@reticlehq/init 2.14.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 (114) hide show
  1. package/LICENSE +105 -0
  2. package/NOTICE +5 -0
  3. package/README.md +29 -0
  4. package/dist/agent-rules.d.ts +116 -0
  5. package/dist/agent-rules.js +279 -0
  6. package/dist/app-choice.d.ts +29 -0
  7. package/dist/app-choice.js +39 -0
  8. package/dist/astro-host.d.ts +21 -0
  9. package/dist/astro-host.js +27 -0
  10. package/dist/astro-patch.d.ts +33 -0
  11. package/dist/astro-patch.js +103 -0
  12. package/dist/brace-scan.d.ts +24 -0
  13. package/dist/brace-scan.js +46 -0
  14. package/dist/capabilities.d.ts +40 -0
  15. package/dist/capabilities.js +167 -0
  16. package/dist/closing-hint.d.ts +35 -0
  17. package/dist/closing-hint.js +122 -0
  18. package/dist/codex-toml.d.ts +10 -0
  19. package/dist/codex-toml.js +144 -0
  20. package/dist/connect-steps.d.ts +55 -0
  21. package/dist/connect-steps.js +92 -0
  22. package/dist/cra.d.ts +82 -0
  23. package/dist/cra.js +183 -0
  24. package/dist/csp-check.d.ts +49 -0
  25. package/dist/csp-check.js +168 -0
  26. package/dist/csp-doctor.d.ts +32 -0
  27. package/dist/csp-doctor.js +62 -0
  28. package/dist/desktop-doctor.d.ts +46 -0
  29. package/dist/desktop-doctor.js +201 -0
  30. package/dist/detect.d.ts +225 -0
  31. package/dist/detect.js +334 -0
  32. package/dist/dev-script.d.ts +51 -0
  33. package/dist/dev-script.js +66 -0
  34. package/dist/dev-server-ports.d.ts +20 -0
  35. package/dist/dev-server-ports.js +45 -0
  36. package/dist/electron-patch.d.ts +11 -0
  37. package/dist/electron-patch.js +70 -0
  38. package/dist/electron-vite-patch.d.ts +12 -0
  39. package/dist/electron-vite-patch.js +100 -0
  40. package/dist/existing-config.d.ts +31 -0
  41. package/dist/existing-config.js +63 -0
  42. package/dist/format-generated.d.ts +18 -0
  43. package/dist/format-generated.js +48 -0
  44. package/dist/framework-adapter.d.ts +60 -0
  45. package/dist/framework-adapter.js +138 -0
  46. package/dist/host.d.ts +41 -0
  47. package/dist/host.js +21 -0
  48. package/dist/index.d.ts +25 -0
  49. package/dist/index.js +25 -0
  50. package/dist/init-failure.d.ts +19 -0
  51. package/dist/init-failure.js +18 -0
  52. package/dist/init-opt-out.d.ts +17 -0
  53. package/dist/init-opt-out.js +30 -0
  54. package/dist/install-hint.d.ts +9 -0
  55. package/dist/install-hint.js +41 -0
  56. package/dist/install-retries.d.ts +22 -0
  57. package/dist/install-retries.js +65 -0
  58. package/dist/install-source-config.d.ts +18 -0
  59. package/dist/install-source-config.js +34 -0
  60. package/dist/lockfiles.d.ts +14 -0
  61. package/dist/lockfiles.js +49 -0
  62. package/dist/mcp-clients.d.ts +94 -0
  63. package/dist/mcp-clients.js +327 -0
  64. package/dist/mcp-registered.d.ts +12 -0
  65. package/dist/mcp-registered.js +14 -0
  66. package/dist/mcp.d.ts +89 -0
  67. package/dist/mcp.js +129 -0
  68. package/dist/next-patch.d.ts +49 -0
  69. package/dist/next-patch.js +237 -0
  70. package/dist/node-io.d.ts +22 -0
  71. package/dist/node-io.js +150 -0
  72. package/dist/non-js-project.d.ts +44 -0
  73. package/dist/non-js-project.js +137 -0
  74. package/dist/nuxt-patch.d.ts +18 -0
  75. package/dist/nuxt-patch.js +33 -0
  76. package/dist/patch-kind.d.ts +20 -0
  77. package/dist/patch-kind.js +10 -0
  78. package/dist/plan-electron-vite.d.ts +16 -0
  79. package/dist/plan-electron-vite.js +72 -0
  80. package/dist/plan-framework.d.ts +122 -0
  81. package/dist/plan-framework.js +773 -0
  82. package/dist/plan.d.ts +312 -0
  83. package/dist/plan.js +650 -0
  84. package/dist/platform.d.ts +16 -0
  85. package/dist/platform.js +16 -0
  86. package/dist/preflight.d.ts +30 -0
  87. package/dist/preflight.js +52 -0
  88. package/dist/project-id.d.ts +12 -0
  89. package/dist/project-id.js +25 -0
  90. package/dist/refresh-rules.d.ts +40 -0
  91. package/dist/refresh-rules.js +51 -0
  92. package/dist/remember-project.d.ts +24 -0
  93. package/dist/remember-project.js +40 -0
  94. package/dist/run.d.ts +122 -0
  95. package/dist/run.js +762 -0
  96. package/dist/slash-command.d.ts +27 -0
  97. package/dist/slash-command.js +132 -0
  98. package/dist/snippets.d.ts +267 -0
  99. package/dist/snippets.js +898 -0
  100. package/dist/tanstack-start.d.ts +18 -0
  101. package/dist/tanstack-start.js +57 -0
  102. package/dist/version.d.ts +18 -0
  103. package/dist/version.js +23 -0
  104. package/dist/vite-config.d.ts +18 -0
  105. package/dist/vite-config.js +111 -0
  106. package/dist/vite-owning-config.d.ts +25 -0
  107. package/dist/vite-owning-config.js +219 -0
  108. package/dist/windows-quote.d.ts +38 -0
  109. package/dist/windows-quote.js +70 -0
  110. package/dist/workspace-apps.d.ts +40 -0
  111. package/dist/workspace-apps.js +188 -0
  112. package/dist/workspace-redirect.d.ts +22 -0
  113. package/dist/workspace-redirect.js +97 -0
  114. package/package.json +53 -0
package/dist/run.js ADDED
@@ -0,0 +1,762 @@
1
+ /**
2
+ * The impure shell for `reticle init`: gather project files via an injected IO surface, build the
3
+ * plan (pure), optionally write the apply-steps, and print a human-readable report. All filesystem
4
+ * access goes through `InitIo` so the orchestration is unit-testable with an in-memory IO.
5
+ */
6
+ import { dirname, join } from 'node:path';
7
+ import { CSP_FILES } from './csp-doctor.js';
8
+ import { preflightRefusal } from './preflight.js';
9
+ import { detectDjangoProject, detectStreamlitProject, djangoSetupMessage, noPackageJsonMessage, streamlitSetupMessage, } from './non-js-project.js';
10
+ import { devCommandFrom } from './dev-script.js';
11
+ import { restartHint, FEEDBACK_HINT } from './closing-hint.js';
12
+ import { projectIdOf, rememberProjectOnDisk } from './remember-project.js';
13
+ import { detect, Framework, UiLibrary } from './detect.js';
14
+ import { wasMcpRegistered } from './mcp-registered.js';
15
+ import { pickAstroHost } from './astro-host.js';
16
+ import { ELECTRON_VITE_CONFIG_CANDIDATES, NEXT_CONFIG_CANDIDATES, PACKAGE_JSON, VITE_CONFIG_CANDIDATES, } from './workspace-apps.js';
17
+ import { redirectToWorkspaceApp } from './workspace-redirect.js';
18
+ import { isConnectStep } from './connect-steps.js';
19
+ import { CURSOR_RULE_PATH, RETICLE_MD_PATH } from './agent-rules.js';
20
+ import { CRA_ENV_PATH } from './cra.js';
21
+ import { NEXT_LAYOUT_CANDIDATES, NEXT_PAGES_APP_CANDIDATES } from './next-patch.js';
22
+ import { formatGeneratedSource } from './format-generated.js';
23
+ /** CRA's bundled entry, in the order create-react-app itself generates them. */
24
+ const CRA_ENTRY_CANDIDATES = ['src/index.tsx', 'src/index.jsx', 'src/index.ts', 'src/index.js'];
25
+ function craEntryOf(io) {
26
+ for (const path of CRA_ENTRY_CANDIDATES) {
27
+ const source = io.readFile(path);
28
+ if (source !== null)
29
+ return { path, source };
30
+ }
31
+ return null;
32
+ }
33
+ import { DEPS_TARGET, RETICLE_CONFIG_FILE, frameworkPackages, MCP_TARGET, buildPlan, StepStatus, } from './plan.js';
34
+ import { claudeAvailableProbe, claudeExistsProbe } from './mcp.js';
35
+ import { reticleDevLocation } from './next-patch.js';
36
+ import { scanTestids, storeHints, scanStores } from './capabilities.js';
37
+ import { fileBackedClients, clientMarkerRelPath, ConfigScope, McpClient, CURSOR_PROJECT_MARKER, } from './mcp-clients.js';
38
+ import { deriveProjectId, packageName } from './project-id.js';
39
+ import { VITE_DEV_MODULE_PATH, ELECTRON_VITE_DEV_MODULE_PATH, connectArgWithToken, nuxtPluginPath, staticPageSnippet, djangoMiddlewareSnippet, reticleConfigContent, streamlitPageSnippet, } from './snippets.js';
40
+ import { NUXT_CONFIG_CANDIDATES } from './nuxt-patch.js';
41
+ import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH } from './slash-command.js';
42
+ import { RETICLE_VERSION } from './version.js';
43
+ import { InitFailure } from './init-failure.js';
44
+ import { resolveLockfiles } from './lockfiles.js';
45
+ // Re-exported so the existing import site (and its test block) keeps working after the split.
46
+ export { resolveLockfiles };
47
+ const NODE_MODULES_DIR = 'node_modules';
48
+ const SVELTEKIT_HOOKS = 'src/hooks.client.ts';
49
+ const REACT_ROUTER_ENTRY = 'app/entry.client.tsx';
50
+ const TANSTACK_START_ROOT_CANDIDATES = [
51
+ 'src/routes/__root.tsx',
52
+ 'src/routes/__root.jsx',
53
+ 'app/routes/__root.tsx',
54
+ 'app/routes/__root.jsx',
55
+ ];
56
+ const SOURCE_FILE = /\.(tsx|jsx|ts|js|svelte|vue|astro)$/;
57
+ /** Files read for the testid scan. A capabilities block is a hint; reading a whole repo for it is not. */
58
+ const MAX_SCANNED_FILES = 200;
59
+ /** Directories that never hold the app's own source, and are expensive or misleading to read. */
60
+ const NOT_SOURCE_DIRS = new Set([
61
+ 'node_modules',
62
+ 'dist',
63
+ 'build',
64
+ 'out',
65
+ 'coverage',
66
+ 'target',
67
+ 'public',
68
+ 'static',
69
+ '.next',
70
+ '.svelte-kit',
71
+ '.nuxt',
72
+ ]);
73
+ /** How far below the app root to look. Deep enough for `modules/users/UserList.tsx`, not a repo crawl. */
74
+ const MAX_SCAN_DEPTH = 5;
75
+ /**
76
+ * Read a bounded set of the app's source files, for the `data-testid` scan.
77
+ *
78
+ * This used to walk the fixed list `src, src/components, src/pages, app, components`. Reported from
79
+ * the field (#318) by a repo whose app is at `src/admin`: the scan read directories that were not
80
+ * the app's and correctly reported finding nothing in them, so `init` said "no data-testid values
81
+ * yet" about an app with several — which makes an agent go and write the ones already there. That
82
+ * list had already grown once for a `frontend/` app, and a third report of the same shape is what
83
+ * says the answer is not another name in a list. So: walk the app root, bounded by depth and by the
84
+ * same file cap as before.
85
+ */
86
+ function readSourceFiles(io) {
87
+ const out = [];
88
+ const read = (path) => {
89
+ const content = io.readFile(path);
90
+ if (content !== null)
91
+ out.push({ path, source: content });
92
+ };
93
+ for (const name of io.rootFiles()) {
94
+ if (out.length >= MAX_SCANNED_FILES)
95
+ return out;
96
+ if (SOURCE_FILE.test(name))
97
+ read(name);
98
+ }
99
+ const walk = (dir, depth) => {
100
+ for (const name of io.listFiles(dir)) {
101
+ if (out.length >= MAX_SCANNED_FILES)
102
+ return;
103
+ if (SOURCE_FILE.test(name))
104
+ read(`${dir}/${name}`);
105
+ }
106
+ if (depth >= MAX_SCAN_DEPTH)
107
+ return;
108
+ for (const sub of io.listDirs(dir)) {
109
+ if (out.length >= MAX_SCANNED_FILES)
110
+ return;
111
+ if (sub.startsWith('.') || NOT_SOURCE_DIRS.has(sub))
112
+ continue;
113
+ walk(`${dir}/${sub}`, depth + 1);
114
+ }
115
+ };
116
+ for (const dir of io.listDirs('.')) {
117
+ if (out.length >= MAX_SCANNED_FILES)
118
+ return out;
119
+ if (dir.startsWith('.') || NOT_SOURCE_DIRS.has(dir))
120
+ continue;
121
+ walk(dir, 1);
122
+ }
123
+ return out;
124
+ }
125
+ /** Direct dependency names, for naming the state libraries an app actually has. */
126
+ function dependencyNames(pkg) {
127
+ const p = (pkg ?? {});
128
+ return new Set([
129
+ ...Object.keys(p['dependencies'] ?? {}),
130
+ ...Object.keys(p['devDependencies'] ?? {}),
131
+ ]);
132
+ }
133
+ /** electron-vite and the unbundled Electron layouts this repo already ships. */
134
+ const ELECTRON_MAIN_SOURCES = [
135
+ 'src/main/index.ts',
136
+ 'src/main/index.js',
137
+ 'src/main/index.mts',
138
+ 'src/main/index.mjs',
139
+ 'electron/main.cjs',
140
+ 'electron/main.js',
141
+ 'electron/main.ts',
142
+ 'src/main.ts',
143
+ 'src/main.js',
144
+ ];
145
+ const ELECTRON_PRELOAD_SOURCES = [
146
+ 'src/preload/index.ts',
147
+ 'src/preload/index.js',
148
+ 'src/preload/index.mts',
149
+ 'src/preload/index.mjs',
150
+ 'electron/preload.cjs',
151
+ 'electron/preload.js',
152
+ 'electron/preload.ts',
153
+ 'src/preload.ts',
154
+ 'src/preload.js',
155
+ ];
156
+ const ASTRO_CONFIG_CANDIDATES = [
157
+ 'astro.config.mjs',
158
+ 'astro.config.js',
159
+ 'astro.config.ts',
160
+ 'astro.config.cjs',
161
+ ];
162
+ /** Where a conventional Astro app keeps the layout every page wraps itself in. */
163
+ const ASTRO_LAYOUTS_DIR = 'src/layouts';
164
+ /** Also searched: an app with no layouts directory renders the document straight from a page. */
165
+ const ASTRO_PAGES_DIR = 'src/pages';
166
+ /**
167
+ * `⚠` means WORK LEFT TO DO and nothing else — it is what an agent (and the release gate) counts to
168
+ * decide whether the install finished. A notice gets its own mark so "steps remaining" can reach zero
169
+ * on a working install that happens to be on an ungated stack.
170
+ */
171
+ /**
172
+ * A step's status AFTER the run, which is what actually happened to it.
173
+ *
174
+ * `report` applies the same downgrade when printing: a step that failed or was skipped is shown as
175
+ * MANUAL whatever it planned to be. Reading the planned status alone would say a step applied when
176
+ * the run had already given up on it.
177
+ */
178
+ function resolvedStatus(plan, target, failed, skipped) {
179
+ const step = plan.steps.find((s) => s.target === target);
180
+ if (step === undefined)
181
+ return undefined;
182
+ if (failed.has(target) || skipped.has(target))
183
+ return StepStatus.MANUAL;
184
+ return step.status;
185
+ }
186
+ const STATUS_SYMBOL = {
187
+ [StepStatus.APPLY]: '✓',
188
+ [StepStatus.MANUAL]: '⚠',
189
+ [StepStatus.ALREADY]: '·',
190
+ [StepStatus.SKIP]: '–',
191
+ [StepStatus.NOTICE]: 'ℹ',
192
+ };
193
+ function firstPresent(files, candidates) {
194
+ for (const c of candidates)
195
+ if (files.has(c))
196
+ return c;
197
+ return null;
198
+ }
199
+ function firstReadable(io, candidates) {
200
+ for (const path of candidates) {
201
+ const source = io.readFile(path);
202
+ if (source !== null)
203
+ return { path, source };
204
+ }
205
+ return null;
206
+ }
207
+ /**
208
+ * The directory the human's agent runs in, when it is not the app's directory.
209
+ *
210
+ * `undefined` for a single-package repo, which keeps every agent-file path project-relative exactly
211
+ * as it was — the two roots are the same there, which is why writing them beside the app went
212
+ * unnoticed until a repo with its app at `src/admin` reported it.
213
+ */
214
+ function agentRootOf(options) {
215
+ const root = options.agentRoot;
216
+ return root === undefined || root === options.cwd ? undefined : root;
217
+ }
218
+ function gatherPlanInput(options, io, pkg) {
219
+ // Stable identity derived from the app's package.json name + root, so it survives port changes.
220
+ const projectId = deriveProjectId(packageName(pkg), options.cwd);
221
+ const rootFiles = new Set(io.rootFiles());
222
+ const nodeModulesMarkers = new Set(io.listFiles('node_modules'));
223
+ const detectInput = {
224
+ pkg: 'object' === typeof pkg && pkg !== null ? pkg : {},
225
+ configFiles: rootFiles,
226
+ // Walk up for the lockfile so a monorepo sub-package picks the workspace's package manager —
227
+ // unless this package's own installed tree already answers it, which outranks an inherited one.
228
+ lockfiles: resolveLockfiles(rootFiles, options.cwd, io, nodeModulesMarkers),
229
+ // An already-installed tree names its own manager, which matters when no lockfile is committed.
230
+ nodeModulesMarkers,
231
+ };
232
+ const detection = detect(detectInput);
233
+ const vitePath = firstPresent(rootFiles, VITE_CONFIG_CANDIDATES);
234
+ const viteSource = null === vitePath ? null : io.readFile(vitePath);
235
+ const viteConfig = vitePath !== null && viteSource !== null ? { path: vitePath, source: viteSource } : null;
236
+ const electronVitePath = firstPresent(rootFiles, ELECTRON_VITE_CONFIG_CANDIDATES);
237
+ const electronViteSource = null === electronVitePath ? null : io.readFile(electronVitePath);
238
+ const electronViteConfig = electronVitePath !== null && electronViteSource !== null
239
+ ? { path: electronVitePath, source: electronViteSource }
240
+ : null;
241
+ const electronMain = firstReadable(io, ELECTRON_MAIN_SOURCES);
242
+ const electronPreload = firstReadable(io, ELECTRON_PRELOAD_SOURCES);
243
+ // Global MCP registration targets each agent that's present: Claude via its CLI, Cursor via its
244
+ // global config file. Only probe when the MCP step is in play.
245
+ const availableProbe = claudeAvailableProbe();
246
+ const claudeCli = options.mcp ? io.probe(availableProbe.command, availableProbe.args) : false;
247
+ const existsProbe = claudeExistsProbe();
248
+ const mcpExists = claudeCli ? io.probe(existsProbe.command, existsProbe.args) : false;
249
+ // Every MCP client this machine shows evidence of. Conservative and one-directional: we write
250
+ // into a config a client ALREADY has, and never create ~/.gemini or ~/.codeium for somebody who
251
+ // does not use them.
252
+ const detectedClients = options.mcp
253
+ ? fileBackedClients()
254
+ .map((spec) => {
255
+ const marker = clientMarkerRelPath(spec);
256
+ const absolute = spec.scope === ConfigScope.HOME ? join(io.homeDir(), spec.relPath) : spec.relPath;
257
+ const markerPath = spec.scope === ConfigScope.HOME ? join(io.homeDir(), marker) : marker;
258
+ // A fresh Cursor profile has not written ~/.cursor yet; the project-level .cursor/ is the
259
+ // fallback that kept that real case working. Same signal, project scope.
260
+ const projectFallback = spec.id === McpClient.CURSOR && !io.exists(markerPath)
261
+ ? io.exists(CURSOR_PROJECT_MARKER)
262
+ : false;
263
+ if (!io.exists(markerPath) && !projectFallback)
264
+ return null;
265
+ return {
266
+ id: spec.id,
267
+ configPath: absolute,
268
+ existing: io.readFile(absolute),
269
+ };
270
+ })
271
+ .filter((entry) => entry !== null)
272
+ : [];
273
+ const astroPath = firstPresent(rootFiles, ASTRO_CONFIG_CANDIDATES);
274
+ const astroSource = null === astroPath ? null : io.readFile(astroPath);
275
+ // Which file owns the DOCUMENT, not how many files sit in a directory. The old rule ("exactly one
276
+ // .astro in src/layouts") fired on neither real Astro app: one has no layouts directory and
277
+ // renders from src/pages/index.astro, the other has three files there of which two are partials.
278
+ // `</body>` is the discriminator — see astro-host.
279
+ const astroCandidates = [ASTRO_LAYOUTS_DIR, ASTRO_PAGES_DIR].flatMap((dir) => io
280
+ .listFiles(dir)
281
+ .filter((f) => f.endsWith('.astro'))
282
+ .map((f) => ({ path: `${dir}/${f}`, source: io.readFile(`${dir}/${f}`) }))
283
+ .filter((c) => c.source !== null));
284
+ const astroHost = pickAstroHost(astroCandidates);
285
+ const layoutRelPath = astroHost?.path ?? null;
286
+ const astroLayoutSource = astroHost?.source ?? null;
287
+ const nuxtConfigPath = firstPresent(rootFiles, NUXT_CONFIG_CANDIDATES);
288
+ const nuxtConfigSource = null === nuxtConfigPath ? null : io.readFile(nuxtConfigPath);
289
+ // Nuxt 4's default srcDir is `app/`, Nuxt 3's is the root — and a plugin written into the
290
+ // directory Nuxt does not scan is never registered, silently.
291
+ const nuxtHasAppDir = io.listDirs('.').includes('app');
292
+ const nextConfigFile = firstPresent(rootFiles, NEXT_CONFIG_CANDIDATES);
293
+ // App Router first; a Pages Router app has no layout, and its mount point is pages/_app.
294
+ const layoutPath = NEXT_LAYOUT_CANDIDATES.find((p) => io.exists(p)) ??
295
+ NEXT_PAGES_APP_CANDIDATES.find((p) => io.exists(p)) ??
296
+ null;
297
+ const layoutSource = null === layoutPath ? null : io.readFile(layoutPath);
298
+ // Where the component goes depends on WHICH router mounts it: `pages/` routes on presence, so a
299
+ // component there becomes a broken route; `app/` routes on filename, so a sibling is inert.
300
+ const devLocation = reticleDevLocation(layoutPath ?? 'app/layout.tsx', detection.typescript);
301
+ // Read once: both the testid scan and the store scan want the same bounded set of files.
302
+ const sourceFiles = readSourceFiles(io);
303
+ const agentRoot = agentRootOf(options);
304
+ const agentFile = (relPath) => agentRoot === undefined ? relPath : join(agentRoot, relPath);
305
+ const cspSources = {};
306
+ for (const file of CSP_FILES) {
307
+ const source = io.readFile(file);
308
+ if (null !== source)
309
+ cspSources[file] = source;
310
+ }
311
+ return {
312
+ detection,
313
+ captureBodies: options.captureBodies,
314
+ cspSources,
315
+ claudeCli,
316
+ mcpExists,
317
+ platform: process.platform,
318
+ detectedClients,
319
+ cursorProjectPresent: io.exists(CURSOR_PROJECT_MARKER),
320
+ viteConfig,
321
+ electronViteConfig,
322
+ electronPreload,
323
+ electronMain,
324
+ astroConfig: astroPath !== null && astroSource !== null ? { path: astroPath, source: astroSource } : null,
325
+ astroLayout: layoutRelPath !== null && astroLayoutSource !== null
326
+ ? { path: layoutRelPath, source: astroLayoutSource }
327
+ : null,
328
+ astroEnvDts: io.readFile('src/env.d.ts'),
329
+ nextConfigFile,
330
+ nextConfigSource: null === nextConfigFile ? null : io.readFile(nextConfigFile),
331
+ nextLayout: layoutPath !== null && layoutSource !== null
332
+ ? { path: layoutPath, source: layoutSource }
333
+ : null,
334
+ // Capabilities: scanned, never asked for. Bounded — a hint for the agent, not a repo index.
335
+ testids: scanTestids(sourceFiles.map((f) => f.source)),
336
+ storeHints: storeHints(dependencyNames(pkg)),
337
+ foundStores: scanStores(sourceFiles, dependencyNames(pkg)),
338
+ nextFoundStores: scanStores(sourceFiles, dependencyNames(pkg), dirname(devLocation.path)),
339
+ viteDevModuleExists: io.exists(detection.framework === Framework.ELECTRON_VITE
340
+ ? ELECTRON_VITE_DEV_MODULE_PATH
341
+ : VITE_DEV_MODULE_PATH),
342
+ nextReticleDevPath: devLocation.path,
343
+ nextReticleDevImport: devLocation.importSpecifier,
344
+ nextReticleDevExists: io.exists(devLocation.path),
345
+ nextReticleDevSource: io.readFile(devLocation.path),
346
+ svelteKitHooksExists: io.exists(SVELTEKIT_HOOKS),
347
+ reactRouterEntryExists: io.exists(REACT_ROUTER_ENTRY),
348
+ // The SOURCE, so an entry the app already owns is ADDED TO rather than replaced — it is an
349
+ // override of React Router's default, and everything in it is load-bearing.
350
+ reactRouterEntrySource: io.readFile(REACT_ROUTER_ENTRY),
351
+ nuxtConfig: nuxtConfigPath !== null && nuxtConfigSource !== null
352
+ ? { path: nuxtConfigPath, source: nuxtConfigSource }
353
+ : null,
354
+ nuxtHasAppDir,
355
+ nuxtPluginExists: io.exists(nuxtPluginPath(nuxtHasAppDir)),
356
+ tanstackStartRoot: TANSTACK_START_ROOT_CANDIDATES.find((file) => io.exists(file)),
357
+ craEntry: craEntryOf(io),
358
+ craEnv: io.readFile(CRA_ENV_PATH),
359
+ pairingToken: io.host.pairingToken(),
360
+ installSource: io.host.installSource(),
361
+ reticleConfigExists: io.exists(RETICLE_CONFIG_FILE),
362
+ // The CONTENT, so a config that exists can be checked rather than trusted — a `"port"` set to
363
+ // the app's own dev-server port used to survive every re-run of `init`.
364
+ reticleConfigSource: io.readFile(RETICLE_CONFIG_FILE),
365
+ // Read the agent instruction files so the rule merge stays idempotent across re-runs — from the
366
+ // agent's own root, or the merge would idempotently check a file it is not going to write.
367
+ claudeMdContent: io.readFile(agentFile('CLAUDE.md')),
368
+ agentsMdContent: io.readFile(agentFile('AGENTS.md')),
369
+ reticleMdContent: io.readFile(agentFile(RETICLE_MD_PATH)),
370
+ cursorRuleContent: io.readFile(agentFile(CURSOR_RULE_PATH)),
371
+ claudeCommandContent: io.readFile(agentFile(CLAUDE_COMMAND_PATH)),
372
+ cursorCommandContent: io.readFile(agentFile(CURSOR_COMMAND_PATH)),
373
+ ...(agentRoot === undefined
374
+ ? {}
375
+ : {
376
+ agentFileRoot: agentRoot,
377
+ // The config the AGENT's cwd already has, if any — see agentRootConfigStep.
378
+ agentRootConfigSource: io.readFile(agentFile(RETICLE_CONFIG_FILE)),
379
+ }),
380
+ options: {
381
+ port: options.port,
382
+ mcp: options.mcp,
383
+ install: options.install,
384
+ projectId,
385
+ // The SDK must match the CLI asking for it — see pinnedPackages.
386
+ sdkVersion: RETICLE_VERSION,
387
+ },
388
+ };
389
+ }
390
+ /** Where workspace tooling conventionally puts packages. */
391
+ /** pnpm's workspace declaration, read when present — it is authoritative about where packages live. */
392
+ const SKIPPED_DETAIL = 'skipped — the dependency install above failed, and wiring the app to a package that is not ' +
393
+ 'installed stops it booting. Run that install, then re-run `reticle init`.';
394
+ function report(plan, dryRun, failed, skipped, degraded, io, projectDir, agentRoot,
395
+ /** The project's own dev command, so the closing line names what a human would type. */
396
+ devCommand,
397
+ /**
398
+ * Whether this run continues into the phases that boot the app and drive it.
399
+ *
400
+ * The closing hint tells the reader to restart their dev server and then drive a flow. When those
401
+ * are the very next things this command does, printing them is worse than noise: it is an
402
+ * instruction to do by hand what is about to happen automatically, three lines before it happens.
403
+ */
404
+ continuesToRuntime = false) {
405
+ io.print(dryRun ? 'reticle init (dry run, no files written)' : 'reticle init');
406
+ // Every path below is printed RELATIVE, and until now nothing said what to. Reported from the
407
+ // field as "[✓] Reticle config → .reticle.json" followed by the file not being there: the app was
408
+ // in `frontend/`, init redirected into it, and the report's `.reticle.json` was true about a
409
+ // directory the reader was not standing in. One line makes every path in the report unambiguous.
410
+ io.print(` in ${projectDir}`);
411
+ // The agent files go where the AGENT is, which after a redirect is not where the app is. Said out
412
+ // loud because it changes where `/reticle` will exist, and a reader who assumes one directory for
413
+ // everything goes looking in the wrong one (#318).
414
+ if (agentRoot !== undefined)
415
+ io.print(` agent files in ${agentRoot} (where /reticle will exist)`);
416
+ io.print('');
417
+ let applied = 0;
418
+ let manual = 0;
419
+ // A ⚠ on a CONNECT step is a guaranteed failure, not a caveat: nothing performs the manual step, so
420
+ // the app never dials the daemon and every tool answers "no browser session connected". `ok` was
421
+ // hardcoded true, so a run that could not possibly work reported success.
422
+ let connectPending = false;
423
+ for (const s of plan.steps) {
424
+ // A side effect that failed to apply is reported as a manual step with its fallback command.
425
+ const note = degraded.get(s.target);
426
+ if (note !== undefined) {
427
+ // Applied, but not the way it was asked for. A NOTICE, not work — the install did happen.
428
+ io.print(` [${STATUS_SYMBOL[StepStatus.NOTICE]}] ${s.title} → ${s.target}`);
429
+ for (const line of note.split('\n'))
430
+ io.print(` ${line}`);
431
+ applied++;
432
+ continue;
433
+ }
434
+ const downgraded = failed.has(s.target) || skipped.has(s.target);
435
+ const status = downgraded ? StepStatus.MANUAL : s.status;
436
+ const detail = skipped.has(s.target)
437
+ ? SKIPPED_DETAIL
438
+ : downgraded && s.exec !== undefined
439
+ ? `step failed — run manually: ${s.exec.fallback}`
440
+ : s.detail;
441
+ io.print(` [${STATUS_SYMBOL[status]}] ${s.title} → ${s.target}`);
442
+ if (status === StepStatus.APPLY)
443
+ applied++;
444
+ if (status === StepStatus.MANUAL || status === StepStatus.NOTICE) {
445
+ // A notice prints in full like a manual step — it is worth reading — but is NOT counted as work.
446
+ if (status === StepStatus.MANUAL) {
447
+ manual++;
448
+ if (isConnectStep(s.title))
449
+ connectPending = true;
450
+ }
451
+ for (const line of detail.split('\n'))
452
+ io.print(` ${line}`);
453
+ }
454
+ else if (detail.length > 0) {
455
+ io.print(` ${detail}`);
456
+ }
457
+ }
458
+ io.print('');
459
+ if (connectPending) {
460
+ io.print('This app will NOT connect until the ⚠ step above is done by hand — Reticle tools will report ' +
461
+ '"no browser session connected" until then. Everything else is already in place.');
462
+ io.print('');
463
+ }
464
+ if (!continuesToRuntime) {
465
+ io.print(restartHint(resolvedStatus(plan, MCP_TARGET, failed, skipped), devCommand));
466
+ }
467
+ return { ok: !connectPending, applied, manual };
468
+ }
469
+ /**
470
+ * Perform the apply-step side effects; return the targets whose side effect failed.
471
+ *
472
+ * Steps run in plan order, which puts the dependency install BEFORE everything that imports what it
473
+ * installs. If it fails, the wiring is skipped rather than applied: patching `next.config.ts` to
474
+ * import a `@reticlehq/next` that was never installed takes the dev server down with
475
+ * MODULE_NOT_FOUND, so the app stops booting *because* Reticle was installed. A skipped step is a
476
+ * message; a half-wired app is a broken project.
477
+ */
478
+ /**
479
+ * Are the SDK packages actually on disk, whatever the install step reported?
480
+ *
481
+ * `dependsOnInstall` exists to stop init writing a `next.config.ts` that imports a package which is
482
+ * not there — that took a dev server down once, and installing Reticle must never be why an app
483
+ * stops booting. The invariant it protects is "the import RESOLVES", but it was gated on "our
484
+ * install subprocess exited 0", and those come apart in exactly the situation the failure creates:
485
+ * init tells the user to install by hand, they do, they re-run init, the install step fails again
486
+ * (wrong package manager, not on PATH) and every wiring step is skipped a second time. Reported from
487
+ * a Next 16 app where npm had already installed both packages successfully — leaving an init that
488
+ * could not be retried into working, which is the shape this guard was written to prevent.
489
+ *
490
+ * Reading node_modules answers the real question and costs one `exists` call per package.
491
+ */
492
+ function sdkPackagesPresent(framework, uiLibrary, io) {
493
+ const packages = frameworkPackages(framework, uiLibrary);
494
+ return (packages.length > 0 &&
495
+ packages.every((p) => io.exists(`${NODE_MODULES_DIR}/${p}/${PACKAGE_JSON}`)));
496
+ }
497
+ function applyEffects(plan, io) {
498
+ const failed = new Set();
499
+ const skipped = new Set();
500
+ const degraded = new Map();
501
+ let installFailed = false;
502
+ for (const s of plan.steps) {
503
+ if (s.status !== StepStatus.APPLY)
504
+ continue;
505
+ if (installFailed && true === s.dependsOnInstall) {
506
+ skipped.add(s.target);
507
+ continue;
508
+ }
509
+ const write = s.write;
510
+ if (write !== undefined) {
511
+ // Confirm the EFFECT, not the intention. Reported from the field (#160): init printed
512
+ // `[✓] Reticle config → .reticle.json` and the file was not there afterward. Nothing checked —
513
+ // no arrangement of the filesystem (a read-only mount, a full disk, an antivirus quarantining
514
+ // a new dotfile) could have turned that tick into anything else. A checkmark that cannot fail
515
+ // is decoration, and this one is the first thing a new user reads. Same shape as #139.
516
+ const wrote = io.host.span('init.write', { target: s.target, path: write.path }, () => {
517
+ try {
518
+ // Format connect modules with the project's Prettier when present (#684) — a clean
519
+ // install must not fail the project's own lint on a file we just wrote.
520
+ const content = formatGeneratedSource(write.content, write.path, io.cwd());
521
+ io.writeFile(write.path, content);
522
+ }
523
+ catch {
524
+ return false; // a throw is the loud version of the same failure
525
+ }
526
+ if (!io.exists(write.path))
527
+ return false;
528
+ // A PATCH is only applied if what it was supposed to add is there to read back. A generated
529
+ // file we own is proved by existing; a config we edited is not, and a patcher that quietly
530
+ // no-ops returns the source unchanged, which writes and exists exactly like a success.
531
+ const expect = write.expect;
532
+ if (undefined === expect)
533
+ return true;
534
+ const after = io.readFile(write.path) ?? '';
535
+ return expect.every((needle) => after.includes(needle));
536
+ });
537
+ if (!wrote) {
538
+ failed.add(s.target);
539
+ continue; // do not run this step's exec against a file that is not there
540
+ }
541
+ }
542
+ // Bound once so the traced call cannot need a `?? ''` fallback — a default there would turn a
543
+ // narrowing mistake into an empty command that silently "succeeds".
544
+ const exec = s.exec;
545
+ // Per-step, because the interesting part of init's wall-clock is WHICH step spent it: a
546
+ // package-manager install and a `claude mcp add` are both subprocesses, and one of them being
547
+ // slow is a completely different problem from the other.
548
+ if (exec !== undefined &&
549
+ !io.host.span('init.exec', { target: s.target, command: exec.command }, () => io.exec(exec.command, exec.args))) {
550
+ // A weaker second attempt beats no install at all — but only when it is REPORTED, because the
551
+ // thing it gives up is the version pin that keeps SDK and daemon in step.
552
+ //
553
+ // Traced separately, and it is the reason init can take twice as long as it looks like it
554
+ // should: this is a SECOND full package-manager run, and until it had its own span 1.6 of
555
+ // init's 2.3 seconds simply vanished — the span above accounted for the first attempt and
556
+ // nothing accounted for this one.
557
+ // Walked in order, cheapest concession first, and STOPS at the first success — a later,
558
+ // weaker attempt must never run once an earlier one has already produced a working tree.
559
+ const succeeded = (s.retries ?? []).find((retry) => io.host.span('init.exec.retry', { target: s.target, command: retry.command }, () => io.exec(retry.command, retry.args)));
560
+ if (succeeded !== undefined) {
561
+ degraded.set(s.target, succeeded.note);
562
+ continue;
563
+ }
564
+ // Verify, don't re-run: give the install step itself the same sdkPackagesPresent benefit
565
+ // already given to the wiring it gates below (#683).
566
+ if (s.target === DEPS_TARGET && sdkPackagesPresent(plan.framework, plan.uiLibrary, io))
567
+ continue;
568
+ failed.add(s.target);
569
+ // A failed install only blocks the wiring when the packages are genuinely ABSENT. See
570
+ // sdkPackagesPresent: the guard protects "the import resolves", not "our subprocess exited 0".
571
+ if (s.target === DEPS_TARGET)
572
+ installFailed = true;
573
+ }
574
+ }
575
+ // Where this project lives, remembered for a daemon that will be started somewhere else.
576
+ //
577
+ // Deliberately AFTER the loop and unconditional on which steps ran: most init runs report "already
578
+ // wired", and a re-run in a re-cloned or moved checkout is exactly when the remembered path has
579
+ // gone stale — so the run that would otherwise be a no-op is the one that repairs the entry. The
580
+ // return value is ignored on purpose; this is a cache, and a read-only home directory must not
581
+ // turn a wired project into a failed init.
582
+ const projectId = projectIdOf(io.readFile(RETICLE_CONFIG_FILE));
583
+ if (projectId !== undefined) {
584
+ rememberProjectOnDisk(io, projectId, io.cwd(), Date.now());
585
+ }
586
+ return { failed, skipped, degraded };
587
+ }
588
+ /**
589
+ * Which STEP failed, from the step targets — not from an error string, which would carry paths.
590
+ *
591
+ * The distinction that matters: a dependency install failing is a machine/network problem (offline,
592
+ * a locked registry, a broken package manager), while MCP registration failing means the `claude` CLI
593
+ * is missing or refused. Two completely different fixes, and until now both were simply "init didn't
594
+ * work" with nothing to tell them apart.
595
+ */
596
+ function classifyInitFailure(failed) {
597
+ if (failed.has(DEPS_TARGET))
598
+ return InitFailure.DEPENDENCY_INSTALL;
599
+ if (failed.has(MCP_TARGET))
600
+ return InitFailure.MCP_REGISTRATION;
601
+ return InitFailure.OTHER;
602
+ }
603
+ export function runInit(options, io) {
604
+ const result = runInitSteps(options, io);
605
+ // The ask goes here, not in report(): report() is only the success-shaped path, and the exits that
606
+ // matter most are the ones that never reach it — no package.json, an ambiguous workspace — where
607
+ // setup died before anything ran and the person holding the report has the least to go on. This
608
+ // wrapper is the one point every exit passes through.
609
+ //
610
+ // `redirected` is what keeps it to ONE print: wiring an app in a monorepo re-enters runInit for the
611
+ // chosen directory, and the inner call must not ask again.
612
+ if (true !== options.redirected) {
613
+ io.print('');
614
+ io.print(FEEDBACK_HINT);
615
+ }
616
+ return result;
617
+ }
618
+ /**
619
+ * The manifest, parsed once, or the reason it could not be.
620
+ *
621
+ * It used to be parsed in three places from the same raw string, and two of them were unguarded —
622
+ * so `reticle init` on a `package.json` with a trailing comma died with a raw `SyntaxError` and a
623
+ * stack through `redirectToWorkspaceApp`. A stack trace in front of a user is a bug whatever caused
624
+ * it, and this one lands on the very first thing the command does, before it has said anything.
625
+ *
626
+ * `setup/reticle.mjs` has always got this right ("… is not valid JSON (…). Fix it and re-run"). The
627
+ * shipped CLI did not, which is the shape of every divergence between the two: the prototype refuses
628
+ * politely, `init` throws. Parsing once at the single point the file enters means no later caller
629
+ * CAN reintroduce it — a guard per call site would have been three guards and a fourth one waiting.
630
+ */
631
+ function readManifest(io) {
632
+ const raw = io.readFile(PACKAGE_JSON);
633
+ if (null === raw)
634
+ return { pkg: null };
635
+ try {
636
+ return { pkg: JSON.parse(raw) };
637
+ }
638
+ catch (err) {
639
+ // First line only: JSON.parse's message carries the offending position, and the rest is noise.
640
+ const detail = String(err instanceof Error ? err.message : err).split('\n')[0] ?? 'unparseable';
641
+ return { error: detail };
642
+ }
643
+ }
644
+ function runInitSteps(options, io) {
645
+ const manifest = readManifest(io);
646
+ if ('error' in manifest) {
647
+ io.print(`${PACKAGE_JSON} is not valid JSON (${manifest.error}). Fix it and re-run — init reads the ` +
648
+ 'framework, the dev script and the package manager from it, and will not guess at any of ' +
649
+ 'them from a file it cannot read.');
650
+ io.host.reportOutcome({ ok: false, reason: InitFailure.MALFORMED_PACKAGE_JSON });
651
+ return { ok: false, applied: 0, manual: 0 };
652
+ }
653
+ const pkgRaw = manifest.pkg;
654
+ // Look for the app BEFORE concluding there isn't one.
655
+ //
656
+ // A root with no package.json is not a dead end — it is the ordinary shape of a repo whose app
657
+ // lives one directory down (`frontend/`, `web/`, `client/`) with no manifest at the top. Bailing
658
+ // first made that repo un-instrumentable: `reticle init` said "No package.json found", and
659
+ // `--app frontend`, the flag that exists for exactly this, was never read because the bail came
660
+ // first. Reported twice by the same user, who tried the documented workaround and hit the same
661
+ // wall. Discovery already handles the case (it scans top-level directories, not just declared
662
+ // workspaces); it was simply unreachable.
663
+ //
664
+ // `'{}'` because the redirect only needs the manifest to ask "is THIS directory the app", and a
665
+ // directory with no package.json is definitively not.
666
+ const redirectedEarly = redirectToWorkspaceApp(options, io, pkgRaw ?? {}, runInit);
667
+ if (redirectedEarly !== null)
668
+ return redirectedEarly;
669
+ if (null === pkgRaw) {
670
+ const streamlit = detectStreamlitProject((file) => io.readFile(file), io.rootFiles());
671
+ // Asked only when Streamlit already said no, so the two can never both claim the page.
672
+ const django = !streamlit &&
673
+ detectDjangoProject((file) => io.exists(join(options.cwd, file)), io.rootFiles());
674
+ io.print(
675
+ // Two genuinely different situations used to share one sentence: a JS developer in the wrong
676
+ // directory, and a project that is not JavaScript at all. The second reads the old wording as
677
+ // a path problem and goes looking for a directory that cannot exist — reported from a
678
+ // Streamlit app, where the search continued into hunting for a browser bundle to inject by
679
+ // hand before the real answer surfaced.
680
+ streamlit
681
+ ? streamlitSetupMessage()
682
+ : django
683
+ ? djangoSetupMessage()
684
+ : noPackageJsonMessage((file) => io.exists(join(options.cwd, file))));
685
+ // The message says "add the snippet below". Print the snippet, or the message is the same
686
+ // broken promise in the other direction. `connectArg` carries the port; there is no projectId
687
+ // to bake, because a projectId is derived from the package.json that does not exist here.
688
+ // Scope the project on disk before printing anything, so the daemon can serve this page.
689
+ //
690
+ // This path used to print and exit, leaving no `.reticle.json` at all — and the reporter who
691
+ // asked for the Django path had to hand-write one alongside the middleware. Without it the
692
+ // daemon has no config in its own directory, refuses the page's dial, and every downstream
693
+ // symptom points somewhere other than the cause (see #685). The projectId derivation already
694
+ // handles the no-package case: it falls back to the root folder name, fingerprinted by the
695
+ // absolute path so two checkouts stay distinct.
696
+ //
697
+ // Written only when absent. A config a user or an earlier run already placed here is theirs.
698
+ const nonJsProjectId = deriveProjectId(undefined, io.cwd());
699
+ if (!io.exists(RETICLE_CONFIG_FILE)) {
700
+ io.writeFile(RETICLE_CONFIG_FILE, reticleConfigContent(Framework.HTML, options.port, nonJsProjectId, io.host.installSource()));
701
+ rememberProjectOnDisk(io, nonJsProjectId, io.cwd(), Date.now());
702
+ io.print(`Wrote ${RETICLE_CONFIG_FILE} (project "${nonJsProjectId}").`);
703
+ }
704
+ const connect = connectArgWithToken(options.port, nonJsProjectId, io.host.pairingToken());
705
+ io.print(streamlit
706
+ ? streamlitPageSnippet(connect)
707
+ : django
708
+ ? djangoMiddlewareSnippet(connect)
709
+ : staticPageSnippet(connect));
710
+ // The onboarding funnel had NO instrumentation, so a setup that died here was indistinguishable
711
+ // from someone who never ran the command — the two failure modes with the most different fixes.
712
+ io.host.reportOutcome({ ok: false, reason: InitFailure.NO_PACKAGE_JSON });
713
+ return { ok: false, applied: 0, manual: 0 };
714
+ }
715
+ // Init is the flow a user experiences the wait of personally, and the fixture gate measures it at
716
+ // 1–6s per app with no explanation of the spread. These three spans split that number into detect
717
+ // (filesystem probing), plan (pure), and apply (writes + package-manager and CLI subprocesses).
718
+ const planInput = gatherPlanInput(options, io, pkgRaw);
719
+ // Before anything is written, and AFTER detection — the package manager to check is the one init
720
+ // RESOLVED, not a raw lockfile read. An inherited pnpm-lock.yaml at a monorepo root does not mean
721
+ // the app in frontend/ uses pnpm, and checking the file refused a scaffold the install gate proves
722
+ // must succeed. Both conditions make every later phase fail, and each arrives far from its cause
723
+ // when it is not checked here. See preflight.ts.
724
+ const refusal = preflightRefusal({
725
+ cwd: () => io.cwd(),
726
+ canWrite: () => io.canWrite(),
727
+ probe: (command, args) => io.probe(command, args),
728
+ }, planInput.detection.packageManager, { alreadyServed: options.url !== undefined && '' !== options.url });
729
+ if (refusal !== undefined) {
730
+ io.print(refusal);
731
+ return { ok: false, applied: 0, manual: 1 };
732
+ }
733
+ const plan = io.host.span('init.plan', {}, () => buildPlan(planInput));
734
+ const effects = options.dryRun
735
+ ? { failed: new Set(), skipped: new Set(), degraded: new Map() }
736
+ : io.host.span('init.apply', { steps: plan.steps.length }, () => applyEffects(plan, io));
737
+ const { failed, skipped, degraded } = effects;
738
+ // The project's own dev command, so the closing line names what a human would actually type.
739
+ const devCommand = devCommandFrom(pkgRaw, planInput.detection.packageManager);
740
+ const result = report(plan, options.dryRun, failed, skipped, degraded, io, options.cwd, agentRootOf(options), devCommand, true === options.continuesToRuntime);
741
+ // A dry run is a preview, not an outcome — reporting it would inflate both success and failure.
742
+ if (options.dryRun)
743
+ return result;
744
+ const outcome = {
745
+ ok: result.ok,
746
+ ...(result.ok ? {} : { reason: classifyInitFailure(failed) }),
747
+ stack: plan.framework,
748
+ // The step's REAL final status, not the absence of a failure — see mcp-registered.
749
+ mcpRegistered: wasMcpRegistered(resolvedStatus(plan, MCP_TARGET, failed, skipped)),
750
+ };
751
+ const context = {
752
+ appDir: options.cwd,
753
+ framework: plan.framework,
754
+ packageManager: planInput.detection.packageManager,
755
+ ...(undefined === devCommand ? {} : { devCommand }),
756
+ ...(true === options.redirected ? { redirectedTo: options.cwd } : {}),
757
+ };
758
+ if (true === options.deferOutcome)
759
+ return { ...result, context, outcome };
760
+ io.host.reportOutcome(outcome);
761
+ return { ...result, context };
762
+ }