rivet-design 0.9.2 → 0.9.4

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 (157) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +78 -2
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +464 -62
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +331 -9
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1985 -61
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  9. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts +65 -0
  10. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -0
  11. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +162 -0
  12. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -0
  13. package/dist/mcp/agent-variants/contracts.d.ts +2508 -10
  14. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  15. package/dist/mcp/agent-variants/contracts.js +295 -5
  16. package/dist/mcp/agent-variants/contracts.js.map +1 -1
  17. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts +78 -0
  18. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts.map +1 -0
  19. package/dist/mcp/agent-variants/createProjectArtifacts.js +123 -0
  20. package/dist/mcp/agent-variants/createProjectArtifacts.js.map +1 -0
  21. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +241 -0
  22. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -0
  23. package/dist/mcp/agent-variants/createZeroToOneTool.js +213 -0
  24. package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -0
  25. package/dist/mcp/agent-variants/designContextStore.d.ts +160 -0
  26. package/dist/mcp/agent-variants/designContextStore.d.ts.map +1 -0
  27. package/dist/mcp/agent-variants/designContextStore.js +295 -0
  28. package/dist/mcp/agent-variants/designContextStore.js.map +1 -0
  29. package/dist/mcp/agent-variants/elementRefToTarget.d.ts +21 -0
  30. package/dist/mcp/agent-variants/elementRefToTarget.d.ts.map +1 -0
  31. package/dist/mcp/agent-variants/elementRefToTarget.js +47 -0
  32. package/dist/mcp/agent-variants/elementRefToTarget.js.map +1 -0
  33. package/dist/mcp/agent-variants/errors.d.ts +1 -1
  34. package/dist/mcp/agent-variants/errors.d.ts.map +1 -1
  35. package/dist/mcp/agent-variants/errors.js +7 -0
  36. package/dist/mcp/agent-variants/errors.js.map +1 -1
  37. package/dist/mcp/agent-variants/index.d.ts +4 -2
  38. package/dist/mcp/agent-variants/index.d.ts.map +1 -1
  39. package/dist/mcp/agent-variants/index.js +7 -1
  40. package/dist/mcp/agent-variants/index.js.map +1 -1
  41. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +440 -0
  42. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -0
  43. package/dist/mcp/agent-variants/inspirationDesignContext.js +2467 -0
  44. package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -0
  45. package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
  46. package/dist/mcp/agent-variants/pendingChangesAdapter.js +21 -7
  47. package/dist/mcp/agent-variants/pendingChangesAdapter.js.map +1 -1
  48. package/dist/mcp/agent-variants/previewQa.d.ts +61 -0
  49. package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -0
  50. package/dist/mcp/agent-variants/previewQa.js +374 -0
  51. package/dist/mcp/agent-variants/previewQa.js.map +1 -0
  52. package/dist/mcp/agent-variants/sourceContext.d.ts +8 -0
  53. package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -0
  54. package/dist/mcp/agent-variants/sourceContext.js +183 -0
  55. package/dist/mcp/agent-variants/sourceContext.js.map +1 -0
  56. package/dist/mcp/agent-variants/tools.d.ts +36 -0
  57. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  58. package/dist/mcp/agent-variants/tools.js +451 -19
  59. package/dist/mcp/agent-variants/tools.js.map +1 -1
  60. package/dist/mcp/changeBatchClassification.d.ts +30 -0
  61. package/dist/mcp/changeBatchClassification.d.ts.map +1 -0
  62. package/dist/mcp/changeBatchClassification.js +65 -0
  63. package/dist/mcp/changeBatchClassification.js.map +1 -0
  64. package/dist/mcp/server.d.ts.map +1 -1
  65. package/dist/mcp/server.js +258 -41
  66. package/dist/mcp/server.js.map +1 -1
  67. package/dist/prompts/agentModPrompts.js +4 -4
  68. package/dist/prompts/agentModPrompts.js.map +1 -1
  69. package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
  70. package/dist/proxy-middleware/proxy-config.js +1 -15
  71. package/dist/proxy-middleware/proxy-config.js.map +1 -1
  72. package/dist/routes/agentVariants.d.ts +3 -1
  73. package/dist/routes/agentVariants.d.ts.map +1 -1
  74. package/dist/routes/agentVariants.js +138 -13
  75. package/dist/routes/agentVariants.js.map +1 -1
  76. package/dist/routes/mcp.d.ts +7 -1
  77. package/dist/routes/mcp.d.ts.map +1 -1
  78. package/dist/routes/mcp.js +139 -16
  79. package/dist/routes/mcp.js.map +1 -1
  80. package/dist/server.d.ts.map +1 -1
  81. package/dist/server.js +23 -5
  82. package/dist/server.js.map +1 -1
  83. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  84. package/dist/services/ProjectDetectionService.js +9 -0
  85. package/dist/services/ProjectDetectionService.js.map +1 -1
  86. package/dist/services/SessionBridgeService.d.ts +22 -0
  87. package/dist/services/SessionBridgeService.d.ts.map +1 -1
  88. package/dist/services/SessionBridgeService.js +61 -0
  89. package/dist/services/SessionBridgeService.js.map +1 -1
  90. package/dist/services/TelemetryService.d.ts +121 -0
  91. package/dist/services/TelemetryService.d.ts.map +1 -1
  92. package/dist/services/TelemetryService.js +155 -0
  93. package/dist/services/TelemetryService.js.map +1 -1
  94. package/dist/services/WorktreeManager.d.ts +116 -6
  95. package/dist/services/WorktreeManager.d.ts.map +1 -1
  96. package/dist/services/WorktreeManager.js +394 -19
  97. package/dist/services/WorktreeManager.js.map +1 -1
  98. package/dist/services/agent/AgentModService.js +6 -6
  99. package/dist/services/agent/AgentModService.js.map +1 -1
  100. package/dist/services/templates/designCatalog.d.ts +27 -0
  101. package/dist/services/templates/designCatalog.d.ts.map +1 -0
  102. package/dist/services/templates/designCatalog.js +141 -0
  103. package/dist/services/templates/designCatalog.js.map +1 -0
  104. package/dist/services/templates/designmd/airbnb.md +545 -0
  105. package/dist/services/templates/designmd/airtable.md +554 -0
  106. package/dist/services/templates/designmd/apple.md +562 -0
  107. package/dist/services/templates/designmd/binance.md +634 -0
  108. package/dist/services/templates/designmd/bmw-m.md +503 -0
  109. package/dist/services/templates/designmd/bmw.md +544 -0
  110. package/dist/services/templates/designmd/bugatti.md +454 -0
  111. package/dist/services/templates/designmd/cal.md +542 -0
  112. package/dist/services/templates/designmd/claude.md +589 -0
  113. package/dist/services/templates/designmd/clay.md +541 -0
  114. package/dist/services/templates/designmd/cohere.md +451 -0
  115. package/dist/services/templates/designmd/cursor.md +537 -0
  116. package/dist/services/templates/designmd/expo.md +526 -0
  117. package/dist/services/templates/designmd/figma.md +578 -0
  118. package/dist/services/templates/designmd/framer.md +544 -0
  119. package/dist/services/templates/designmd/hp.md +670 -0
  120. package/dist/services/templates/designmd/linear.app.md +548 -0
  121. package/dist/services/templates/designmd/mintlify.md +852 -0
  122. package/dist/services/templates/designmd/miro.md +825 -0
  123. package/dist/services/templates/designmd/notion.md +821 -0
  124. package/dist/services/templates/designmd/raycast.md +669 -0
  125. package/dist/services/templates/designmd/resend.md +585 -0
  126. package/dist/services/templates/designmd/sentry.md +262 -0
  127. package/dist/services/templates/designmd/shopify.md +350 -0
  128. package/dist/services/templates/designmd/spotify.md +246 -0
  129. package/dist/services/templates/designmd/stripe.md +322 -0
  130. package/dist/services/templates/designmd/supabase.md +255 -0
  131. package/dist/services/templates/designmd/superhuman.md +252 -0
  132. package/dist/services/templates/designmd/uber.md +295 -0
  133. package/dist/services/templates/designmd/vercel.md +310 -0
  134. package/dist/services/templates/viteReactTs.d.ts +48 -0
  135. package/dist/services/templates/viteReactTs.d.ts.map +1 -0
  136. package/dist/services/templates/viteReactTs.js +274 -0
  137. package/dist/services/templates/viteReactTs.js.map +1 -0
  138. package/dist/types/change-request-types.d.ts +29 -3
  139. package/dist/types/change-request-types.d.ts.map +1 -1
  140. package/dist/utils/skills/claude-skill.d.ts +2 -2
  141. package/dist/utils/skills/claude-skill.d.ts.map +1 -1
  142. package/dist/utils/skills/claude-skill.js +19 -98
  143. package/dist/utils/skills/claude-skill.js.map +1 -1
  144. package/dist/utils/skills/cursor-rules.d.ts +2 -2
  145. package/dist/utils/skills/cursor-rules.d.ts.map +1 -1
  146. package/dist/utils/skills/cursor-rules.js +15 -80
  147. package/dist/utils/skills/cursor-rules.js.map +1 -1
  148. package/dist/utils/skills/shared-variants-protocol.d.ts +23 -0
  149. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -0
  150. package/dist/utils/skills/shared-variants-protocol.js +130 -0
  151. package/dist/utils/skills/shared-variants-protocol.js.map +1 -0
  152. package/package.json +6 -6
  153. package/src/ui/dist/assets/main-CpX7fB64.js +382 -0
  154. package/src/ui/dist/assets/main-Qqe2_oMT.css +1 -0
  155. package/src/ui/dist/index.html +2 -2
  156. package/src/ui/dist/assets/main-AsPCtLsx.js +0 -382
  157. package/src/ui/dist/assets/main-BzmseUDd.css +0 -1
@@ -1,11 +1,191 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.AgentVariantsOrchestrator = void 0;
7
+ const crypto_1 = require("crypto");
4
8
  const events_1 = require("events");
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const simple_git_1 = require("simple-git");
5
13
  const logger_1 = require("../../utils/logger");
6
14
  const errors_1 = require("./errors");
15
+ const createProjectArtifacts_1 = require("./createProjectArtifacts");
7
16
  const contracts_1 = require("./contracts");
17
+ const viteReactTs_1 = require("../../services/templates/viteReactTs");
18
+ const designCatalog_1 = require("../../services/templates/designCatalog");
19
+ const previewQa_1 = require("./previewQa");
8
20
  const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
21
+ const FRESH_DEV_SERVER_HOST = '127.0.0.1';
22
+ /**
23
+ * Allowlist of asset file extensions an agent-planned source may have.
24
+ * `assetPlan` is sized for large local *assets* (3D models, images,
25
+ * audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
26
+ * configs. The check is defense-in-depth alongside the path / symlink
27
+ * filters in `copyAssetIntoWorktree`; without it, a prompt-injected
28
+ * source plan could copy readable host files such as SSH keys or
29
+ * project secrets into the generated app.
30
+ *
31
+ * Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
32
+ * common carriers for credentials (`credentials.json`, `.aws/config`,
33
+ * shell history `.txt`, README-shaped secrets) and have no legitimate
34
+ * use as binary preview assets. If a future flow needs structured data
35
+ * it should ship it through the manifest instead of file copy.
36
+ */
37
+ const ALLOWED_ASSET_EXTENSIONS = new Set([
38
+ // 3D / models
39
+ '.glb', '.gltf', '.obj', '.fbx', '.usdz',
40
+ // images
41
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
42
+ // video
43
+ '.mp4', '.webm', '.mov',
44
+ // audio
45
+ '.mp3', '.wav', '.ogg', '.m4a',
46
+ // fonts
47
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
48
+ // PDFs
49
+ '.pdf',
50
+ ]);
51
+ /**
52
+ * Reject paths that traverse through a "sensitive" directory anywhere
53
+ * in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
54
+ * a copy passes the extension allowlist, an agent-controlled source
55
+ * resolving inside `~/.ssh/` is almost certainly an exfiltration
56
+ * attempt rather than a legitimate asset reference.
57
+ */
58
+ const SENSITIVE_PATH_SEGMENTS = new Set([
59
+ '.ssh',
60
+ '.aws',
61
+ '.gnupg',
62
+ '.kube',
63
+ '.docker',
64
+ '.config',
65
+ '.npm',
66
+ '.cargo',
67
+ '.gem',
68
+ '.netrc',
69
+ 'private',
70
+ 'secrets',
71
+ 'credentials',
72
+ ]);
73
+ function hasSensitivePathSegment(absolutePath) {
74
+ // Split on any path separator; check each segment case-insensitively.
75
+ const segments = absolutePath.split(/[/\\]/).filter(Boolean);
76
+ return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
77
+ }
78
+ /**
79
+ * Returns true when `absolutePath` is the root itself or one of its
80
+ * descendants after both paths have been resolved.
81
+ */
82
+ function isPathWithinRoot(absolutePath, root) {
83
+ const relative = path_1.default.relative(root, absolutePath);
84
+ return (relative === '' ||
85
+ (relative.length > 0 &&
86
+ !relative.startsWith('..') &&
87
+ !path_1.default.isAbsolute(relative)));
88
+ }
89
+ /**
90
+ * Copy one agent-planned asset file into a variant worktree at the
91
+ * project-relative `destination`. The agent decides routing
92
+ * (`destination` like `public/assets/avatar.glb`) so all variants share
93
+ * a consistent layout. Fails loudly when the source is missing or the
94
+ * destination tries to escape the worktree — silent fallbacks here
95
+ * would re-introduce the broken-asset failure mode this routing is
96
+ * supposed to prevent.
97
+ *
98
+ * Security: `entry.source` comes from `report_source_plan` and is
99
+ * agent-controlled. To prevent a prompt-injected source plan from
100
+ * exfiltrating arbitrary readable host files (SSH keys, dotfiles,
101
+ * cloud credentials, project secrets) into the generated app, the
102
+ * source must (1) be an absolute path that resolves via realpath
103
+ * (no traversal, no symlinks pointing outside the resolved location),
104
+ * (2) be a regular file (not a device, socket, fifo, or directory),
105
+ * (3) live under the approved asset root, (4) have an extension on the
106
+ * asset allowlist.
107
+ */
108
+ function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
109
+ if (!assetSourceRoot) {
110
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
111
+ }
112
+ if (!path_1.default.isAbsolute(assetSourceRoot)) {
113
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
114
+ }
115
+ if (!path_1.default.isAbsolute(entry.source)) {
116
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
117
+ }
118
+ const ext = path_1.default.extname(entry.source).toLowerCase();
119
+ if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
120
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source extension '${ext || '(none)'}' is not on the allowlist (got '${entry.source}'). Only inert media/font/document assets may be copied.`);
121
+ }
122
+ // lstat first: a symlink whose target lies outside the source path
123
+ // could otherwise smuggle in arbitrary files. Reject symlinks outright;
124
+ // realpath would resolve them but the user-visible intent of an
125
+ // assetPlan entry is to name a concrete file.
126
+ let lstat;
127
+ try {
128
+ lstat = fs_1.default.lstatSync(entry.source);
129
+ }
130
+ catch {
131
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
132
+ }
133
+ if (lstat.isSymbolicLink()) {
134
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
135
+ }
136
+ if (!lstat.isFile()) {
137
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
138
+ }
139
+ // Resolve realpath as additional defense: even with no symlinks at the
140
+ // leaf, an intermediate directory could be a symlink. Cross-check the
141
+ // file kind on the resolved path.
142
+ let resolvedSource;
143
+ try {
144
+ resolvedSource = fs_1.default.realpathSync(entry.source);
145
+ }
146
+ catch {
147
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
148
+ }
149
+ let resolvedAssetSourceRoot;
150
+ try {
151
+ resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
152
+ }
153
+ catch {
154
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
155
+ }
156
+ const resolvedStat = fs_1.default.statSync(resolvedSource);
157
+ if (!resolvedStat.isFile()) {
158
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
159
+ }
160
+ // Symlinked parent directory defense: even though we rejected a symlink
161
+ // leaf and confirmed the resolved file is regular, an intermediate dir
162
+ // could have been a symlink that quietly forwards into a sensitive
163
+ // ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
164
+ // NEITHER the user-supplied path NOR its realpath traverses a known
165
+ // sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
166
+ // re-verify the extension on the resolved path so a `.glb` symlink
167
+ // chain cannot smuggle in a `.json` realpath.
168
+ if (hasSensitivePathSegment(entry.source) ||
169
+ hasSensitivePathSegment(resolvedSource)) {
170
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
171
+ }
172
+ if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
173
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
174
+ }
175
+ const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
176
+ if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
177
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
178
+ }
179
+ const normalizedDest = path_1.default.normalize(entry.destination);
180
+ if (normalizedDest.startsWith('..') ||
181
+ path_1.default.isAbsolute(normalizedDest)) {
182
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
183
+ }
184
+ const absDest = path_1.default.join(worktreePath, normalizedDest);
185
+ fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
186
+ fs_1.default.copyFileSync(resolvedSource, absDest);
187
+ }
188
+ const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
9
189
  const NOOP_TELEMETRY = { track: () => undefined };
10
190
  /**
11
191
  * Wraps SessionStore for the operations that have side effects: approve
@@ -25,7 +205,31 @@ class AgentVariantsOrchestrator {
25
205
  adapter;
26
206
  resolveEnv;
27
207
  telemetry;
208
+ installDependencies;
209
+ materializeProject;
210
+ previewQaRunner;
211
+ switchPreviewPort;
28
212
  resources = new Map();
213
+ /**
214
+ * Committed dev servers from prior sessions that survived teardown. The
215
+ * orchestrator outlives any single session — once a new session starts, the
216
+ * prior committed dev server is no longer needed (the next session opens
217
+ * its own preview chip), so stop it before the new session's first preview
218
+ * goes live. Keyed by sessionId for diagnostics; values are kept alive
219
+ * intentionally between commit and next `propose` / `shutdown`.
220
+ */
221
+ lingeringCommittedDevServers = new Map();
222
+ /**
223
+ * Per-session in-flight commit chain. Two overlapping commitVariant calls
224
+ * would otherwise both pass the `getVariantPick` idempotency check (the
225
+ * pick isn't recorded until after `applyDiffToProject`) and double-invoke
226
+ * `git apply` — which corrupts the working tree on the second run. The
227
+ * chain serializes commits per session so the second caller waits for the
228
+ * first to finish and then hits the idempotency check (returning
229
+ * `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
230
+ * for a different one).
231
+ */
232
+ inflightCommits = new Map();
29
233
  /** Most recent agent-variants sessionId — read by the iframe chip via
30
234
  * GET /api/variants/active. Cleared on cancel or commit. */
31
235
  activeSessionId = null;
@@ -38,9 +242,21 @@ class AgentVariantsOrchestrator {
38
242
  this.adapter = deps.pendingChangesAdapter;
39
243
  this.resolveEnv = deps.resolveProjectEnvironment;
40
244
  this.telemetry = deps.telemetry ?? NOOP_TELEMETRY;
245
+ this.installDependencies =
246
+ deps.installDependencies ?? defaultInstallDependencies;
247
+ this.materializeProject =
248
+ deps.materializeProject ?? defaultMaterializeProject;
249
+ this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
250
+ this.switchPreviewPort = deps.switchPreviewPort;
41
251
  }
42
252
  // --- Pure delegations (no side effects) ---------------------------------
43
253
  propose(args) {
254
+ // A new session means any lingering committed dev server from a prior
255
+ // session is no longer needed — the next session opens its own preview.
256
+ // Best-effort stop; failures here don't block propose.
257
+ void this.stopLingeringCommittedDevServers().catch((err) => {
258
+ log.warn('Stopping lingering committed dev servers failed', err);
259
+ });
44
260
  const result = this.store.propose(args);
45
261
  this.activeSessionId = result.sessionId;
46
262
  this.ensureResources(result.sessionId);
@@ -82,13 +298,11 @@ class AgentVariantsOrchestrator {
82
298
  stage === 'cancelled'
83
299
  ? this.store.getSummary(sessionId)
84
300
  : null;
85
- const variants = (summary?.variants ?? []).map((v) => ({
86
- workItemId: v.workItemId,
87
- status: v.status,
88
- label: v.label,
89
- previewPort: this.getDevServerPort(sessionId, v.workItemId),
90
- worktreePath: this.getWorktreePath(sessionId, v.workItemId),
91
- }));
301
+ const variants = this.getVariants(sessionId);
302
+ const sessionProjectContext = this.store.getProjectContext(sessionId);
303
+ const projectContext = toActiveProjectContext(sessionProjectContext);
304
+ const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
305
+ const artifacts = buildSessionArtifacts(sessionProjectContext);
92
306
  return {
93
307
  active: true,
94
308
  sessionId,
@@ -97,6 +311,9 @@ class AgentVariantsOrchestrator {
97
311
  progress,
98
312
  summary,
99
313
  variants,
314
+ projectContext,
315
+ ...(destinationPath ? { destinationPath } : {}),
316
+ ...(artifacts.length > 0 ? { artifacts } : {}),
100
317
  };
101
318
  }
102
319
  emitChange() {
@@ -109,6 +326,26 @@ class AgentVariantsOrchestrator {
109
326
  }
110
327
  reportBriefs(args) {
111
328
  const result = this.store.reportBriefs(args);
329
+ const evidenceBackedCount = result.briefs.filter((brief) => /\b(source|evidence|preserve|borrow)\b/i.test(brief.body)).length;
330
+ const shortBriefCount = result.briefs.filter((brief) => brief.body.length <= 200).length;
331
+ this.telemetry.trackAgentVariantsBriefQuality?.({
332
+ sessionId: args.sessionId,
333
+ briefCount: result.briefs.length,
334
+ evidenceBackedCount,
335
+ shortBriefCount,
336
+ });
337
+ this.emitChange();
338
+ return result;
339
+ }
340
+ reportSourcePlan(args) {
341
+ const result = this.store.reportSourcePlan(args);
342
+ this.telemetry.trackAgentVariantsSourceContextQuality?.({
343
+ sessionId: args.sessionId,
344
+ sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
345
+ artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
346
+ hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
347
+ preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
348
+ });
112
349
  this.emitChange();
113
350
  return result;
114
351
  }
@@ -156,9 +393,83 @@ class AgentVariantsOrchestrator {
156
393
  getSummary(sessionId) {
157
394
  return this.store.getSummary(sessionId);
158
395
  }
396
+ getVariants(sessionId) {
397
+ const resources = this.resources.get(sessionId);
398
+ return this.store.getVariants(sessionId).map((variant) => {
399
+ const port = this.getDevServerPort(sessionId, variant.workItemId);
400
+ // For fresh sessions, static_preview items ARE the variants — look up
401
+ // directly by workItemId. Fall back to briefId search for existing-project
402
+ // sessions where code_gen items were paired with companion static_preview items.
403
+ const staticPreview = resources?.staticPreviews.get(variant.workItemId) ??
404
+ this.getStaticPreviewByBriefId(sessionId, variant.briefId);
405
+ let preview;
406
+ if (staticPreview) {
407
+ preview = {
408
+ kind: 'static_artifact',
409
+ url: this.buildStaticPreviewUrl(sessionId, staticPreview.workItemId),
410
+ };
411
+ }
412
+ else if (!port) {
413
+ // handleSucceededReport is fire-and-forget so staticPreviews may not be
414
+ // populated yet when getVariants is called (e.g. in the terminal response).
415
+ // item.output is set synchronously by store.reportComplete, so it's always
416
+ // available. Read it directly as a fallback so previewUrls in terminal
417
+ // responses include the variant that just triggered terminal state.
418
+ try {
419
+ const output = this.store.getWorkItemOutput(sessionId, variant.workItemId);
420
+ if (typeof output?.html === 'string' && output.html.length > 0) {
421
+ preview = {
422
+ kind: 'static_artifact',
423
+ url: this.buildStaticPreviewUrl(sessionId, variant.workItemId),
424
+ };
425
+ }
426
+ }
427
+ catch { /* work item may not exist in edge cases */ }
428
+ }
429
+ if (!preview && port) {
430
+ preview = { kind: 'dev_server', port };
431
+ }
432
+ const qa = resources?.qaResults.get(variant.workItemId);
433
+ const isSucceeded = variant.status === 'succeeded';
434
+ const qaFailed = qa?.status === 'failed';
435
+ const canView = Boolean(preview) || (isSucceeded && Boolean(port));
436
+ const canCommit = isSucceeded && !qaFailed;
437
+ const commitDisabledReason = qaFailed
438
+ ? qa?.summary ?? 'Variant failed QA'
439
+ : 'Wait for a successful variant';
440
+ return {
441
+ ...variant,
442
+ design: enrichDesignSource(variant.design),
443
+ ...(preview ? { preview } : {}),
444
+ port,
445
+ ...(qa ? { qa } : {}),
446
+ actions: {
447
+ view: canView
448
+ ? { enabled: true }
449
+ : {
450
+ enabled: false,
451
+ reason: isSucceeded
452
+ ? 'Preview is unavailable for this variant'
453
+ : 'Variant is still running',
454
+ },
455
+ commit: canCommit
456
+ ? { enabled: true }
457
+ : {
458
+ enabled: false,
459
+ reason: commitDisabledReason,
460
+ },
461
+ },
462
+ };
463
+ });
464
+ }
159
465
  hasSession(sessionId) {
160
466
  return this.store.hasSession(sessionId);
161
467
  }
468
+ /** Read the projectContext for a session — used by Express routes that
469
+ * build the chip snapshot via `stateForSession`. */
470
+ getProjectContext(sessionId) {
471
+ return this.store.getProjectContext(sessionId);
472
+ }
162
473
  /** Resolve a worktree path for a code-gen work item, if provisioned. */
163
474
  getWorktreePath(sessionId, workItemId) {
164
475
  return this.resources.get(sessionId)?.worktrees.get(workItemId)
@@ -168,16 +479,45 @@ class AgentVariantsOrchestrator {
168
479
  getDevServerPort(sessionId, workItemId) {
169
480
  return this.resources.get(sessionId)?.worktrees.get(workItemId)?.port;
170
481
  }
482
+ getStaticPreviewHtml(sessionId, workItemId) {
483
+ // Primary: from the staticPreviews Map populated by handleSucceededReport.
484
+ const fromMap = this.resources.get(sessionId)?.staticPreviews.get(workItemId)?.html;
485
+ if (fromMap)
486
+ return fromMap;
487
+ // Fallback: read directly from the work item's stored output — available
488
+ // as soon as reportComplete runs, before handleSucceededReport fires.
489
+ try {
490
+ const output = this.store.getWorkItemOutput(sessionId, workItemId);
491
+ return typeof output?.html === 'string' && output.html.length > 0
492
+ ? buildStaticPreviewDocument({ html: output.html })
493
+ : undefined;
494
+ }
495
+ catch {
496
+ return undefined;
497
+ }
498
+ }
499
+ getStaticPreviewByBriefId(sessionId, briefId) {
500
+ const resources = this.resources.get(sessionId);
501
+ if (!resources)
502
+ return undefined;
503
+ return [...resources.staticPreviews.values()].find((preview) => preview.briefId === briefId);
504
+ }
505
+ buildStaticPreviewUrl(sessionId, workItemId) {
506
+ const safeSessionId = encodeURIComponent(sessionId);
507
+ const safeWorkItemId = encodeURIComponent(workItemId);
508
+ return `/api/variants/${safeSessionId}/static/${safeWorkItemId}`;
509
+ }
171
510
  // --- Mutation wrappers (state + side effects) ---------------------------
172
511
  async approve(args) {
173
512
  const result = this.store.approve(args);
174
- this.ensureResources(args.sessionId);
513
+ const resources = this.ensureResources(args.sessionId);
514
+ resources.approveStartedAt = Date.now();
175
515
  this.telemetry.track('agent_variants.approved', {
176
516
  source: 'mcp',
177
517
  sessionId: args.sessionId,
178
518
  approvedCount: result.approvedCount,
179
519
  totalBriefs: result.totalCount,
180
- isFresh: Boolean(result.scaffoldBaseWorkItemId),
520
+ isFresh: this.store.getProjectContext(args.sessionId).kind === 'fresh',
181
521
  });
182
522
  this.emitChange();
183
523
  // Await provisioning before returning so request_work always sees a
@@ -195,28 +535,153 @@ class AgentVariantsOrchestrator {
195
535
  }
196
536
  return result;
197
537
  }
538
+ /**
539
+ * Unified one-call kickoff that collapses propose → report_briefs →
540
+ * approve. Synthesizes placeholder briefs server-side (the agent's
541
+ * streamed first-line label replaces them in the UI) and immediately
542
+ * approves so the session lands in `work_items_ready`.
543
+ *
544
+ * Supports both existing-project sessions (spawns code_gen work items
545
+ * for the agent to lease) and zero-to-one sessions (spawns
546
+ * static_preview work items; the server runs scaffold_base in the
547
+ * background). For zero-to-one sessions with source URLs / inspiration
548
+ * extraction, callers must use create_zero_to_one_project instead —
549
+ * this method does not run source research.
550
+ *
551
+ * Host-agnostic: no LLM calls happen here. The agent (Claude Code,
552
+ * Cursor, Codex) generates the label and code when it leases the
553
+ * work items via continue_variants(action="request_work").
554
+ */
555
+ async startUnified(args) {
556
+ const count = args.briefs?.length ?? args.count ?? 4;
557
+ const projectContext = args.projectContext ?? { kind: 'existing' };
558
+ const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
559
+ const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
560
+ Boolean(sourceContext?.sourceArtifacts?.length) ||
561
+ Boolean(sourceContext?.sourceIntent) ||
562
+ Boolean(sourceContext?.artifact);
563
+ if (isSourceGrounded) {
564
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-grounded sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
565
+ }
566
+ const proposeResult = this.propose({
567
+ prompt: args.prompt,
568
+ count,
569
+ target: args.target,
570
+ projectContext,
571
+ });
572
+ if (proposeResult.stage !== 'awaiting_briefs' ||
573
+ !proposeResult.briefWorkItem) {
574
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
575
+ }
576
+ const briefs = args.briefs
577
+ ? args.briefs.map((b, i) => ({
578
+ briefId: `v${i + 1}`,
579
+ label: b.label,
580
+ body: b.body,
581
+ ...(b.visualReferenceUrl
582
+ ? { visualReferenceUrl: b.visualReferenceUrl }
583
+ : {}),
584
+ }))
585
+ : Array.from({ length: count }, (_, i) => ({
586
+ briefId: `v${i + 1}`,
587
+ label: `Variant ${i + 1}`,
588
+ body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
589
+ }));
590
+ this.store.reportBriefs({
591
+ sessionId: proposeResult.sessionId,
592
+ workItemId: proposeResult.briefWorkItem.id,
593
+ attempt: proposeResult.briefWorkItem.attempt,
594
+ briefs,
595
+ });
596
+ const approveResult = await this.approve({
597
+ sessionId: proposeResult.sessionId,
598
+ selections: briefs.map((b) => ({ briefId: b.briefId })),
599
+ });
600
+ this.telemetry.track('agent_variants.unified_started', {
601
+ source: 'mcp',
602
+ sessionId: proposeResult.sessionId,
603
+ count,
604
+ hasTarget: Boolean(args.target),
605
+ targetType: args.target?.type ?? null,
606
+ projectContextKind: projectContext.kind,
607
+ });
608
+ // For existing-project sessions, approve() returns the new code_gen
609
+ // ids in codeGenWorkItemIds. For zero-to-one, approve() spawns
610
+ // static_preview items instead — those ids aren't in codeGenWorkItemIds,
611
+ // so read them out of the active-variants snapshot which covers both
612
+ // kinds. Snapshot order mirrors brief order (insertion order).
613
+ const variantIds = projectContext.kind === 'fresh'
614
+ ? this.store
615
+ .getVariants(proposeResult.sessionId)
616
+ .map((v) => v.workItemId)
617
+ : approveResult.codeGenWorkItemIds;
618
+ const variants = variantIds.map((workItemId, i) => ({
619
+ variantId: workItemId,
620
+ briefId: briefs[i].briefId,
621
+ label: briefs[i].label,
622
+ status: 'pending',
623
+ }));
624
+ let leaseId;
625
+ let leaseTtlMs;
626
+ let leasedWorkItems;
627
+ if (args.leaseOwner) {
628
+ const lease = this.requestWork({
629
+ sessionId: proposeResult.sessionId,
630
+ leaseOwner: args.leaseOwner,
631
+ });
632
+ leaseId = lease.leaseId;
633
+ leaseTtlMs = lease.leaseTtlMs;
634
+ leasedWorkItems = lease.leasedWorkItems;
635
+ }
636
+ return {
637
+ sessionId: proposeResult.sessionId,
638
+ variants,
639
+ scaffoldBaseWorkItemId: approveResult.scaffoldBaseWorkItemId ?? null,
640
+ leaseId,
641
+ leaseTtlMs,
642
+ leasedWorkItems,
643
+ };
644
+ }
198
645
  async reportComplete(args) {
199
- const result = this.store.reportComplete(args);
646
+ // QA gate: for `succeeded` static_preview reports, run preview QA
647
+ // synchronously before recording success in the store. A failed QA
648
+ // verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
649
+ // so the variant never reaches `ready` and `getVariants` can disable
650
+ // its commit action.
651
+ const qaOverride = await this.evaluateQaForReport(args);
652
+ const effectiveArgs = qaOverride?.overrideArgs ?? args;
653
+ const result = this.store.reportComplete(effectiveArgs);
200
654
  this.emitChange();
201
655
  // Per-variant telemetry on terminal item statuses (skip 'running'
202
656
  // heartbeats and the brief work item — only code_gen / scaffold_base
203
657
  // matter for the funnel).
204
- if (args.status === 'succeeded' ||
205
- args.status === 'failed' ||
206
- args.status === 'cancelled') {
658
+ if (effectiveArgs.status === 'succeeded' ||
659
+ effectiveArgs.status === 'failed' ||
660
+ effectiveArgs.status === 'cancelled') {
207
661
  const resources = this.resources.get(args.sessionId);
208
662
  const leasedAt = resources?.leasedAt.get(args.workItemId);
209
663
  const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
664
+ const approveStartedAt = resources?.approveStartedAt;
665
+ const approveToCompleteMs = approveStartedAt !== undefined ? Date.now() - approveStartedAt : null;
666
+ // Byte size of generated output for HTML vs full-stack comparison.
667
+ const outputBytes = estimateOutputBytes(args.output);
668
+ const projectContextKind = this.store.getProjectContext(args.sessionId).kind;
210
669
  this.telemetry.track('agent_variants.variant_completed', {
211
670
  source: 'mcp',
212
671
  sessionId: args.sessionId,
213
672
  workItemId: args.workItemId,
214
- status: args.status,
673
+ status: effectiveArgs.status,
215
674
  attempt: args.attempt,
216
675
  durationMs,
217
- hasError: Boolean(args.error),
218
- errorCode: args.error?.code ?? null,
676
+ approveToCompleteMs,
677
+ hasError: Boolean(effectiveArgs.error),
678
+ errorCode: effectiveArgs.error?.code ?? null,
219
679
  isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
680
+ projectContextKind,
681
+ tokensIn: args.tokensIn ?? null,
682
+ tokensOut: args.tokensOut ?? null,
683
+ model: args.model ?? null,
684
+ outputBytes,
220
685
  });
221
686
  }
222
687
  if (contracts_1.TERMINAL_STAGES.has(result.stage)) {
@@ -224,6 +689,9 @@ class AgentVariantsOrchestrator {
224
689
  if (resources && resources.terminalAt === undefined) {
225
690
  resources.terminalAt = Date.now();
226
691
  const totalLatencyMs = resources.terminalAt - resources.startedAt;
692
+ const approveToTerminalMs = resources.approveStartedAt !== undefined
693
+ ? resources.terminalAt - resources.approveStartedAt
694
+ : null;
227
695
  const summary = result.summary ?? this.store.getSummary(args.sessionId);
228
696
  this.telemetry.track('agent_variants.terminal_state', {
229
697
  source: 'mcp',
@@ -233,11 +701,17 @@ class AgentVariantsOrchestrator {
233
701
  failureCount: summary.failureCount,
234
702
  cancelledCount: summary.cancelledCount,
235
703
  totalLatencyMs,
704
+ approveToTerminalMs,
705
+ projectContextKind: this.store.getProjectContext(args.sessionId).kind,
236
706
  });
237
707
  }
238
708
  }
239
- if (args.status === 'succeeded') {
240
- void this.handleSucceededReport(args.sessionId, args.workItemId).catch((err) => {
709
+ if (effectiveArgs.status === 'succeeded') {
710
+ void this.handleSucceededReport({
711
+ sessionId: args.sessionId,
712
+ workItemId: args.workItemId,
713
+ output: args.output,
714
+ }).catch((err) => {
241
715
  log.error(`handleSucceededReport failed for ${args.sessionId}/${args.workItemId}`, err);
242
716
  });
243
717
  }
@@ -267,6 +741,37 @@ class AgentVariantsOrchestrator {
267
741
  });
268
742
  return result;
269
743
  }
744
+ /**
745
+ * Cancel a single variant within a session. Siblings continue. Idempotent
746
+ * on terminal states. Per-variant worktree teardown is deferred to a
747
+ * follow-up; the lease is released, so requestWork won't re-surface this
748
+ * variant and the agent's report_variant_complete for it (if it lands
749
+ * after cancellation) is rejected by the stale-lease check.
750
+ */
751
+ async cancelVariant(args) {
752
+ const result = this.store.cancelWorkItem({
753
+ sessionId: args.sessionId,
754
+ workItemId: args.variantId,
755
+ reason: args.reason,
756
+ });
757
+ this.telemetry.track('agent_variants.variant_cancelled', {
758
+ source: 'mcp',
759
+ sessionId: args.sessionId,
760
+ variantId: args.variantId,
761
+ finalStatus: result.finalStatus,
762
+ sessionStage: result.sessionStage,
763
+ alreadyTerminal: result.alreadyTerminal,
764
+ reason: args.reason ?? null,
765
+ });
766
+ this.emitChange();
767
+ return {
768
+ sessionId: args.sessionId,
769
+ variantId: args.variantId,
770
+ finalStatus: result.finalStatus,
771
+ sessionStage: result.sessionStage,
772
+ alreadyTerminal: result.alreadyTerminal,
773
+ };
774
+ }
270
775
  /**
271
776
  * User has reviewed the rendered variants in chat and picked one. Look up
272
777
  * the captured diff, build a VariantPickEnvelope, and enqueue to the
@@ -274,6 +779,29 @@ class AgentVariantsOrchestrator {
274
779
  * the call returns duplicate=true without re-enqueueing.
275
780
  */
276
781
  async commitVariant(args) {
782
+ // Serialize per-session so two overlapping commits can't both run
783
+ // `applyDiffToProject` (or the fresh rename) before the first records
784
+ // its pick. The second caller waits for the first to settle, then runs
785
+ // commitVariantImpl which hits the idempotency check in
786
+ // `getVariantPick` / `recordVariantPick`.
787
+ const prior = this.inflightCommits.get(args.sessionId);
788
+ const run = (async () => {
789
+ if (prior) {
790
+ await prior.catch(() => undefined);
791
+ }
792
+ return this.commitVariantImpl(args);
793
+ })();
794
+ this.inflightCommits.set(args.sessionId, run);
795
+ try {
796
+ return await run;
797
+ }
798
+ finally {
799
+ if (this.inflightCommits.get(args.sessionId) === run) {
800
+ this.inflightCommits.delete(args.sessionId);
801
+ }
802
+ }
803
+ }
804
+ async commitVariantImpl(args) {
277
805
  // Idempotent path — SessionStore.recordVariantPick is the source of truth
278
806
  // for which variant the user picked. If the same variant is being
279
807
  // committed again, replay the previously stored envelope's metadata
@@ -284,43 +812,212 @@ class AgentVariantsOrchestrator {
284
812
  enqueued: false,
285
813
  duplicate: true,
286
814
  changedFilesCount: existingPick.changedFilesCount,
815
+ payloadKind: existingPick.payload.kind === 'new-project'
816
+ ? 'project-created'
817
+ : existingPick.payload.kind,
818
+ destinationPath: existingPick.destinationPath,
287
819
  };
288
820
  }
289
821
  const resources = this.resources.get(args.sessionId);
290
822
  if (!resources) {
291
823
  throw new errors_1.AgentVariantsError('SESSION_NOT_FOUND', `No live resources for session ${args.sessionId} (already torn down?)`);
292
824
  }
293
- const record = resources.worktrees.get(args.variantId);
294
- if (!record) {
295
- throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
296
- }
297
- if (record.diff === undefined) {
298
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
825
+ const variantSnapshot = this.getVariants(args.sessionId).find((variant) => variant.workItemId === args.variantId);
826
+ if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
827
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
299
828
  }
300
- const env = await this.resolveEnv(args.sessionId);
301
829
  const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
302
830
  const projectContext = this.store.getProjectContext(args.sessionId);
303
- const changedFilesCount = countDiffFiles(record.diff);
304
- const payload = projectContext.kind === 'fresh'
305
- ? {
306
- kind: 'new-project',
307
- destinationPath: projectContext.workspacePath,
308
- sourceWorktreePath: record.worktreePath,
309
- changedFilesCount,
831
+ let payload;
832
+ let envelopeDestination;
833
+ let changedFilesCount;
834
+ let freshVariantFolderName;
835
+ if (projectContext.kind === 'fresh') {
836
+ const destinationPath = projectContext.workspacePath;
837
+ this.assertDestinationAvailable(destinationPath);
838
+ const variantFolderName = this.getFreshVariantFolderName({
839
+ sessionId: args.sessionId,
840
+ variantId: args.variantId,
841
+ variantName: input.briefLabel,
842
+ });
843
+ freshVariantFolderName = variantFolderName;
844
+ const freshMode = projectContext.executionPlan?.mode === 'vite_app'
845
+ ? 'vite_app'
846
+ : 'static_preview';
847
+ if (freshMode === 'vite_app') {
848
+ // Vite_app: the deliverable is the entire variant worktree, not a
849
+ // single HTML file. When the worktree lives on the same volume as
850
+ // the destination (the common case — provisionFreshWorktrees scaffolds
851
+ // under destinationParent/.rivet-variants/) we can rename it into
852
+ // place. That moves the entire tree — including the materialized
853
+ // node_modules — in O(1) with no copy, and the rename is atomic.
854
+ // For cross-volume cases (user passed a destinationParent on a
855
+ // different mount than the .rivet-variants dir, e.g. tests stubbing
856
+ // workspacePath to a tmp dir), fall back to the recursive-copy +
857
+ // background-install path so the feature still works.
858
+ const record = resources.worktrees.get(args.variantId);
859
+ if (!record) {
860
+ throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
861
+ }
862
+ let usedRename = false;
863
+ let isDependencyInstallRequired = false;
864
+ try {
865
+ if (areSameVolume(record.worktreePath, destinationPath)) {
866
+ // Vite's dev server writes its module cache to `<worktreePath>/.vite/`
867
+ // and recreates that directory on the fly whenever it's missing. If
868
+ // we rename a worktree out from under a running dev server, Vite
869
+ // will materialize an empty `.vite/` shell back at the OLD path —
870
+ // leaving behind orphan stub dirs in `.rivet-variants/` and
871
+ // confusing later cleanup. Stop every variant's dev server first
872
+ // (including the chosen variant, which is about to move) so no
873
+ // process is holding the old paths open during the rename.
874
+ //
875
+ // We stop them eagerly here rather than relying on teardownSession
876
+ // because teardown runs AFTER the rename — too late to prevent the
877
+ // stub-dir race.
878
+ await this.stopAllDevServersForSession(resources);
879
+ // node_modules in fresh variants 1..N is a symlink into variant 0
880
+ // (see runBackgroundInstall). After rename, variant 0's
881
+ // node_modules vanishes during cleanup of the sibling worktrees,
882
+ // leaving the chosen variant with a dangling link. Resolve the
883
+ // symlink to a real directory first so the destination has real
884
+ // deps and the rename is self-contained.
885
+ isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
886
+ await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
887
+ usedRename = true;
888
+ }
889
+ else {
890
+ await this.materializeProject(record.worktreePath, destinationPath);
891
+ }
892
+ }
893
+ catch (err) {
894
+ const message = err instanceof Error ? err.message : String(err);
895
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
896
+ }
897
+ changedFilesCount = countWorktreeFiles(destinationPath);
898
+ if (!usedRename) {
899
+ // Cross-volume fallback: materializeProject excludes node_modules
900
+ // so we have to re-install at the destination. Don't await the
901
+ // install — the agent's commit_variant call should return
902
+ // immediately — but chain the dev-server start to it so the iframe
903
+ // recovers without manual intervention once deps are ready.
904
+ void this.installDependencies(destinationPath)
905
+ .then(() => this.startCommittedDevServer({
906
+ resources,
907
+ destinationPath,
908
+ }))
909
+ .catch((err) => {
910
+ log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
911
+ });
912
+ }
913
+ else if (isDependencyInstallRequired) {
914
+ await this.installDependencies(destinationPath);
915
+ }
916
+ if (usedRename) {
917
+ // Preserve the unchosen variants as runnable siblings of the
918
+ // chosen project. Best-effort: failures here log and continue so
919
+ // a partial history never blocks the commit handoff.
920
+ try {
921
+ this.preserveUnchosenVariants({
922
+ sessionId: args.sessionId,
923
+ chosenVariantId: args.variantId,
924
+ destinationPath,
925
+ });
926
+ }
927
+ catch (err) {
928
+ log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
929
+ }
930
+ // Stopped every dev server pre-rename to dodge the .vite/ stub-dir
931
+ // race. The iframe is still pointed at the (now-dead) chosen
932
+ // variant's port via the proxy, so without a fresh server at the
933
+ // destination the user would see "preview disconnected" the moment
934
+ // they commit. Spawn one and retarget the proxy. Best-effort: if
935
+ // it fails, the user can `npm run dev` themselves at destination.
936
+ await this.startCommittedDevServer({
937
+ resources,
938
+ destinationPath,
939
+ });
940
+ }
941
+ payload = {
942
+ kind: 'project-created',
943
+ destinationPath,
944
+ changedFilesCount,
945
+ note: usedRename
946
+ ? 'Vite app moved to destinationPath (rename); node_modules came along.'
947
+ : 'Vite app materialized at destinationPath; `npm install` running in the background.',
948
+ };
949
+ envelopeDestination = destinationPath;
950
+ }
951
+ else {
952
+ // Static_preview: HTML is the entire deliverable. Write index.html.
953
+ const staticPreview = resources.staticPreviews.get(args.variantId);
954
+ const persistedSnapshotIndexPath = (0, createProjectArtifacts_1.createProjectVariantIndexPath)(destinationPath, variantFolderName);
955
+ const htmlFromSnapshot = fs_1.default.existsSync(persistedSnapshotIndexPath)
956
+ ? fs_1.default.readFileSync(persistedSnapshotIndexPath, 'utf8')
957
+ : null;
958
+ if (!staticPreview && !htmlFromSnapshot) {
959
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
960
+ }
961
+ try {
962
+ fs_1.default.mkdirSync(destinationPath, { recursive: true });
963
+ fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
964
+ }
965
+ catch (err) {
966
+ const message = err instanceof Error ? err.message : String(err);
967
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
968
+ }
969
+ changedFilesCount = 1;
970
+ payload = {
971
+ kind: 'project-created',
972
+ destinationPath,
973
+ changedFilesCount,
974
+ note: 'Static preview written to index.html at destinationPath.',
975
+ };
976
+ envelopeDestination = destinationPath;
977
+ }
978
+ }
979
+ else {
980
+ // Existing project: diff against the user's repo. resolveEnv lives in
981
+ // this branch only — fresh sessions don't have a user-supplied project
982
+ // and the bridge.getProjectPath() it reads from would either be stale
983
+ // or empty for them.
984
+ const env = await this.resolveEnv(args.sessionId);
985
+ const record = resources.worktrees.get(args.variantId);
986
+ if (!record) {
987
+ throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
988
+ }
989
+ if (record.diff === undefined) {
990
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
991
+ }
992
+ changedFilesCount = countDiffFiles(record.diff);
993
+ // Apply the diff to the user's working tree right now so the variant
994
+ // lands on the current branch (uncommitted). Previously this just
995
+ // enqueued the diff for an agent to apply; non-MCP sessions had no
996
+ // agent and the variant was effectively lost.
997
+ if (!this.worktrees.applyDiffToProject) {
998
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
999
+ }
1000
+ try {
1001
+ await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
1002
+ }
1003
+ catch (err) {
1004
+ const message = err instanceof Error ? err.message : String(err);
1005
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
310
1006
  }
311
- : {
312
- kind: 'diff',
1007
+ payload = {
1008
+ kind: 'diff-applied',
313
1009
  diff: record.diff,
314
1010
  target: input.target,
315
1011
  changedFilesCount,
1012
+ note: 'Variant diff applied to the user\'s working tree (uncommitted).',
316
1013
  };
1014
+ envelopeDestination = env.projectPath;
1015
+ }
317
1016
  const envelope = {
318
1017
  sourceSessionId: args.sessionId,
319
1018
  variantId: args.variantId,
320
1019
  variantLabel: input.briefLabel,
321
- destinationPath: projectContext.kind === 'fresh'
322
- ? projectContext.workspacePath
323
- : env.projectPath,
1020
+ destinationPath: envelopeDestination,
324
1021
  changedFilesCount,
325
1022
  payload,
326
1023
  };
@@ -331,6 +1028,27 @@ class AgentVariantsOrchestrator {
331
1028
  sessionId: args.sessionId,
332
1029
  envelope,
333
1030
  });
1031
+ if (projectContext.kind === 'fresh') {
1032
+ // Manifest persistence is bookkeeping for the .rivet/ artifacts and must
1033
+ // never strand the session — `recordVariantPick` above has already marked
1034
+ // this variant as the selection, so a retry would short-circuit via the
1035
+ // `duplicate: true` path and skip the enqueue below. Catch and log
1036
+ // instead of letting a filesystem hiccup block the handoff to pending-
1037
+ // changes.
1038
+ try {
1039
+ this.persistFreshSelectionMetadata({
1040
+ sessionId: args.sessionId,
1041
+ variantId: args.variantId,
1042
+ variantName: input.briefLabel,
1043
+ projectPath: projectContext.workspacePath,
1044
+ variantFolderName: freshVariantFolderName ?? (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel),
1045
+ changedFilesCount,
1046
+ });
1047
+ }
1048
+ catch (err) {
1049
+ log.warn(`persistFreshSelectionMetadata failed for session ${args.sessionId} variant ${args.variantId} — continuing with enqueue`, err);
1050
+ }
1051
+ }
334
1052
  const enqueueResult = this.adapter.enqueue(envelope);
335
1053
  resources.committedVariantIds.add(args.variantId);
336
1054
  if (this.activeSessionId === args.sessionId) {
@@ -350,24 +1068,63 @@ class AgentVariantsOrchestrator {
350
1068
  });
351
1069
  this.emitChange();
352
1070
  log.info(`Variant ${args.variantId} (${input.briefLabel}) committed by user → enqueued=${enqueueResult.enqueued}`);
353
- // Once a variant is committed, the unpicked worktrees are no longer
354
- // useful. Schedule teardown in the background so the agent's
355
- // commit_variant call returns immediately.
356
- void this.teardownSession(args.sessionId, 'committed').catch((err) => {
357
- log.warn(`teardownSession after commit failed for ${args.sessionId}`, err);
358
- });
1071
+ if (!args.skipTeardown) {
1072
+ // Once a variant is committed, the unpicked worktrees are no longer
1073
+ // useful. Schedule teardown in the background so the agent's
1074
+ // commit_variant call returns immediately.
1075
+ void this.teardownSession(args.sessionId, 'committed').catch((err) => {
1076
+ log.warn(`teardownSession after commit failed for ${args.sessionId}`, err);
1077
+ });
1078
+ }
359
1079
  return {
360
1080
  enqueued: enqueueResult.enqueued,
361
1081
  duplicate: enqueueResult.duplicate,
362
1082
  changedFilesCount,
1083
+ payloadKind: payload.kind,
1084
+ destinationPath: envelopeDestination,
363
1085
  };
364
1086
  }
1087
+ async cleanupCommittedSession(sessionId) {
1088
+ await this.teardownSession(sessionId, 'committed');
1089
+ }
1090
+ /**
1091
+ * Ensure the user-facing destination path can receive the new project.
1092
+ * Rejects when the path exists and is non-empty.
1093
+ */
1094
+ assertDestinationAvailable(destinationPath) {
1095
+ if (!fs_1.default.existsSync(destinationPath))
1096
+ return;
1097
+ const entries = fs_1.default.readdirSync(destinationPath);
1098
+ const userVisibleEntries = entries.filter((entry) => entry !== '.rivet');
1099
+ if (userVisibleEntries.length === 0)
1100
+ return;
1101
+ throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
1102
+ }
365
1103
  /** Read the captured diff for a code-gen variant, if available. */
366
1104
  getVariantDiff(sessionId, variantId) {
367
1105
  return this.resources.get(sessionId)?.worktrees.get(variantId)?.diff;
368
1106
  }
369
1107
  // --- Side-effect implementations ----------------------------------------
370
1108
  async provisionWorktrees(sessionId, approveResult) {
1109
+ const projectContext = this.store.getProjectContext(sessionId);
1110
+ if (projectContext.kind === 'fresh') {
1111
+ // Fresh + static_preview: no worktrees, no scaffold, HTML is the
1112
+ // deliverable. Fresh + vite_app: provision a Vite skeleton per variant
1113
+ // and copy the agent-planned assetPlan files before the agent leases
1114
+ // its code_gen items.
1115
+ if (approveResult.scaffoldBaseWorkItemId) {
1116
+ await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
1117
+ }
1118
+ return;
1119
+ }
1120
+ await this.provisionExistingWorktrees(sessionId, approveResult);
1121
+ }
1122
+ /**
1123
+ * Existing-project flow: clone the user's repo N times via git worktree.
1124
+ * Runs synchronously because the operation is fast (no install needed —
1125
+ * node_modules is symlinked from the source project).
1126
+ */
1127
+ async provisionExistingWorktrees(sessionId, approveResult) {
371
1128
  const resources = this.ensureResources(sessionId);
372
1129
  const totalCount = approveResult.codeGenWorkItemIds.length +
373
1130
  (approveResult.scaffoldBaseWorkItemId ? 1 : 0);
@@ -385,14 +1142,268 @@ class AgentVariantsOrchestrator {
385
1142
  cursor += 1;
386
1143
  }
387
1144
  for (const id of approveResult.codeGenWorkItemIds) {
388
- const path = paths[cursor];
1145
+ const p = paths[cursor];
389
1146
  cursor += 1;
390
- resources.worktrees.set(id, { workItemId: id, worktreePath: path });
1147
+ resources.worktrees.set(id, { workItemId: id, worktreePath: p });
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Fresh-project flow: scaffold one template per variant + DESIGN.md per
1152
+ * slot synchronously (fast — just file writes + git init), then dispatch a
1153
+ * background `npm install` task per worktree. Returns immediately so the
1154
+ * `approve_variant_briefs` MCP tool doesn't time out on cold installs.
1155
+ *
1156
+ * On install success: `sessionStore.completeInternal(scaffold_base)` runs,
1157
+ * which unblocks the dependent code_gen items.
1158
+ *
1159
+ * On install failure: the scaffold_base work item is marked failed via
1160
+ * `failInternal`, which cascades DEPENDENCY_FAILED to all dependent
1161
+ * code_gen items so the session degrades cleanly.
1162
+ */
1163
+ async provisionFreshWorktrees(sessionId, approveResult, projectContext) {
1164
+ const resources = this.ensureResources(sessionId);
1165
+ const scaffoldId = approveResult.scaffoldBaseWorkItemId;
1166
+ if (!scaffoldId) {
1167
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Fresh session ${sessionId} approved without a scaffold_base item`);
1168
+ }
1169
+ const codeGenIds = approveResult.codeGenWorkItemIds;
1170
+ if (codeGenIds.length === 0)
1171
+ return;
1172
+ const designContext = projectContext.designContext
1173
+ ? codeGenIds.map((codeGenId) => {
1174
+ const input = this.store.getWorkItemInput(sessionId, codeGenId);
1175
+ return input.designContextEntry;
1176
+ })
1177
+ : undefined;
1178
+ const sourceContext = projectContext.sourceContext;
1179
+ const createFresh = this.worktrees.createFreshWorktrees;
1180
+ if (!createFresh) {
1181
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'worktreeManager does not implement createFreshWorktrees');
1182
+ }
1183
+ const scaffoldStartedAt = Date.now();
1184
+ trackScaffoldStarted(this.telemetry, {
1185
+ sessionId,
1186
+ variantCount: codeGenIds.length,
1187
+ designContext: summarizeDesignContext(designContext),
1188
+ });
1189
+ log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
1190
+ // destinationParent for fresh worktrees: same parent the materialized
1191
+ // project will land in. Keeping the worktree on the same volume turns
1192
+ // commit into a directory rename instead of a recursive copy.
1193
+ const destinationParent = path_1.default.dirname(projectContext.workspacePath);
1194
+ const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
1195
+ resources.scaffoldBaseWorkItemId = scaffoldId;
1196
+ resources.freshDestinationParent = destinationParent;
1197
+ // Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
1198
+ // work item is internal — no dedicated worktree.
1199
+ for (let i = 0; i < codeGenIds.length; i++) {
1200
+ resources.worktrees.set(codeGenIds[i], {
1201
+ workItemId: codeGenIds[i],
1202
+ worktreePath: paths[i],
1203
+ });
1204
+ }
1205
+ // Copy agent-planned assets (e.g. avatar.glb) into each worktree so
1206
+ // generated code can reference them via plan.referenceAs. Done before
1207
+ // the background install so the assets are on disk by the time the
1208
+ // agent leases its code_gen item.
1209
+ // If copy throws (e.g. source asset deleted between planning and
1210
+ // provisioning), fail scaffold_base immediately so dependent code_gen
1211
+ // items cascade DEPENDENCY_FAILED instead of leaving the session
1212
+ // permanently stuck in `work_items_ready`.
1213
+ const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
1214
+ if (assetPlan.length > 0) {
1215
+ try {
1216
+ for (const worktreePath of paths) {
1217
+ for (const entry of assetPlan) {
1218
+ copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
1219
+ }
1220
+ }
1221
+ }
1222
+ catch (err) {
1223
+ const message = err instanceof Error ? err.message : String(err);
1224
+ log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
1225
+ trackScaffoldFailed(this.telemetry, {
1226
+ sessionId,
1227
+ errorCode: 'SCAFFOLD_FAILED',
1228
+ durationMs: Date.now() - scaffoldStartedAt,
1229
+ });
1230
+ try {
1231
+ this.store.failInternal({
1232
+ sessionId,
1233
+ workItemId: scaffoldId,
1234
+ error: { code: 'SCAFFOLD_FAILED', message },
1235
+ });
1236
+ this.emitChange();
1237
+ }
1238
+ catch (storeErr) {
1239
+ log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
1240
+ }
1241
+ return;
1242
+ }
391
1243
  }
1244
+ // Background install: don't await. The orchestrator finishes the
1245
+ // approve→provision call immediately; the agent polls for scaffold
1246
+ // completion via continue_variants(action='check').
1247
+ void this.runBackgroundInstall(sessionId, scaffoldId, paths, scaffoldStartedAt).catch((err) => {
1248
+ log.error(`runBackgroundInstall failed for session ${sessionId}`, err);
1249
+ });
392
1250
  }
393
- async handleSucceededReport(sessionId, workItemId) {
1251
+ /**
1252
+ * Run `npm install` in parallel across all fresh worktrees. On all-success,
1253
+ * mark scaffold_base succeeded via `completeInternal`. On any failure,
1254
+ * mark scaffold_base failed via `failInternal` and emit telemetry.
1255
+ */
1256
+ async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
1257
+ const installStartedAt = Date.now();
1258
+ try {
1259
+ const [firstWorktree, ...rest] = worktreePaths;
1260
+ await this.installDependencies(firstWorktree);
1261
+ await Promise.all(rest.map(async (p) => {
1262
+ const target = path_1.default.join(firstWorktree, 'node_modules');
1263
+ const link = path_1.default.join(p, 'node_modules');
1264
+ // The fresh template has no node_modules until install runs, so
1265
+ // the link target is guaranteed absent in `p` — no need to remove
1266
+ // first.
1267
+ try {
1268
+ fs_1.default.symlinkSync(target, link, 'dir');
1269
+ }
1270
+ catch (symlinkErr) {
1271
+ log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
1272
+ await this.installDependencies(p);
1273
+ }
1274
+ }));
1275
+ }
1276
+ catch (err) {
1277
+ const message = err instanceof Error ? err.message : String(err);
1278
+ log.warn(`npm install failed for session ${sessionId}; marking scaffold_base failed`, err);
1279
+ trackScaffoldFailed(this.telemetry, {
1280
+ sessionId,
1281
+ errorCode: 'SCAFFOLD_FAILED',
1282
+ durationMs: Date.now() - scaffoldStartedAt,
1283
+ });
1284
+ try {
1285
+ this.store.failInternal({
1286
+ sessionId,
1287
+ workItemId: scaffoldWorkItemId,
1288
+ error: { code: 'SCAFFOLD_FAILED', message },
1289
+ });
1290
+ this.emitChange();
1291
+ }
1292
+ catch (storeErr) {
1293
+ log.error(`failInternal failed for ${sessionId}/${scaffoldWorkItemId}`, storeErr);
1294
+ }
1295
+ return;
1296
+ }
1297
+ try {
1298
+ this.store.completeInternal({
1299
+ sessionId,
1300
+ workItemId: scaffoldWorkItemId,
1301
+ output: { worktreePaths },
1302
+ });
1303
+ this.emitChange();
1304
+ }
1305
+ catch (err) {
1306
+ log.warn(`completeInternal failed for ${sessionId}/${scaffoldWorkItemId}`, err);
1307
+ }
1308
+ trackScaffoldCompleted(this.telemetry, {
1309
+ sessionId,
1310
+ durationMs: Date.now() - scaffoldStartedAt,
1311
+ installDurationMs: Date.now() - installStartedAt,
1312
+ variantCount: worktreePaths.length,
1313
+ });
1314
+ }
1315
+ /**
1316
+ * Gate `succeeded` reports for `static_preview` items through preview QA
1317
+ * before they reach the store. Returns either `null` (caller continues
1318
+ * with the original args — non-static-preview report, non-succeeded
1319
+ * status, no parseable HTML, or QA passed) or an override carrying a
1320
+ * `failed` rewrite of the report. The QA result is also memoized on the
1321
+ * session resources so `getVariants` can surface it.
1322
+ *
1323
+ * Only fresh-project sessions go through this gate today; existing-
1324
+ * project `code_gen` variants produce a diff with no preview document
1325
+ * to inspect, so they skip QA and behave exactly as before.
1326
+ */
1327
+ async evaluateQaForReport(args) {
1328
+ if (args.status !== 'succeeded')
1329
+ return null;
1330
+ if (!this.store.hasSession(args.sessionId))
1331
+ return null;
1332
+ const projectContext = this.store.getProjectContext(args.sessionId);
1333
+ if (projectContext.kind !== 'fresh')
1334
+ return null;
1335
+ const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
1336
+ if (!parsed)
1337
+ return null;
1338
+ let qa;
1339
+ try {
1340
+ qa = await this.previewQaRunner({
1341
+ sessionId: args.sessionId,
1342
+ workItemId: args.workItemId,
1343
+ html: parsed.html,
1344
+ });
1345
+ }
1346
+ catch (err) {
1347
+ const message = err instanceof Error ? err.message : String(err);
1348
+ qa = {
1349
+ status: 'failed',
1350
+ issues: [
1351
+ {
1352
+ kind: 'preview_unavailable',
1353
+ detail: 'qa_runner_error',
1354
+ message,
1355
+ },
1356
+ ],
1357
+ summary: `Preview QA runner threw: ${message}`,
1358
+ };
1359
+ }
1360
+ const resources = this.ensureResources(args.sessionId);
1361
+ resources.qaResults.set(args.workItemId, qa);
1362
+ if (qa.status === 'failed') {
1363
+ return {
1364
+ qa,
1365
+ overrideArgs: {
1366
+ ...args,
1367
+ status: 'failed',
1368
+ error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
1369
+ },
1370
+ };
1371
+ }
1372
+ return { qa };
1373
+ }
1374
+ async handleSucceededReport(args) {
1375
+ const { sessionId, workItemId } = args;
394
1376
  const resources = this.resources.get(sessionId);
395
1377
  const record = resources?.worktrees.get(workItemId);
1378
+ if (resources && !record) {
1379
+ const staticPreview = parseStaticPreviewOutput(normalizeOutput(args.output));
1380
+ if (staticPreview) {
1381
+ const input = this.store.getWorkItemInput(sessionId, workItemId);
1382
+ if (input.briefId) {
1383
+ const record = {
1384
+ workItemId,
1385
+ briefId: input.briefId,
1386
+ html: staticPreview.html,
1387
+ };
1388
+ resources.staticPreviews.set(workItemId, record);
1389
+ if (this.store.getProjectContext(sessionId).kind === 'fresh') {
1390
+ this.persistFreshVariantArtifacts({
1391
+ sessionId,
1392
+ workItemId,
1393
+ html: staticPreview.html,
1394
+ });
1395
+ }
1396
+ const leasedAt = resources.leasedAt.get(workItemId);
1397
+ trackStaticPreviewCompleted(this.telemetry, {
1398
+ sessionId,
1399
+ variantId: workItemId,
1400
+ durationMs: leasedAt !== undefined ? Date.now() - leasedAt : null,
1401
+ });
1402
+ this.emitChange();
1403
+ }
1404
+ return;
1405
+ }
1406
+ }
396
1407
  if (!resources || !record) {
397
1408
  log.warn(`No worktree record for ${sessionId}/${workItemId}; skipping diff capture`);
398
1409
  return;
@@ -403,6 +1414,8 @@ class AgentVariantsOrchestrator {
403
1414
  log.info(`scaffold_base ${workItemId} succeeded; awaiting dependent code-gen`);
404
1415
  return;
405
1416
  }
1417
+ const projectContext = this.store.getProjectContext(sessionId);
1418
+ const isFresh = projectContext.kind === 'fresh';
406
1419
  try {
407
1420
  record.diff = await this.worktrees.getDiff(record.worktreePath);
408
1421
  log.info(`Variant ${workItemId} diff captured (${countDiffFiles(record.diff)} files)`);
@@ -415,26 +1428,457 @@ class AgentVariantsOrchestrator {
415
1428
  // through live variants in the iframe via the chip. Failures here are
416
1429
  // logged but non-fatal — the user can still pick by reading the diff.
417
1430
  try {
418
- const env = await this.resolveEnv(sessionId);
419
1431
  const port = await this.worktrees.getFreePort();
420
- const cwd = await this.worktrees.getProjectCwdInWorktree(record.worktreePath);
421
- const { cmd, args, env: spawnEnv } = env.buildDevCommand
422
- ? env.buildDevCommand(port)
423
- : {
424
- cmd: env.packageManager,
425
- args: [env.devCommand, '--port', String(port)],
426
- env: { PORT: String(port) },
427
- };
428
- const proc = await this.worktrees.startDevServer(cwd, port, cmd, args, spawnEnv);
1432
+ const dev = await this.resolveDevServer(sessionId, record.worktreePath, port, isFresh);
1433
+ const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
429
1434
  record.port = port;
430
1435
  record.devServerProcess = proc;
1436
+ // If the dev server dies on its own (crash, OOM, Vite hard-fail),
1437
+ // clear the port so the iframe stops routing the chip onto a dead
1438
+ // socket. Without this the proxy retargets onto an unreachable port
1439
+ // and the chip shows "upstream_unreachable" instead of the accurate
1440
+ // "Preview is unavailable for this variant".
1441
+ proc.once('exit', (code, signal) => {
1442
+ if (record.devServerProcess !== proc)
1443
+ return;
1444
+ record.port = undefined;
1445
+ record.devServerProcess = undefined;
1446
+ log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
1447
+ this.emitChange();
1448
+ });
431
1449
  this.emitChange();
432
- log.info(`Variant ${workItemId} dev server up on port ${port} (worktree ${record.worktreePath}; cmd: ${cmd} ${args.join(' ')})`);
1450
+ trackFreshDevServerStarted(this.telemetry, {
1451
+ sessionId,
1452
+ variantId: workItemId,
1453
+ port,
1454
+ });
1455
+ log.info(`Variant ${workItemId} dev server up on port ${port} (worktree ${record.worktreePath}; cmd: ${dev.cmd} ${dev.args.join(' ')})`);
433
1456
  }
434
1457
  catch (err) {
1458
+ trackFreshDevServerFailed(this.telemetry, {
1459
+ sessionId,
1460
+ variantId: workItemId,
1461
+ errorCode: 'DEV_SERVER_START_FAILED',
1462
+ });
435
1463
  log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
436
1464
  }
437
1465
  }
1466
+ persistFreshVariantArtifacts(args) {
1467
+ const projectContext = this.store.getProjectContext(args.sessionId);
1468
+ if (projectContext.kind !== 'fresh') {
1469
+ return;
1470
+ }
1471
+ const projectPath = projectContext.workspacePath;
1472
+ const now = new Date().toISOString();
1473
+ const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(projectPath);
1474
+ const existingProjectManifest = this.readManifest(projectManifestPath);
1475
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1476
+ createdAt: existingProjectManifest?.createdAt ?? now,
1477
+ selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1478
+ latestVariantSessionId: args.sessionId,
1479
+ selectedVariantId: existingProjectManifest?.selectedVariantId,
1480
+ }));
1481
+ const briefInput = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1482
+ const variantFolderName = this.getFreshVariantFolderName({
1483
+ sessionId: args.sessionId,
1484
+ variantId: args.workItemId,
1485
+ variantName: briefInput.briefLabel,
1486
+ });
1487
+ const snapshotPath = (0, createProjectArtifacts_1.createProjectVariantSnapshotPath)(projectPath, variantFolderName);
1488
+ fs_1.default.rmSync(snapshotPath, { recursive: true, force: true });
1489
+ fs_1.default.mkdirSync(snapshotPath, { recursive: true });
1490
+ const briefPath = (0, createProjectArtifacts_1.createProjectVariantBriefPath)(projectPath, variantFolderName);
1491
+ fs_1.default.writeFileSync(briefPath, `# ${briefInput.briefLabel}\n\n${briefInput.briefBody}\n`, 'utf8');
1492
+ const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(projectPath, variantFolderName);
1493
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1494
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1495
+ createdAt: now,
1496
+ sessionId: args.sessionId,
1497
+ variantId: args.workItemId,
1498
+ variantName: briefInput.briefLabel,
1499
+ variantFolderName,
1500
+ changedFilesCount: 1,
1501
+ });
1502
+ fs_1.default.writeFileSync((0, createProjectArtifacts_1.createProjectVariantIndexPath)(projectPath, variantFolderName), args.html, 'utf8');
1503
+ }
1504
+ persistFreshSelectionMetadata(args) {
1505
+ const now = new Date().toISOString();
1506
+ const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(args.projectPath);
1507
+ const existingProjectManifest = this.readManifest(projectManifestPath);
1508
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1509
+ createdAt: existingProjectManifest?.createdAt ?? now,
1510
+ selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1511
+ latestVariantSessionId: args.sessionId,
1512
+ selectedVariantId: args.variantId,
1513
+ }));
1514
+ const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(args.projectPath, args.variantFolderName);
1515
+ const existingVariantManifest = this.readManifest(variantManifestPath);
1516
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1517
+ ...existingVariantManifest,
1518
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1519
+ createdAt: existingVariantManifest?.createdAt ?? now,
1520
+ sessionId: args.sessionId,
1521
+ variantId: args.variantId,
1522
+ variantName: args.variantName,
1523
+ variantFolderName: args.variantFolderName,
1524
+ changedFilesCount: args.changedFilesCount,
1525
+ selectedVariantId: args.variantId,
1526
+ });
1527
+ }
1528
+ getFreshVariantFolderName(args) {
1529
+ const baseSlug = (0, createProjectArtifacts_1.createProjectVariantSlug)(args.variantName);
1530
+ const variants = this.store.getVariants(args.sessionId);
1531
+ const matchingVariants = variants.filter((variant) => {
1532
+ const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1533
+ return (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel ?? '') === baseSlug;
1534
+ });
1535
+ const index = matchingVariants.findIndex((variant) => variant.workItemId === args.variantId);
1536
+ if (index <= 0) {
1537
+ return baseSlug;
1538
+ }
1539
+ return `${baseSlug}-${index + 1}`;
1540
+ }
1541
+ readManifest(manifestPath) {
1542
+ if (!fs_1.default.existsSync(manifestPath)) {
1543
+ return undefined;
1544
+ }
1545
+ try {
1546
+ const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
1547
+ return JSON.parse(raw);
1548
+ }
1549
+ catch {
1550
+ return undefined;
1551
+ }
1552
+ }
1553
+ /**
1554
+ * Resolve dev server invocation for a worktree. Fresh-project worktrees
1555
+ * always use the Vite template's npm command at the worktree root; existing
1556
+ * projects defer to the user's framework/packageManager config.
1557
+ */
1558
+ async resolveDevServer(sessionId, worktreePath, port, isFresh) {
1559
+ if (isFresh) {
1560
+ return {
1561
+ cwd: worktreePath,
1562
+ cmd: 'npm',
1563
+ args: [
1564
+ 'run',
1565
+ 'dev',
1566
+ '--',
1567
+ '--port',
1568
+ String(port),
1569
+ '--host',
1570
+ FRESH_DEV_SERVER_HOST,
1571
+ ],
1572
+ env: { PORT: String(port) },
1573
+ };
1574
+ }
1575
+ const env = await this.resolveEnv(sessionId);
1576
+ const cwd = await this.worktrees.getProjectCwdInWorktree(worktreePath);
1577
+ if (env.buildDevCommand) {
1578
+ return { cwd, ...env.buildDevCommand(port) };
1579
+ }
1580
+ return {
1581
+ cwd,
1582
+ cmd: env.packageManager,
1583
+ args: [env.devCommand, '--port', String(port)],
1584
+ env: { PORT: String(port) },
1585
+ };
1586
+ }
1587
+ /**
1588
+ * After the chosen variant has been renamed into `destinationPath`, move
1589
+ * each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
1590
+ * folder and repoint its `node_modules` symlink at the chosen project's
1591
+ * real install. Writes a manifest documenting all 5 variants (the chosen
1592
+ * one with `folder: null` and `chosenPath: '../<slug>'`).
1593
+ *
1594
+ * Sets `vitePreservedSiblings = true` on session resources so the
1595
+ * subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
1596
+ * delete the just-preserved history). Dev servers still get stopped.
1597
+ */
1598
+ preserveUnchosenVariants(args) {
1599
+ const resources = this.resources.get(args.sessionId);
1600
+ if (!resources)
1601
+ return;
1602
+ const destinationParent = path_1.default.dirname(args.destinationPath);
1603
+ const projectSlug = path_1.default.basename(args.destinationPath);
1604
+ const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
1605
+ const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
1606
+ const briefs = this.store.getBriefs(args.sessionId);
1607
+ const variants = this.store.getVariants(args.sessionId);
1608
+ const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
1609
+ const manifestEntries = [];
1610
+ let chosenSlug = '';
1611
+ for (const variant of variants) {
1612
+ const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1613
+ const label = input.briefLabel ?? variant.label ?? 'variant';
1614
+ const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
1615
+ const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
1616
+ const numericPrefix = String(briefIdx + 1).padStart(2, '0');
1617
+ const folderName = `${numericPrefix}-${slug}`;
1618
+ if (variant.workItemId === args.chosenVariantId) {
1619
+ chosenSlug = slug;
1620
+ manifestEntries.push({
1621
+ variantId: variant.workItemId,
1622
+ label,
1623
+ slug,
1624
+ folder: null,
1625
+ isChosen: true,
1626
+ chosenPath: `../${projectSlug}`,
1627
+ });
1628
+ continue;
1629
+ }
1630
+ const record = resources.worktrees.get(variant.workItemId);
1631
+ if (!record) {
1632
+ // No worktree (e.g. failed variant) — still list it in manifest with
1633
+ // null folder so the user can see why it's absent.
1634
+ manifestEntries.push({
1635
+ variantId: variant.workItemId,
1636
+ label,
1637
+ slug,
1638
+ folder: null,
1639
+ isChosen: false,
1640
+ });
1641
+ continue;
1642
+ }
1643
+ if (!fs_1.default.existsSync(record.worktreePath)) {
1644
+ manifestEntries.push({
1645
+ variantId: variant.workItemId,
1646
+ label,
1647
+ slug,
1648
+ folder: null,
1649
+ isChosen: false,
1650
+ });
1651
+ continue;
1652
+ }
1653
+ try {
1654
+ const newPath = this.worktrees.moveWorktreeToHistory
1655
+ ? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
1656
+ : (() => {
1657
+ fs_1.default.mkdirSync(historyDir, { recursive: true });
1658
+ const dest = path_1.default.join(historyDir, folderName);
1659
+ fs_1.default.renameSync(record.worktreePath, dest);
1660
+ return dest;
1661
+ })();
1662
+ // Repoint node_modules at the chosen project's real install.
1663
+ try {
1664
+ if (this.worktrees.repointNodeModulesSymlink) {
1665
+ this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
1666
+ }
1667
+ else {
1668
+ const link = path_1.default.join(newPath, 'node_modules');
1669
+ try {
1670
+ fs_1.default.rmSync(link, { force: true, recursive: true });
1671
+ }
1672
+ catch {
1673
+ /* may not exist */
1674
+ }
1675
+ const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
1676
+ fs_1.default.symlinkSync(target, link, 'dir');
1677
+ }
1678
+ }
1679
+ catch (err) {
1680
+ log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
1681
+ }
1682
+ // Update the in-memory record so teardown doesn't try to operate on
1683
+ // the stale path.
1684
+ record.worktreePath = newPath;
1685
+ manifestEntries.push({
1686
+ variantId: variant.workItemId,
1687
+ label,
1688
+ slug,
1689
+ folder: folderName,
1690
+ isChosen: false,
1691
+ });
1692
+ }
1693
+ catch (err) {
1694
+ log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
1695
+ manifestEntries.push({
1696
+ variantId: variant.workItemId,
1697
+ label,
1698
+ slug,
1699
+ folder: null,
1700
+ isChosen: false,
1701
+ });
1702
+ }
1703
+ }
1704
+ const manifest = {
1705
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1706
+ createdAt: new Date().toISOString(),
1707
+ sessionId: args.sessionId,
1708
+ chosenVariantId: args.chosenVariantId,
1709
+ chosenSlug: chosenSlug || projectSlug,
1710
+ variants: manifestEntries,
1711
+ };
1712
+ try {
1713
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
1714
+ }
1715
+ catch (err) {
1716
+ log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
1717
+ }
1718
+ resources.vitePreservedSiblings = true;
1719
+ }
1720
+ /**
1721
+ * Rename `sourceWorktreePath` into `destinationPath`, then replace the
1722
+ * worktree's `.git` with a fresh-history init + initial commit. Matches
1723
+ * the historical materializeProject behavior of "user sees a fresh repo
1724
+ * at destinationPath" so we don't surprise them with a `rivet: variant
1725
+ * baseline` commit they didn't intend.
1726
+ *
1727
+ * The rename is the load-bearing operation: it moves the entire variant
1728
+ * tree (template files + materialized node_modules) in O(1) when the
1729
+ * source and destination share a volume. The git reset that follows is
1730
+ * best-effort housekeeping; a failure there leaves files in place.
1731
+ */
1732
+ async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
1733
+ // destinationPath must not exist for fs.rename to behave as a move.
1734
+ // assertDestinationAvailable already enforced that any existing directory
1735
+ // contains only `.rivet`, which we preserve across the move.
1736
+ const destinationParent = path_1.default.dirname(destinationPath);
1737
+ await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
1738
+ let preservedRivetParent;
1739
+ const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
1740
+ if (fs_1.default.existsSync(destinationPath)) {
1741
+ preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
1742
+ if (fs_1.default.existsSync(existingRivetPath)) {
1743
+ await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
1744
+ }
1745
+ await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
1746
+ }
1747
+ try {
1748
+ await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
1749
+ }
1750
+ catch (err) {
1751
+ if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
1752
+ await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
1753
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1754
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1755
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1756
+ }
1757
+ }
1758
+ throw err;
1759
+ }
1760
+ if (preservedRivetParent) {
1761
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1762
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1763
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1764
+ }
1765
+ await fs_1.default.promises.rm(preservedRivetParent, {
1766
+ recursive: true,
1767
+ force: true,
1768
+ });
1769
+ }
1770
+ // Replace the variant-baseline git history with a fresh init so the
1771
+ // user doesn't inherit a `rivet: variant baseline` commit.
1772
+ const dotGit = path_1.default.join(destinationPath, '.git');
1773
+ try {
1774
+ fs_1.default.rmSync(dotGit, { recursive: true, force: true });
1775
+ }
1776
+ catch {
1777
+ // Best-effort — leaving the baseline .git is preferable to failing
1778
+ // the commit after we've moved the tree.
1779
+ }
1780
+ try {
1781
+ const git = (0, simple_git_1.simpleGit)(destinationPath);
1782
+ await git.raw(['init']);
1783
+ await git.raw(['add', '-A']);
1784
+ await git
1785
+ .raw([
1786
+ '-c',
1787
+ 'user.name=Rivet',
1788
+ '-c',
1789
+ 'user.email=hello@tryrivet.design',
1790
+ 'commit',
1791
+ '--no-gpg-sign',
1792
+ '-m',
1793
+ 'Initial commit (created with Rivet)',
1794
+ ])
1795
+ .catch(() => {
1796
+ // Best-effort: missing user identity or empty tree leaves the
1797
+ // commit step un-applied. Files are still in place.
1798
+ });
1799
+ }
1800
+ catch (err) {
1801
+ log.warn(`Initial git history setup at ${destinationPath} failed`, err);
1802
+ }
1803
+ }
1804
+ /**
1805
+ * Stop every variant dev server currently running for `resources`. Clears
1806
+ * the `devServerProcess` / `port` fields on each record so subsequent
1807
+ * teardown logic doesn't try to stop them a second time. Called from
1808
+ * `commitVariant` (vite_app rename path) BEFORE the worktree directory is
1809
+ * renamed — leaving Vite alive across the rename leaks empty `.vite/`
1810
+ * stub dirs at the old paths.
1811
+ */
1812
+ async stopAllDevServersForSession(resources) {
1813
+ const stops = [];
1814
+ for (const record of resources.worktrees.values()) {
1815
+ if (!record.devServerProcess)
1816
+ continue;
1817
+ const proc = record.devServerProcess;
1818
+ stops.push(this.worktrees
1819
+ .stopDevServer(proc)
1820
+ .catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
1821
+ record.devServerProcess = undefined;
1822
+ record.port = undefined;
1823
+ }
1824
+ await Promise.all(stops);
1825
+ }
1826
+ /**
1827
+ * Spawn a fresh dev server at the user-facing destinationPath and retarget
1828
+ * the proxy so the iframe stays live across commit. Stores the process on
1829
+ * `resources.committedDevServer` so teardown can hand it off to the
1830
+ * lingering registry instead of killing it. Best-effort: a failure here is
1831
+ * non-fatal — the commit still succeeds; the user just has to run
1832
+ * `npm run dev` themselves to bring the preview back.
1833
+ */
1834
+ async startCommittedDevServer(args) {
1835
+ try {
1836
+ const port = await this.worktrees.getFreePort();
1837
+ const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
1838
+ 'run',
1839
+ 'dev',
1840
+ '--',
1841
+ '--port',
1842
+ String(port),
1843
+ '--host',
1844
+ FRESH_DEV_SERVER_HOST,
1845
+ ], { PORT: String(port) });
1846
+ args.resources.committedDevServer = {
1847
+ proc,
1848
+ port,
1849
+ path: args.destinationPath,
1850
+ };
1851
+ try {
1852
+ this.switchPreviewPort?.(port);
1853
+ }
1854
+ catch (err) {
1855
+ log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
1856
+ }
1857
+ log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
1858
+ }
1859
+ catch (err) {
1860
+ log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
1861
+ }
1862
+ }
1863
+ /**
1864
+ * Stop any committed dev servers that were left alive past their session's
1865
+ * teardown. Called at the start of a new `propose` (so the next session's
1866
+ * preview doesn't compete with stale processes) and exposed for explicit
1867
+ * shutdown hooks. Best-effort: per-server failures are logged but don't
1868
+ * propagate.
1869
+ */
1870
+ async stopLingeringCommittedDevServers() {
1871
+ const entries = [...this.lingeringCommittedDevServers.entries()];
1872
+ this.lingeringCommittedDevServers.clear();
1873
+ await Promise.all(entries.map(async ([sessionId, entry]) => {
1874
+ try {
1875
+ await this.worktrees.stopDevServer(entry.proc);
1876
+ }
1877
+ catch (err) {
1878
+ log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
1879
+ }
1880
+ }));
1881
+ }
438
1882
  async teardownSession(sessionId, reason) {
439
1883
  const resources = this.resources.get(sessionId);
440
1884
  if (!resources)
@@ -444,17 +1888,46 @@ class AgentVariantsOrchestrator {
444
1888
  resources.cleanupStarted = true;
445
1889
  log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
446
1890
  // Stop dev servers in parallel; ignore individual failures.
1891
+ // NOTE: For the fresh vite_app rename path, commitVariant already stopped
1892
+ // these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
1893
+ // stub dirs at the old worktree paths). In that case devServerProcess is
1894
+ // already cleared and this loop is a no-op — exactly the desired behavior.
447
1895
  const stops = [...resources.worktrees.values()]
448
1896
  .filter((r) => r.devServerProcess)
449
1897
  .map((r) => this.worktrees
450
1898
  .stopDevServer(r.devServerProcess)
451
1899
  .catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
452
1900
  await Promise.all(stops);
453
- try {
454
- await this.worktrees.cleanupSession(sessionId);
1901
+ // The committed dev server (spawned at destinationPath after a fresh
1902
+ // vite_app commit) intentionally outlives this teardown — the iframe is
1903
+ // pointed at it via the proxy and the user just chose this project. Hand
1904
+ // it off to the lingering registry; it'll be stopped on the next
1905
+ // `propose` or explicit shutdown.
1906
+ if (resources.committedDevServer && reason === 'committed') {
1907
+ this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
455
1908
  }
456
- catch (err) {
457
- log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
1909
+ else if (resources.committedDevServer) {
1910
+ // Non-committed teardown (cancel/shutdown) with a committed dev server
1911
+ // shouldn't happen in current code paths, but stop it defensively so we
1912
+ // don't leak procs.
1913
+ try {
1914
+ await this.worktrees.stopDevServer(resources.committedDevServer.proc);
1915
+ }
1916
+ catch (err) {
1917
+ log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
1918
+ }
1919
+ }
1920
+ // When a vite_app commit already moved the unchosen worktrees into a
1921
+ // sibling history dir, calling cleanupSession would re-delete them. Skip
1922
+ // the worktree removal step in that case — the move already cleared the
1923
+ // .rivet-variants/ entries for the chosen + preserved variants.
1924
+ if (!resources.vitePreservedSiblings) {
1925
+ try {
1926
+ await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
1927
+ }
1928
+ catch (err) {
1929
+ log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
1930
+ }
458
1931
  }
459
1932
  this.resources.delete(sessionId);
460
1933
  }
@@ -464,10 +1937,13 @@ class AgentVariantsOrchestrator {
464
1937
  r = {
465
1938
  sessionId,
466
1939
  worktrees: new Map(),
1940
+ staticPreviews: new Map(),
467
1941
  cleanupStarted: false,
468
1942
  committedVariantIds: new Set(),
469
1943
  startedAt: Date.now(),
470
1944
  leasedAt: new Map(),
1945
+ qaResults: new Map(),
1946
+ vitePreservedSiblings: false,
471
1947
  };
472
1948
  this.resources.set(sessionId, r);
473
1949
  }
@@ -475,8 +1951,456 @@ class AgentVariantsOrchestrator {
475
1951
  }
476
1952
  }
477
1953
  exports.AgentVariantsOrchestrator = AgentVariantsOrchestrator;
1954
+ /** MCP bridges sometimes serialize the output field as a JSON string instead
1955
+ * of a parsed object. Normalize it before field-level inspection. */
1956
+ /** Rough byte-size of the agent's output. Used as a proxy for "how much
1957
+ * code did the agent write" when comparing HTML vs full-stack flows. */
1958
+ function estimateOutputBytes(output) {
1959
+ if (output === undefined || output === null)
1960
+ return 0;
1961
+ try {
1962
+ return typeof output === 'string'
1963
+ ? output.length
1964
+ : JSON.stringify(output).length;
1965
+ }
1966
+ catch {
1967
+ return 0;
1968
+ }
1969
+ }
1970
+ function normalizeOutput(output) {
1971
+ if (typeof output === 'string') {
1972
+ try {
1973
+ return JSON.parse(output);
1974
+ }
1975
+ catch {
1976
+ return output;
1977
+ }
1978
+ }
1979
+ return output;
1980
+ }
1981
+ function parseStaticPreviewOutput(output) {
1982
+ if (!output || typeof output !== 'object')
1983
+ return null;
1984
+ const html = output.html;
1985
+ if (typeof html !== 'string' || html.trim().length === 0)
1986
+ return null;
1987
+ const css = output.css;
1988
+ const js = output.js;
1989
+ return {
1990
+ html: buildStaticPreviewDocument({
1991
+ html,
1992
+ css: typeof css === 'string' ? css : undefined,
1993
+ js: typeof js === 'string' ? js : undefined,
1994
+ }),
1995
+ };
1996
+ }
1997
+ function buildStaticPreviewDocument(input) {
1998
+ if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
1999
+ return input.html;
2000
+ }
2001
+ const style = input.css ? `<style>\n${input.css}\n</style>` : '';
2002
+ const script = input.js ? `<script>\n${input.js}\n</script>` : '';
2003
+ return `<!doctype html>
2004
+ <html lang="en">
2005
+ <head>
2006
+ <meta charset="UTF-8" />
2007
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
2008
+ ${style}
2009
+ </head>
2010
+ <body>
2011
+ ${input.html}
2012
+ ${script}
2013
+ </body>
2014
+ </html>`;
2015
+ }
478
2016
  function countDiffFiles(diff) {
479
2017
  // Each file in a unified diff starts with "diff --git ".
480
2018
  return (diff.match(/^diff --git /gm) ?? []).length;
481
2019
  }
2020
+ /**
2021
+ * Count source files under a materialized fresh-project destination,
2022
+ * skipping install artifacts (`node_modules`, `.git`, build outputs) so the
2023
+ * commit telemetry / chip count reflects the agent-visible surface area
2024
+ * rather than the install footprint.
2025
+ */
2026
+ function countWorktreeFiles(destinationPath) {
2027
+ let count = 0;
2028
+ const walk = (dir) => {
2029
+ let entries;
2030
+ try {
2031
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
2032
+ }
2033
+ catch {
2034
+ return;
2035
+ }
2036
+ for (const entry of entries) {
2037
+ if (MATERIALIZE_EXCLUDE.has(entry.name))
2038
+ continue;
2039
+ const full = path_1.default.join(dir, entry.name);
2040
+ if (entry.isDirectory())
2041
+ walk(full);
2042
+ else if (entry.isFile())
2043
+ count += 1;
2044
+ }
2045
+ };
2046
+ walk(destinationPath);
2047
+ return count;
2048
+ }
2049
+ const enrichDesignSource = (design) => {
2050
+ if (!design || design.kind !== 'slug')
2051
+ return design;
2052
+ return {
2053
+ ...design,
2054
+ displayName: (0, designCatalog_1.getDesignSystemBySlug)(design.slug)?.name ?? design.slug,
2055
+ };
2056
+ };
2057
+ const toActiveProjectContext = (projectContext) => {
2058
+ if (projectContext.kind === 'existing') {
2059
+ return { kind: 'existing' };
2060
+ }
2061
+ return {
2062
+ kind: 'fresh',
2063
+ workspacePath: projectContext.workspacePath,
2064
+ framework: projectContext.framework,
2065
+ designContext: projectContext.designContext?.map((entry) => entry.kind === 'slug'
2066
+ ? { kind: 'slug', slug: entry.slug }
2067
+ : { kind: 'markdown', label: entry.label }),
2068
+ ...(projectContext.sourceContext
2069
+ ? {
2070
+ sourceContext: {
2071
+ sourceCount: projectContext.sourceContext.sourceUrls?.length ?? 0,
2072
+ isSourceGrounded: Boolean(projectContext.sourceContext.artifact),
2073
+ },
2074
+ }
2075
+ : {}),
2076
+ ...(projectContext.executionPlan
2077
+ ? {
2078
+ executionPlan: {
2079
+ mode: projectContext.executionPlan.mode,
2080
+ confidence: projectContext.executionPlan.confidence,
2081
+ reason: projectContext.executionPlan.reason,
2082
+ assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
2083
+ runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
2084
+ },
2085
+ }
2086
+ : {}),
2087
+ };
2088
+ };
2089
+ /**
2090
+ * Resolve the user-facing supporting artifacts for a session.
2091
+ *
2092
+ * For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
2093
+ * turned into a `design_context` artifact carrying the full DESIGN.md
2094
+ * markdown:
2095
+ * - `slug` entries resolve bundled catalog markdown via the design catalog.
2096
+ * - `markdown` entries (Agent Browser / inspiration extractor output) carry
2097
+ * their stored markdown verbatim.
2098
+ * Slots whose markdown can't be resolved are skipped so the UI never renders
2099
+ * a metadata-only DESIGN.md row.
2100
+ */
2101
+ const buildSessionArtifacts = (projectContext) => {
2102
+ if (projectContext.kind !== 'fresh')
2103
+ return [];
2104
+ const designContext = projectContext.designContext;
2105
+ if (!designContext || designContext.length === 0)
2106
+ return [];
2107
+ const artifactsByContent = new Map();
2108
+ designContext.forEach((entry, slot) => {
2109
+ if (!entry)
2110
+ return;
2111
+ if (entry.kind === 'slug') {
2112
+ const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
2113
+ if (!markdown)
2114
+ return;
2115
+ const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
2116
+ addDesignContextArtifact(artifactsByContent, {
2117
+ id: `design_context:${slot}:${entry.slug}`,
2118
+ kind: 'design_context',
2119
+ label: catalogEntry?.name ?? entry.slug,
2120
+ ...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
2121
+ status: 'ready',
2122
+ source: 'static',
2123
+ contentType: 'text/markdown',
2124
+ content: markdown,
2125
+ });
2126
+ return;
2127
+ }
2128
+ if (!entry.content)
2129
+ return;
2130
+ addDesignContextArtifact(artifactsByContent, {
2131
+ id: `design_context:${slot}:${entry.label}`,
2132
+ kind: 'design_context',
2133
+ label: entry.label,
2134
+ status: 'ready',
2135
+ source: 'agent_browser',
2136
+ contentType: 'text/markdown',
2137
+ content: entry.content,
2138
+ });
2139
+ });
2140
+ return [...artifactsByContent.values()].map((artifact) => {
2141
+ if (artifact.usedByVariantCount <= 1)
2142
+ return artifact;
2143
+ return {
2144
+ ...artifact,
2145
+ summary: artifact.summary
2146
+ ? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
2147
+ : `Used by ${artifact.usedByVariantCount} variants.`,
2148
+ };
2149
+ });
2150
+ };
2151
+ const addDesignContextArtifact = (artifactsByContent, artifact) => {
2152
+ if (!artifact.content)
2153
+ return;
2154
+ const existing = artifactsByContent.get(artifact.content);
2155
+ if (existing) {
2156
+ existing.usedByVariantCount += 1;
2157
+ return;
2158
+ }
2159
+ artifactsByContent.set(artifact.content, {
2160
+ ...artifact,
2161
+ usedByVariantCount: 1,
2162
+ });
2163
+ };
2164
+ const summarizeDesignContext = (designContext) => {
2165
+ if (!designContext)
2166
+ return null;
2167
+ return designContext.reduce((entries, entry, slot) => {
2168
+ if (!entry)
2169
+ return entries;
2170
+ if (entry.kind === 'slug') {
2171
+ entries.push({ slot, kind: 'slug', slug: entry.slug });
2172
+ return entries;
2173
+ }
2174
+ entries.push({
2175
+ slot,
2176
+ kind: 'markdown',
2177
+ markdownLabel: entry.label,
2178
+ });
2179
+ return entries;
2180
+ }, []);
2181
+ };
2182
+ const trackScaffoldStarted = (telemetry, data) => {
2183
+ if (telemetry.trackAgentVariantsScaffoldStarted) {
2184
+ telemetry.trackAgentVariantsScaffoldStarted(data);
2185
+ return;
2186
+ }
2187
+ telemetry.track('agent_variants.scaffold_started', {
2188
+ source: 'mcp',
2189
+ session_id: data.sessionId,
2190
+ variant_count: data.variantCount,
2191
+ design_context: data.designContext ?? null,
2192
+ });
2193
+ };
2194
+ const trackScaffoldCompleted = (telemetry, data) => {
2195
+ if (telemetry.trackAgentVariantsScaffoldCompleted) {
2196
+ telemetry.trackAgentVariantsScaffoldCompleted(data);
2197
+ return;
2198
+ }
2199
+ telemetry.track('agent_variants.scaffold_completed', {
2200
+ source: 'mcp',
2201
+ session_id: data.sessionId,
2202
+ duration_ms: data.durationMs,
2203
+ install_duration_ms: data.installDurationMs,
2204
+ variant_count: data.variantCount,
2205
+ });
2206
+ };
2207
+ const trackScaffoldFailed = (telemetry, data) => {
2208
+ if (telemetry.trackAgentVariantsScaffoldFailed) {
2209
+ telemetry.trackAgentVariantsScaffoldFailed(data);
2210
+ return;
2211
+ }
2212
+ telemetry.track('agent_variants.scaffold_failed', {
2213
+ source: 'mcp',
2214
+ session_id: data.sessionId,
2215
+ error_code: data.errorCode,
2216
+ duration_ms: data.durationMs,
2217
+ });
2218
+ };
2219
+ const trackFreshDevServerStarted = (telemetry, data) => {
2220
+ if (telemetry.trackAgentVariantsFreshDevServerStarted) {
2221
+ telemetry.trackAgentVariantsFreshDevServerStarted(data);
2222
+ return;
2223
+ }
2224
+ telemetry.track('agent_variants.fresh_dev_server_started', {
2225
+ source: 'mcp',
2226
+ session_id: data.sessionId,
2227
+ variant_id: data.variantId,
2228
+ port: data.port,
2229
+ });
2230
+ };
2231
+ const trackFreshDevServerFailed = (telemetry, data) => {
2232
+ if (telemetry.trackAgentVariantsFreshDevServerFailed) {
2233
+ telemetry.trackAgentVariantsFreshDevServerFailed(data);
2234
+ return;
2235
+ }
2236
+ telemetry.track('agent_variants.fresh_dev_server_failed', {
2237
+ source: 'mcp',
2238
+ session_id: data.sessionId,
2239
+ variant_id: data.variantId,
2240
+ error_code: data.errorCode,
2241
+ });
2242
+ };
2243
+ const trackStaticPreviewCompleted = (telemetry, data) => {
2244
+ if (telemetry.trackAgentVariantsStaticPreviewCompleted) {
2245
+ telemetry.trackAgentVariantsStaticPreviewCompleted(data);
2246
+ return;
2247
+ }
2248
+ telemetry.track('agent_variants.static_preview_completed', {
2249
+ source: 'mcp',
2250
+ session_id: data.sessionId,
2251
+ variant_id: data.variantId,
2252
+ duration_ms: data.durationMs,
2253
+ });
2254
+ };
2255
+ const MATERIALIZE_EXCLUDE = new Set([
2256
+ 'node_modules',
2257
+ '.git',
2258
+ 'dist',
2259
+ '.next',
2260
+ '.cache',
2261
+ '.vite',
2262
+ ]);
2263
+ const defaultInstallDependencies = (worktreePath) => {
2264
+ return new Promise((resolve, reject) => {
2265
+ const proc = (0, child_process_1.spawn)('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], {
2266
+ cwd: worktreePath,
2267
+ stdio: ['ignore', 'ignore', 'pipe'],
2268
+ });
2269
+ let stderr = '';
2270
+ proc.stderr.on('data', (chunk) => {
2271
+ stderr += chunk.toString();
2272
+ });
2273
+ proc.on('error', reject);
2274
+ proc.on('exit', (code) => {
2275
+ if (code === 0) {
2276
+ resolve();
2277
+ return;
2278
+ }
2279
+ reject(new Error(`npm install in ${worktreePath} failed (code ${code}): ${stderr.slice(-512)}`));
2280
+ });
2281
+ });
2282
+ };
2283
+ /**
2284
+ * True when `a` and `b` (or the closest existing ancestor of each) live on
2285
+ * the same filesystem volume. Used by commitVariant to decide between an
2286
+ * O(1) rename and a recursive copy-with-reinstall fallback.
2287
+ *
2288
+ * `destinationPath` for fresh sessions does not exist yet (we'd fail the
2289
+ * "destination must be empty" guard otherwise), so we compare against its
2290
+ * parent directory which is guaranteed to exist (createZeroToOneTool either
2291
+ * created it or validated it).
2292
+ */
2293
+ function areSameVolume(a, b) {
2294
+ try {
2295
+ const statA = fs_1.default.statSync(a);
2296
+ const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
2297
+ const statB = fs_1.default.statSync(parentB);
2298
+ return statA.dev === statB.dev;
2299
+ }
2300
+ catch {
2301
+ return false;
2302
+ }
2303
+ }
2304
+ /**
2305
+ * Materialize node_modules from symlink → real directory at the given path,
2306
+ * so a subsequent rename of that path leaves a self-contained tree.
2307
+ *
2308
+ * Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
2309
+ * runBackgroundInstall). When the user picks variant K and we rename it
2310
+ * into the destination, the sibling worktrees get torn down — which would
2311
+ * otherwise leave a dangling symlink in the destination if variant K was
2312
+ * the one with the symlink (K > 0).
2313
+ *
2314
+ * Returns true when the destination must run a dependency install after the
2315
+ * rename because node_modules is missing or points nowhere.
2316
+ */
2317
+ async function resolveNodeModulesSymlink(worktreePath) {
2318
+ const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
2319
+ let lstat;
2320
+ try {
2321
+ lstat = await fs_1.default.promises.lstat(nodeModulesPath);
2322
+ }
2323
+ catch {
2324
+ return true;
2325
+ }
2326
+ if (!lstat.isSymbolicLink())
2327
+ return false;
2328
+ let target;
2329
+ try {
2330
+ target = await fs_1.default.promises.realpath(nodeModulesPath);
2331
+ }
2332
+ catch {
2333
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2334
+ return true;
2335
+ }
2336
+ // Stage the copy under a sibling temp name so a mid-copy failure (disk
2337
+ // full, EACCES, etc.) leaves the original symlink intact rather than
2338
+ // wiping it and leaving the worktree with no node_modules at all.
2339
+ // The final swap is then atomic on the same filesystem (rename + unlink).
2340
+ const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
2341
+ try {
2342
+ await fs_1.default.promises.cp(target, stagingPath, {
2343
+ recursive: true,
2344
+ dereference: false,
2345
+ });
2346
+ }
2347
+ catch (err) {
2348
+ // Best-effort cleanup of the partial staging dir; leave the symlink
2349
+ // intact so the caller can re-run materialization.
2350
+ await fs_1.default.promises
2351
+ .rm(stagingPath, { recursive: true, force: true })
2352
+ .catch(() => { });
2353
+ throw err;
2354
+ }
2355
+ // Swap: remove the symlink, then rename the staging dir into place.
2356
+ // Both operations are local fs ops on the same parent — fast and
2357
+ // unlikely to fail independently, but if the rename fails the symlink
2358
+ // is already gone, so prefer to leave the staging dir on disk over a
2359
+ // half-broken worktree (the caller will surface the throw).
2360
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2361
+ await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
2362
+ return false;
2363
+ }
2364
+ const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
2365
+ await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
2366
+ await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {
2367
+ recursive: true,
2368
+ filter: (sourcePath) => {
2369
+ const rel = path_1.default.relative(sourceWorktreePath, sourcePath);
2370
+ if (!rel)
2371
+ return true;
2372
+ const top = rel.split(path_1.default.sep)[0];
2373
+ return !MATERIALIZE_EXCLUDE.has(top);
2374
+ },
2375
+ });
2376
+ const git = (0, simple_git_1.simpleGit)(destinationPath);
2377
+ await git.raw(['init']);
2378
+ await git.raw(['add', '-A']);
2379
+ await git
2380
+ .raw([
2381
+ '-c',
2382
+ 'user.name=Rivet',
2383
+ '-c',
2384
+ 'user.email=hello@tryrivet.design',
2385
+ 'commit',
2386
+ '--no-gpg-sign',
2387
+ '-m',
2388
+ 'Initial commit (created with Rivet)',
2389
+ ])
2390
+ .catch(() => {
2391
+ // Best-effort: a missing user identity or empty tree leaves the
2392
+ // commit step un-applied. The destination still has the files.
2393
+ });
2394
+ };
2395
+ /**
2396
+ * Synthesize a placeholder brief body for a unified start_variants slot.
2397
+ * The body must fit `briefSchema.body` (≤ 200 chars, ≥ 1 char). The agent's
2398
+ * streamed first-line label replaces this in the UI once code_gen output
2399
+ * arrives. Index/total are unused for now but kept in the signature so
2400
+ * future diversity hints can be added without a call-site change.
2401
+ */
2402
+ function synthesizeUnifiedBriefBody(prompt, _index, _total) {
2403
+ const trimmed = prompt.trim().replace(/\s+/g, ' ').slice(0, 200);
2404
+ return trimmed.length > 0 ? trimmed : 'variant';
2405
+ }
482
2406
  //# sourceMappingURL=WorktreeOrchestrator.js.map