@fiodos/cli 0.1.25 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -97,11 +97,16 @@ const { verifyManifest } = require('./verify');
97
97
  const {
98
98
  wireWebOrb, reportWireResult, connectOrbRegistries, reportRegistriesResult,
99
99
  connectOrbSession, reportSessionConnectResult,
100
+ connectOrbScreen, reportScreenConnectResult,
100
101
  } = require('./wireWeb');
101
102
  const { wireReactNativeOrb, reportReactNativeWire, connectRnSession } = require('./wireReactNative');
103
+ const {
104
+ isShopifyThemeRoot, fetchStorefrontSnapshot, wireShopifyTheme, reportShopifyWire,
105
+ } = require('./shopifyTheme');
102
106
  const { wireFlutterOrb, writeFlutterEnv, reportFlutterWire } = require('./wireFlutter');
103
107
  const { wireHandlers, reportHandlerResult } = require('./wireHandlers');
104
108
  const { wireSession, reportSessionResult } = require('./wireSession');
109
+ const { wireScreen, reportScreenResult } = require('./wireScreen');
105
110
  const { offerWriteEnv, printEnvManualReminder } = require('./writeEnv');
106
111
  const { scoreSurface, computeSurface } = require('./scoreSurface');
107
112
  const { buildRunRecord, writeChangeRegistry } = require('./changeRegistry');
@@ -117,6 +122,8 @@ function arg(flag, fallback) {
117
122
  const VALUE_FLAGS = new Set([
118
123
  '--out', '--model', '--max-input-tokens', '--platform', '--api-key', '--api-url',
119
124
  '--spec', '--analysis-root',
125
+ // shopify (theme repo mode): the store's public domain for the catalog snapshot
126
+ '--store',
120
127
  // from-odata (closed-software mode)
121
128
  '--app-id', '--app-name', '--app-description', '--auth-ref', '--entities',
122
129
  ]);
@@ -168,6 +175,11 @@ function logUser(line) {
168
175
  console.log(`${cyan}◉${reset} ${blue}Fiodos${reset} · ${line}`);
169
176
  }
170
177
 
178
+ /** Human-readable pointer to the per-run change registry written in the repo. */
179
+ function changeLogUserMessage(paths) {
180
+ return `We saved a summary of every file we changed — see ${paths.mdRel} (past runs in .fyodos/changes/)`;
181
+ }
182
+
171
183
  /**
172
184
  * Ask the developer to confirm before the analysis runs. `yes` continues; any
173
185
  * other answer (or `no`) cancels instantly — the command ends without analyzing.
@@ -192,9 +204,11 @@ function askProceed() {
192
204
  });
193
205
  }
194
206
 
195
- /** Spinner copy: "website" for web targets, "app" for mobile/native. */
207
+ /** Spinner copy: "website" for web targets, "store" for Shopify, "app" for mobile/native. */
196
208
  function surfaceNoun(platform) {
197
- return platform === 'web' ? 'website' : 'app';
209
+ if (platform === 'web') return 'website';
210
+ if (platform === 'shopify') return 'store';
211
+ return 'app';
198
212
  }
199
213
 
200
214
  function orbMountSpinnerLabel(platform) {
@@ -293,6 +307,20 @@ async function main() {
293
307
  }
294
308
  }
295
309
 
310
+ // ── Rewire-only mode: `fiodos rewire <dir>` or `fiodos <dir> --wire-only` ──
311
+ // Re-runs ONLY the handler wiring (verify → correct → retry, same loop as the
312
+ // install) from the saved artifacts of the LAST analysis. No re-analysis, no
313
+ // quota, no manifest publish — the repair path when some action ended up
314
+ // "manual" (Context/dashboard point developers here).
315
+ {
316
+ const pos = positionalArgs();
317
+ if (pos[0] === 'rewire' || process.argv.includes('--wire-only')) {
318
+ const target = pos[0] === 'rewire' ? pos[1] || '.' : pos[0] || '.';
319
+ await runRewireOnly(target);
320
+ return;
321
+ }
322
+ }
323
+
296
324
  // Consent gate: confirm before doing anything. `yes` continues; `no` (or any
297
325
  // other answer) cancels instantly — no analysis is run, the command just ends.
298
326
  const proceed = await askProceed();
@@ -329,7 +357,7 @@ async function main() {
329
357
  fs.mkdirSync(outDir, { recursive: true });
330
358
 
331
359
  const platformArg = (arg('--platform', '') || '').toLowerCase();
332
- const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android'];
360
+ const KNOWN_PLATFORMS = ['web', 'mobile', 'flutter', 'ios', 'android', 'shopify'];
333
361
  const forcedPlatform = KNOWN_PLATFORMS.includes(platformArg) ? platformArg : null;
334
362
 
335
363
  // ── 1. Static route scan (cheap facts; also the route verifier's ground truth)
@@ -369,6 +397,24 @@ async function main() {
369
397
 
370
398
  // ── 2. Collect source files (rule-based, budget-aware) ────────────────────
371
399
  const { included, omitted, estimatedTokens } = collectFiles(analysisRoot, { maxInputTokens, platform });
400
+
401
+ // Shopify: the theme alone doesn't say WHAT the store sells. Fetch a PUBLIC
402
+ // storefront snapshot (collections + product sample) from the store's own
403
+ // endpoints and include it as synthetic FILE blocks (_fyodos/storefront/*)
404
+ // so the analysis personalizes routes/bubbles/examples to the REAL catalog
405
+ // — and so evidence verification has concrete files to point at. Fail-open:
406
+ // an unreachable store still gets the universal surface file.
407
+ if (platform === 'shopify') {
408
+ const storeDomain = arg('--store', process.env.FYODOS_SHOPIFY_STORE || '');
409
+ if (!storeDomain) {
410
+ logUser('Tip: add --store your-store.com so the analysis reads your live catalog (collections & products).');
411
+ }
412
+ const snapshot = await withSpinner('Reading your store\u2019s public catalog', () =>
413
+ fetchStorefrontSnapshot(storeDomain, { log: logDev }),
414
+ );
415
+ included.push(...snapshot.files);
416
+ }
417
+
372
418
  fs.writeFileSync(path.join(outDir, 'files-sent.json'), JSON.stringify({
373
419
  included: included.map((f) => f.rel),
374
420
  omitted,
@@ -398,6 +444,10 @@ async function main() {
398
444
  // The AI's "session" block: WHERE the app's auth/session state lives, so the
399
445
  // orb's isAuthenticated/getUserId are wired automatically (no manual step).
400
446
  let sessionWiring = null;
447
+ // The AI's "screen" block: whether screens are URL-driven or CLIENT STATE,
448
+ // and where that state lives — so the orb knows which screen the user is on
449
+ // even when the URL never changes (see wireScreen.js).
450
+ let screenWiring = null;
401
451
  let analysisMeta = { engine: 'auto-manifest-v3-ai-first', model: null, costUSD: 0 };
402
452
  // Signed proof returned by the hosted-analysis proxy: it tells the publish
403
453
  // step the quota was already consumed at /v1/developer/analyze (no double
@@ -461,6 +511,7 @@ async function main() {
461
511
  evidence = parsed.evidence || {};
462
512
  wiring = parsed.wiring || {};
463
513
  sessionWiring = parsed.session || null;
514
+ screenWiring = parsed.screen || null;
464
515
  // Only meaningful when the developer passed --spec; the backend sanitizes
465
516
  // (context fields only, real intents only) before touching any override.
466
517
  specResets = (userSpec && parsed.specResets && typeof parsed.specResets === 'object')
@@ -475,7 +526,7 @@ async function main() {
475
526
  // Actions are always verified against real code. Routes are verified only
476
527
  // when there is filesystem ground truth (mobile); for web they are kept as
477
528
  // AI-proposed and flagged unverified in provenance.
478
- ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes }));
529
+ ({ manifest, provenance } = verifyManifest(proposed, evidence, included, routes, { verifyRoutes, platform }));
479
530
  ensureBackRoute(manifest, provenance);
480
531
  const droppedActions = provenance.actions.filter((a) => !a.included).length;
481
532
  const droppedRoutes = provenance.routes.filter((r) => !r.included).length;
@@ -484,7 +535,7 @@ async function main() {
484
535
  logUser('Analysis complete');
485
536
 
486
537
  fs.writeFileSync(path.join(outDir, 'evidence.json'), JSON.stringify({
487
- evidence, wiring, session: sessionWiring, uncertain: parsed.uncertain || [],
538
+ evidence, wiring, session: sessionWiring, screen: screenWiring, uncertain: parsed.uncertain || [],
488
539
  }, null, 2));
489
540
  fs.writeFileSync(path.join(outDir, 'usage.json'), JSON.stringify({
490
541
  model, elapsedMs, usage,
@@ -618,6 +669,7 @@ async function main() {
618
669
  let handlerWireResult = null;
619
670
  let registryWireResult = null;
620
671
  let sessionWireResult = null;
672
+ let screenWireResult = null;
621
673
  const { runPostWireTest } = require('./postWireTest');
622
674
  const selfCorrect = !process.argv.includes('--no-self-correct');
623
675
  // Corrections follow the same key model as the analysis: hosted (proxy)
@@ -765,6 +817,46 @@ async function main() {
765
817
  }
766
818
  }
767
819
 
820
+ // Fourth pass — SCREEN-TRUTH wiring. For apps whose screens are CLIENT
821
+ // STATE (a section switcher) instead of URLs, connect the active-screen
822
+ // state to the orb so it truly knows where the user is: per-screen
823
+ // bubble, "you are already there" detection and screen-scoped context.
824
+ // Same consent/verification/revert model as session wiring.
825
+ if (screenWiring && !process.argv.includes('--no-orb-wire')) {
826
+ const handlerDeclined =
827
+ handlerResult &&
828
+ (handlerResult.status === 'declined' || handlerResult.status === 'declined-noninteractive');
829
+ if (!handlerDeclined) {
830
+ const handlerConsented =
831
+ handlerResult &&
832
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
833
+ const screenResult = await wireScreen(analysisRoot, {
834
+ screen: screenWiring,
835
+ colors: fyodosTermColors(),
836
+ quiet: !isVerbose(),
837
+ assumeYes: assumeYes || Boolean(handlerConsented),
838
+ noWire,
839
+ framework: 'web',
840
+ appName: manifest.appName,
841
+ runTest: !process.argv.includes('--no-wire-test'),
842
+ testRunner: runPostWireTest,
843
+ });
844
+ screenWireResult = screenResult;
845
+ report(() => reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() }));
846
+ if (
847
+ orbMounted &&
848
+ (screenResult.status === 'applied' || screenResult.status === 'applied-untested')
849
+ ) {
850
+ const connectedScreen = connectOrbScreen(analysisRoot, {
851
+ screenFileAbs: screenResult.screenFile,
852
+ });
853
+ report(() =>
854
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() }),
855
+ );
856
+ }
857
+ }
858
+ }
859
+
768
860
  // Finalize the install proof: now that the (slow) action wiring is done,
769
861
  // tell the dashboard the terminal status so its "actions connected" tick
770
862
  // turns green for real — never before wiring actually finished. Only do
@@ -779,6 +871,7 @@ async function main() {
779
871
  actionsStatus: status,
780
872
  actionsTotal: total,
781
873
  actionsDone: done,
874
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
782
875
  });
783
876
  }
784
877
  };
@@ -833,10 +926,100 @@ async function main() {
833
926
  handlerResult: handlerWireResult,
834
927
  registryResult: registryWireResult,
835
928
  sessionResult: sessionWireResult,
929
+ screenResult: screenWireResult,
836
930
  envResult: envWriteResult,
837
931
  });
838
932
  const paths = writeChangeRegistry(analysisRoot, record);
839
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
933
+ logUser(changeLogUserMessage(paths));
934
+ } catch (e) {
935
+ logDev(`[change-registry] could not write: ${e.message}`);
936
+ }
937
+ }
938
+ } else if (platform === 'shopify' && publishing) {
939
+ // ── Shopify: publish + embed the orb in layout/theme.liquid ───────────────
940
+ // No code wiring: the manifest's commerce actions carry declarative
941
+ // `execution` blocks that the embed turns into handlers at runtime
942
+ // (buildApiActionRegistries, {baseUrl} → the store's own origin). The one
943
+ // edit is the theme.liquid snippet (idempotent markers); Shopify's GitHub
944
+ // sync deploys it when the developer pushes.
945
+ loadAppEnv(analysisRoot);
946
+ const { apiKey, apiUrl } = resolveApiTarget();
947
+ const noWire = process.argv.includes('--no-wire');
948
+ let orbWireResult = null;
949
+
950
+ await withSpinner('Publishing to your project', async ({ setLabel, pause, resume }) => {
951
+ await publishManifest(manifest, publishMeta, analysisToken, { skipSuccessLog: true });
952
+ if (!noWire && !process.argv.includes('--no-orb-wire')) {
953
+ setLabel(orbMountSpinnerLabel(platform));
954
+ orbWireResult = wireShopifyTheme(analysisRoot, { apiKey, apiUrl, noWire });
955
+ pause();
956
+ reportShopifyWire(orbWireResult, fyodosTermColors(), { quiet: !isVerbose() });
957
+ resume();
958
+ // Install proof only when the snippet is genuinely in the theme. All
959
+ // actions are execution-declared (nothing left to wire), so the
960
+ // terminal status is 'done' immediately — the dashboard tick then
961
+ // waits only for the runtime heartbeat once the theme is pushed.
962
+ if (
963
+ apiKey &&
964
+ (orbWireResult.status === 'added' || orbWireResult.status === 'already')
965
+ ) {
966
+ const proof = await postOrbWired({
967
+ apiKey,
968
+ apiUrl,
969
+ file: orbWireResult.file || '',
970
+ platform: 'shopify',
971
+ actionsStatus: 'done',
972
+ });
973
+ if (proof && proof.ok === false && proof.reason !== 'no-api-key') {
974
+ logDev(`[orb-wired] shopify install proof not recorded (${proof.status || proof.reason}); dashboard will rely on the runtime ping`);
975
+ }
976
+ }
977
+ }
978
+ });
979
+ logUser('Published to your project');
980
+ if (orbWireResult && (orbWireResult.status === 'added' || orbWireResult.status === 'already')) {
981
+ logUser('Commit & push this theme repo.');
982
+ // Shopify's GitHub sync only auto-updates the storefront when the
983
+ // CONNECTED theme is already the LIVE one. A brand-new theme connected
984
+ // from GitHub stays a draft until published ONCE by hand — every push
985
+ // after that keeps it live automatically. Without this line the orb can
986
+ // be fully wired and pushed and still never appear to real customers,
987
+ // with nothing in the CLI output explaining why.
988
+ logUser(
989
+ 'If this is already your LIVE theme, that push alone puts the orb on your store. ' +
990
+ 'If it\u2019s still a DRAFT (check the Shopify admin theme badge), publish it ONCE — ' +
991
+ 'Online Store \u2192 Themes \u2192 \u22ef \u2192 Publish \u2014 then every future push stays live automatically.',
992
+ );
993
+ // New/trial Shopify stores are password-protected by default, and a
994
+ // password-protected storefront renders layout/password.liquid INSTEAD
995
+ // of layout/theme.liquid on every page — so without also wiring that
996
+ // layout (done above when the theme has one) the orb would be
997
+ // correctly installed and published yet invisible behind the password
998
+ // gate. Surfacing this explicitly since it is the #1 "installed
999
+ // correctly but nothing shows up" report on a fresh store.
1000
+ if (orbWireResult.password) {
1001
+ logUser(
1002
+ orbWireResult.password.status === 'failed'
1003
+ ? `Heads up: your store looks password-protected but the orb could not be added to ${orbWireResult.password.file} (${orbWireResult.password.reason}) — it will be invisible until you remove the password or fix that layout.`
1004
+ : 'Your theme has a password page (layout/password.liquid) — the orb is embedded there too, so it still shows up while your store is password-protected.',
1005
+ );
1006
+ } else {
1007
+ logUser(
1008
+ 'Heads up: if your store is still password-protected (common on new/trial stores) and your theme has NO separate layout/password.liquid, the orb won\u2019t show on the password page — remove the password (Online Store \u2192 Preferences) to test, or check under a real domain/plan.',
1009
+ );
1010
+ }
1011
+ }
1012
+
1013
+ if (!noWire) {
1014
+ try {
1015
+ const record = buildRunRecord(analysisRoot, {
1016
+ appName: manifest.appName,
1017
+ manifest,
1018
+ publishMeta,
1019
+ orbResult: orbWireResult,
1020
+ });
1021
+ const paths = writeChangeRegistry(analysisRoot, record);
1022
+ logUser(changeLogUserMessage(paths));
840
1023
  } catch (e) {
841
1024
  logDev(`[change-registry] could not write: ${e.message}`);
842
1025
  }
@@ -959,7 +1142,7 @@ async function main() {
959
1142
  envResult: envWriteResult,
960
1143
  });
961
1144
  const paths = writeChangeRegistry(analysisRoot, record);
962
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1145
+ logUser(changeLogUserMessage(paths));
963
1146
  } catch (e) {
964
1147
  logDev(`[change-registry] could not write: ${e.message}`);
965
1148
  }
@@ -1038,7 +1221,7 @@ async function main() {
1038
1221
  envResult: envWriteResult,
1039
1222
  });
1040
1223
  const paths = writeChangeRegistry(analysisRoot, record);
1041
- logUser(`Change log: ${paths.mdRel} (history in .fyodos/changes/)`);
1224
+ logUser(changeLogUserMessage(paths));
1042
1225
  } catch (e) {
1043
1226
  logDev(`[change-registry] could not write: ${e.message}`);
1044
1227
  }
@@ -1131,6 +1314,9 @@ function detectPlatform(appRoot, appDir) {
1131
1314
  return found;
1132
1315
  };
1133
1316
 
1317
+ // Shopify theme repos (GitHub-synced Liquid themes) are unmistakable and
1318
+ // have no package.json framework signal — check them first.
1319
+ if (isShopifyThemeRoot(appRoot)) return 'shopify';
1134
1320
  if (has('pubspec.yaml')) return 'flutter';
1135
1321
  if (has('Package.swift') || hasAnyExt('.xcodeproj') || (has('Sources') && hasAnyExt('.swift'))) return 'ios';
1136
1322
  if ((has('settings.gradle.kts') || has('settings.gradle') || has('build.gradle.kts') || has('build.gradle')) && hasAnyExt('.kt')) {
@@ -1563,6 +1749,7 @@ async function postOrbWired({
1563
1749
  actionsStatus = 'pending',
1564
1750
  actionsTotal = 0,
1565
1751
  actionsDone = 0,
1752
+ actions = null,
1566
1753
  }) {
1567
1754
  if (!apiKey) return { ok: false, reason: 'no-api-key' };
1568
1755
  try {
@@ -1575,6 +1762,11 @@ async function postOrbWired({
1575
1762
  actionsStatus,
1576
1763
  actionsTotal,
1577
1764
  actionsDone,
1765
+ // Per-action wiring detail (intent/status/reason). Powers the backend's
1766
+ // "wired or nonexistent" invariant (unwired actions never reach the
1767
+ // agent prompt) and the dashboard/Context wiring diagnosis. Older
1768
+ // backends ignore the extra field.
1769
+ ...(Array.isArray(actions) ? { actions } : {}),
1578
1770
  }),
1579
1771
  });
1580
1772
  if (res.ok) {
@@ -1589,6 +1781,210 @@ async function postOrbWired({
1589
1781
  }
1590
1782
  }
1591
1783
 
1784
+ /**
1785
+ * Rewire-only mode (`fiodos rewire <dir>` / `--wire-only`): re-run JUST the
1786
+ * handler wiring from the saved artifacts of the last analysis (manifest.json +
1787
+ * evidence.json in the analysis output dir). The full verify→diagnose→correct→
1788
+ * retry loop runs again — including AI corrections — so an action that fell to
1789
+ * "manual" in the install gets fresh attempts WITHOUT a re-analysis (no quota,
1790
+ * no manifest publish, no orb re-mount). Ends by re-posting the per-action
1791
+ * wiring status so the dashboard/Context see the repaired state.
1792
+ */
1793
+ async function runRewireOnly(target) {
1794
+ const inputRoot = path.resolve(target || '.');
1795
+ const safeName = path.basename(inputRoot).replace(/[^a-z0-9_-]/gi, '_') || 'app';
1796
+ const outDir = arg('--out', path.join(os.tmpdir(), 'fiodos', 'analysis', safeName));
1797
+
1798
+ const manifestPath = path.join(outDir, 'manifest.json');
1799
+ const evidencePath = path.join(outDir, 'evidence.json');
1800
+ if (!fs.existsSync(manifestPath) || !fs.existsSync(evidencePath)) {
1801
+ logUser('No saved analysis found for this project — run a full analysis first:');
1802
+ logUser(` npx fiodos analyze ${target || '.'} --publish`);
1803
+ logUser(`(looked in ${outDir}; pass --out <dir> if you analyzed with a custom output dir)`);
1804
+ process.exitCode = 1;
1805
+ return;
1806
+ }
1807
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1808
+ const ev = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));
1809
+ const evidence = ev.evidence || {};
1810
+ const wiring = ev.wiring || {};
1811
+
1812
+ const scope = resolveAnalysisRoot(inputRoot, 'web');
1813
+ const analysisRoot = scope.root;
1814
+ loadAppEnv(analysisRoot);
1815
+ const { apiKey, apiUrl } = resolveApiTarget();
1816
+ const model = resolveModel(arg('--model', '') || DEFAULT_MODEL);
1817
+ const assumeYes = process.argv.includes('--yes') || process.argv.includes('--wire-yes');
1818
+ const { runPostWireTest } = require('./postWireTest');
1819
+ const { included } = collectFiles(analysisRoot, { maxInputTokens: Number(arg('--max-input-tokens', '150000')), platform: 'web' });
1820
+
1821
+ const selfCorrect = !process.argv.includes('--no-self-correct');
1822
+ const correctorUseProxy = !process.argv.includes('--own-ai-key');
1823
+ const corrector = selfCorrect
1824
+ ? async (args) => {
1825
+ const { wiring: fixed } = await correctActionWiring({
1826
+ ...args, model, platform: 'web', useProxy: correctorUseProxy, apiKey, apiUrl,
1827
+ });
1828
+ return fixed;
1829
+ }
1830
+ : null;
1831
+
1832
+ logUser(`Rewiring actions from the saved analysis (${manifest.actions.length} action(s), no re-analysis)…`);
1833
+ let handlerResult = null;
1834
+ await withSpinner(wiringSpinnerLabel('web'), async ({ pause, resume }) => {
1835
+ if (!assumeYes) pause();
1836
+ handlerResult = await wireHandlers(analysisRoot, {
1837
+ manifest,
1838
+ evidence,
1839
+ wiring,
1840
+ appName: manifest.appName,
1841
+ colors: fyodosTermColors(),
1842
+ assumeYes,
1843
+ noWire: false,
1844
+ quiet: !isVerbose(),
1845
+ runTest: !process.argv.includes('--no-wire-test'),
1846
+ revertOnFailure: true,
1847
+ testRunner: runPostWireTest,
1848
+ corrector,
1849
+ files: included,
1850
+ maxAttempts: Number(process.env.FYODOS_WIRE_MAX_ATTEMPTS || 3),
1851
+ });
1852
+ if (!assumeYes) resume();
1853
+ pause();
1854
+ reportHandlerResult(handlerResult, fyodosTermColors(), { quiet: !isVerbose() });
1855
+ resume();
1856
+
1857
+ const handlerApplied =
1858
+ handlerResult &&
1859
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested') &&
1860
+ (handlerResult.autoCount || 0) > 0 &&
1861
+ handlerResult.registryFile;
1862
+ if (handlerApplied) {
1863
+ const connected = connectOrbRegistries(analysisRoot, {
1864
+ registryFileAbs: handlerResult.registryFile,
1865
+ });
1866
+ pause();
1867
+ reportRegistriesResult(connected, fyodosTermColors(), { quiet: !isVerbose() });
1868
+ resume();
1869
+ }
1870
+ });
1871
+
1872
+ // Session + screen wiring also replay from the saved analysis (same consent
1873
+ // model as the main flow: an APPLIED handler wiring proves the "yes").
1874
+ const handlerConsented =
1875
+ handlerResult &&
1876
+ (handlerResult.status === 'applied' || handlerResult.status === 'applied-untested');
1877
+ if (ev.session) {
1878
+ const sessionResult = await wireSession(analysisRoot, {
1879
+ session: ev.session,
1880
+ colors: fyodosTermColors(),
1881
+ quiet: !isVerbose(),
1882
+ assumeYes: assumeYes || Boolean(handlerConsented),
1883
+ framework: 'web',
1884
+ appName: manifest.appName,
1885
+ runTest: !process.argv.includes('--no-wire-test'),
1886
+ testRunner: runPostWireTest,
1887
+ });
1888
+ reportSessionResult(sessionResult, fyodosTermColors(), { quiet: !isVerbose() });
1889
+ if (sessionResult.status === 'applied' || sessionResult.status === 'applied-untested') {
1890
+ const connectedSession = connectOrbSession(analysisRoot, {
1891
+ sessionFileAbs: sessionResult.sessionFile,
1892
+ });
1893
+ reportSessionConnectResult(connectedSession, fyodosTermColors(), { quiet: !isVerbose() });
1894
+ }
1895
+ }
1896
+ if (ev.screen) {
1897
+ const screenResult = await wireScreen(analysisRoot, {
1898
+ screen: ev.screen,
1899
+ colors: fyodosTermColors(),
1900
+ quiet: !isVerbose(),
1901
+ assumeYes: assumeYes || Boolean(handlerConsented),
1902
+ framework: 'web',
1903
+ appName: manifest.appName,
1904
+ runTest: !process.argv.includes('--no-wire-test'),
1905
+ testRunner: runPostWireTest,
1906
+ });
1907
+ reportScreenResult(screenResult, fyodosTermColors(), { quiet: !isVerbose() });
1908
+ if (screenResult.status === 'applied' || screenResult.status === 'applied-untested') {
1909
+ const connectedScreen = connectOrbScreen(analysisRoot, {
1910
+ screenFileAbs: screenResult.screenFile,
1911
+ });
1912
+ reportScreenConnectResult(connectedScreen, fyodosTermColors(), { quiet: !isVerbose() });
1913
+ }
1914
+ }
1915
+
1916
+ if (apiKey && handlerResult) {
1917
+ const { status, total, done } = actionsWiredFromHandlerResult(handlerResult);
1918
+ await postOrbWired({
1919
+ apiKey,
1920
+ apiUrl,
1921
+ file: (handlerResult.registryRel || ''),
1922
+ actionsStatus: status,
1923
+ actionsTotal: total,
1924
+ actionsDone: done,
1925
+ actions: actionsDetailFromHandlerResult(manifest, handlerResult),
1926
+ });
1927
+ }
1928
+ const wired = (handlerResult && handlerResult.autoCount) || 0;
1929
+ const manual = (handlerResult && handlerResult.manualCount) || 0;
1930
+ logUser(`Rewire finished: ${wired} action(s) wired, ${manual} still need attention.`);
1931
+ }
1932
+
1933
+ /**
1934
+ * Per-action wiring detail for the backend (POST /v1/developer/orb-wired).
1935
+ * One entry per manifest action:
1936
+ * { intent, handler, status: 'wired'|'manual'|'link', reason?, attempts? }
1937
+ * 'wired' → verified and installed (the action really runs);
1938
+ * 'manual' → could not be wired/verified — the backend EXCLUDES it from the
1939
+ * agent surface ("wired or nonexistent" invariant) and the
1940
+ * dashboard/Context can show and repair it;
1941
+ * 'link' → navigation/link action, needs no handler (always available).
1942
+ * Returns null when wiring did not actually run (declined/skipped): with no
1943
+ * verdict the backend must not filter anything.
1944
+ */
1945
+ function actionsDetailFromHandlerResult(manifest, handlerResult) {
1946
+ const r = handlerResult || {};
1947
+ const ranStatuses = new Set(['applied', 'applied-untested', 'manual-only', 'reverted', 'failed']);
1948
+ if (!ranStatuses.has(r.status)) return null;
1949
+ const plan = r.plan || {};
1950
+ const autoByIntent = new Map((plan.auto || []).map((e) => [e.intent, e]));
1951
+ const reviewByIntent = new Map((plan.review || plan.manual || []).map((e) => [e.intent, e]));
1952
+ const verifByIntent = new Map((r.verification || []).map((v) => [v.intent, v]));
1953
+ const wholesaleFailed = r.status === 'reverted' || r.status === 'failed';
1954
+
1955
+ const detail = [];
1956
+ for (const a of (manifest && manifest.actions) || []) {
1957
+ const intent = a.intent;
1958
+ if (a.kind === 'link') {
1959
+ detail.push({ intent, handler: '', status: 'link' });
1960
+ continue;
1961
+ }
1962
+ const verif = verifByIntent.get(intent);
1963
+ if (!wholesaleFailed && autoByIntent.has(intent)) {
1964
+ detail.push({
1965
+ intent,
1966
+ handler: a.handler || intent,
1967
+ status: 'wired',
1968
+ ...(verif && verif.level ? { level: verif.level } : {}),
1969
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1970
+ });
1971
+ continue;
1972
+ }
1973
+ const rev = reviewByIntent.get(intent);
1974
+ const reason = wholesaleFailed
1975
+ ? `wiring ${r.status}: the combined install did not pass the build safety net`
1976
+ : String((rev && rev.reason) || (verif && verif.error) || 'could not be wired automatically');
1977
+ detail.push({
1978
+ intent,
1979
+ handler: a.handler || intent,
1980
+ status: 'manual',
1981
+ reason: reason.split('|')[0].replace(/\s+/g, ' ').trim().slice(0, 300),
1982
+ ...(verif && verif.attempts ? { attempts: verif.attempts } : {}),
1983
+ });
1984
+ }
1985
+ return detail;
1986
+ }
1987
+
1592
1988
  // Map a wireHandlers() result to the terminal action-wiring status the backend
1593
1989
  // records, plus how many actions ended up wired. Drives the onboarding's third
1594
1990
  // "actions connected" tick so it only goes green once wiring truly finished.
@@ -127,6 +127,13 @@ function summarizeBuildOutput(raw, maxLines = 4) {
127
127
  return picked.slice(0, maxLines).join('\n').trim();
128
128
  }
129
129
 
130
+ /** Does this output contain a signal that the CODE is broken (vs npm/env noise)? */
131
+ function looksLikeCompileFailure(output) {
132
+ return /error\s+TS\d+|Type error:|Module not found|Can't resolve|Failed to compile|SyntaxError|Parsing error|Cannot find (?:module|name)/i.test(
133
+ String(output || ''),
134
+ );
135
+ }
136
+
130
137
  async function runPostWireTest(appRoot, opts = {}) {
131
138
  const timeoutMs = opts.timeoutMs || 240000;
132
139
  if (!fs.existsSync(path.join(appRoot, 'node_modules'))) {
@@ -143,12 +150,26 @@ async function runPostWireTest(appRoot, opts = {}) {
143
150
  return { ok: true, stage: 'skipped-no-deps', command: '(none)', output: 'no build/typecheck script.' };
144
151
  }
145
152
 
146
- const res = await runAsync('npm', chosen.args, {
147
- cwd: appRoot,
148
- timeout: timeoutMs,
149
- env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
150
- maxBuffer: 64 * 1024 * 1024,
151
- });
153
+ const runOnce = () =>
154
+ runAsync('npm', chosen.args, {
155
+ cwd: appRoot,
156
+ timeout: timeoutMs,
157
+ env: { ...process.env, CI: '1', NODE_ENV: process.env.NODE_ENV || 'production' },
158
+ maxBuffer: 64 * 1024 * 1024,
159
+ });
160
+
161
+ let res = await runOnce();
162
+ // A spawn-level or npm-level failure with ZERO compile diagnostics is
163
+ // environment noise (npm cache contention, a concurrent process touching
164
+ // package.json, transient ENOENT), not broken code. Blaming the action for
165
+ // it drops perfectly good wiring, so retry once before judging.
166
+ const npmLevelFailure =
167
+ (res.error && res.error.code !== 'ETIMEDOUT') ||
168
+ (res.status !== 0 && res.status !== null && !looksLikeCompileFailure(`${res.stdout}\n${res.stderr}`));
169
+ if (npmLevelFailure) {
170
+ await new Promise((r) => setTimeout(r, 1500));
171
+ res = await runOnce();
172
+ }
152
173
 
153
174
  if (res.error && res.error.code === 'ETIMEDOUT') {
154
175
  return { ok: false, stage: 'timeout', command: chosen.label, output: `build exceeded ${Math.round(timeoutMs / 1000)}s` };