rivet-design 0.9.3 → 0.9.5

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 (105) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +26 -10
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +189 -44
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +209 -13
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1489 -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 +1576 -64
  14. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  15. package/dist/mcp/agent-variants/contracts.js +202 -9
  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 +67 -25
  22. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
  23. package/dist/mcp/agent-variants/createZeroToOneTool.js +60 -126
  24. package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -1
  25. package/dist/mcp/agent-variants/errors.d.ts +1 -1
  26. package/dist/mcp/agent-variants/errors.d.ts.map +1 -1
  27. package/dist/mcp/agent-variants/errors.js +1 -0
  28. package/dist/mcp/agent-variants/errors.js.map +1 -1
  29. package/dist/mcp/agent-variants/index.d.ts +3 -4
  30. package/dist/mcp/agent-variants/index.d.ts.map +1 -1
  31. package/dist/mcp/agent-variants/index.js +2 -2
  32. package/dist/mcp/agent-variants/index.js.map +1 -1
  33. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +10 -0
  34. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -1
  35. package/dist/mcp/agent-variants/inspirationDesignContext.js +104 -16
  36. package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -1
  37. package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
  38. package/dist/mcp/agent-variants/pendingChangesAdapter.js +11 -0
  39. package/dist/mcp/agent-variants/pendingChangesAdapter.js.map +1 -1
  40. package/dist/mcp/agent-variants/previewQa.d.ts +61 -0
  41. package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -0
  42. package/dist/mcp/agent-variants/previewQa.js +374 -0
  43. package/dist/mcp/agent-variants/previewQa.js.map +1 -0
  44. package/dist/mcp/agent-variants/sourceContext.d.ts +1 -0
  45. package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -1
  46. package/dist/mcp/agent-variants/sourceContext.js +28 -3
  47. package/dist/mcp/agent-variants/sourceContext.js.map +1 -1
  48. package/dist/mcp/agent-variants/tools.d.ts +40 -3
  49. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  50. package/dist/mcp/agent-variants/tools.js +262 -38
  51. package/dist/mcp/agent-variants/tools.js.map +1 -1
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +32 -7
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/prompts/agentModPrompts.d.ts.map +1 -1
  56. package/dist/prompts/agentModPrompts.js +11 -10
  57. package/dist/prompts/agentModPrompts.js.map +1 -1
  58. package/dist/proxy-middleware/proxy-config.d.ts +2 -2
  59. package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
  60. package/dist/proxy-middleware/proxy-config.js +66 -22
  61. package/dist/proxy-middleware/proxy-config.js.map +1 -1
  62. package/dist/routes/agentVariants.d.ts +2 -13
  63. package/dist/routes/agentVariants.d.ts.map +1 -1
  64. package/dist/routes/agentVariants.js +156 -2
  65. package/dist/routes/agentVariants.js.map +1 -1
  66. package/dist/server.d.ts.map +1 -1
  67. package/dist/server.js +79 -4
  68. package/dist/server.js.map +1 -1
  69. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  70. package/dist/services/ProjectDetectionService.js +21 -0
  71. package/dist/services/ProjectDetectionService.js.map +1 -1
  72. package/dist/services/VariantHistoryService.d.ts +117 -0
  73. package/dist/services/VariantHistoryService.d.ts.map +1 -0
  74. package/dist/services/VariantHistoryService.js +385 -0
  75. package/dist/services/VariantHistoryService.js.map +1 -0
  76. package/dist/services/WorktreeManager.d.ts +67 -9
  77. package/dist/services/WorktreeManager.d.ts.map +1 -1
  78. package/dist/services/WorktreeManager.js +209 -26
  79. package/dist/services/WorktreeManager.js.map +1 -1
  80. package/dist/services/agent/AgentCore.d.ts +1 -1
  81. package/dist/services/agent/AgentCore.d.ts.map +1 -1
  82. package/dist/services/agent/AgentCore.js +24 -1
  83. package/dist/services/agent/AgentCore.js.map +1 -1
  84. package/dist/services/agent/AgentModService.d.ts +1 -1
  85. package/dist/services/agent/AgentModService.js +7 -7
  86. package/dist/services/agent/AgentModService.js.map +1 -1
  87. package/dist/services/templates/viteReactTs.d.ts +6 -0
  88. package/dist/services/templates/viteReactTs.d.ts.map +1 -1
  89. package/dist/services/templates/viteReactTs.js +8 -1
  90. package/dist/services/templates/viteReactTs.js.map +1 -1
  91. package/dist/types/change-request-types.d.ts +14 -0
  92. package/dist/types/change-request-types.d.ts.map +1 -1
  93. package/dist/utils/skills/claude-skill.d.ts +1 -1
  94. package/dist/utils/skills/claude-skill.js +2 -2
  95. package/dist/utils/skills/cursor-rules.d.ts +1 -1
  96. package/dist/utils/skills/cursor-rules.js +1 -1
  97. package/dist/utils/skills/shared-variants-protocol.d.ts +1 -1
  98. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
  99. package/dist/utils/skills/shared-variants-protocol.js +31 -32
  100. package/dist/utils/skills/shared-variants-protocol.js.map +1 -1
  101. package/package.json +4 -5
  102. package/src/ui/dist/assets/main-OdmwI8Od.css +1 -0
  103. package/src/ui/dist/assets/{main-DejhsBWR.js → main-SuZlKEi0.js} +115 -115
  104. package/src/ui/dist/index.html +2 -2
  105. package/src/ui/dist/assets/main-C9jfEp80.css +0 -1
@@ -4,18 +4,191 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AgentVariantsOrchestrator = void 0;
7
+ exports.buildStaticPreviewDocument = buildStaticPreviewDocument;
8
+ const crypto_1 = require("crypto");
7
9
  const events_1 = require("events");
8
10
  const fs_1 = __importDefault(require("fs"));
11
+ const os_1 = __importDefault(require("os"));
9
12
  const path_1 = __importDefault(require("path"));
10
13
  const child_process_1 = require("child_process");
11
14
  const simple_git_1 = require("simple-git");
12
15
  const logger_1 = require("../../utils/logger");
13
16
  const errors_1 = require("./errors");
17
+ const createProjectArtifacts_1 = require("./createProjectArtifacts");
14
18
  const contracts_1 = require("./contracts");
15
19
  const viteReactTs_1 = require("../../services/templates/viteReactTs");
16
20
  const designCatalog_1 = require("../../services/templates/designCatalog");
21
+ const previewQa_1 = require("./previewQa");
22
+ const VariantHistoryService_1 = require("../../services/VariantHistoryService");
17
23
  const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
18
24
  const FRESH_DEV_SERVER_HOST = '127.0.0.1';
25
+ /**
26
+ * Allowlist of asset file extensions an agent-planned source may have.
27
+ * `assetPlan` is sized for large local *assets* (3D models, images,
28
+ * audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
29
+ * configs. The check is defense-in-depth alongside the path / symlink
30
+ * filters in `copyAssetIntoWorktree`; without it, a prompt-injected
31
+ * source plan could copy readable host files such as SSH keys or
32
+ * project secrets into the generated app.
33
+ *
34
+ * Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
35
+ * common carriers for credentials (`credentials.json`, `.aws/config`,
36
+ * shell history `.txt`, README-shaped secrets) and have no legitimate
37
+ * use as binary preview assets. If a future flow needs structured data
38
+ * it should ship it through the manifest instead of file copy.
39
+ */
40
+ const ALLOWED_ASSET_EXTENSIONS = new Set([
41
+ // 3D / models
42
+ '.glb', '.gltf', '.obj', '.fbx', '.usdz',
43
+ // images
44
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
45
+ // video
46
+ '.mp4', '.webm', '.mov',
47
+ // audio
48
+ '.mp3', '.wav', '.ogg', '.m4a',
49
+ // fonts
50
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
51
+ // PDFs
52
+ '.pdf',
53
+ ]);
54
+ /**
55
+ * Reject paths that traverse through a "sensitive" directory anywhere
56
+ * in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
57
+ * a copy passes the extension allowlist, an agent-controlled source
58
+ * resolving inside `~/.ssh/` is almost certainly an exfiltration
59
+ * attempt rather than a legitimate asset reference.
60
+ */
61
+ const SENSITIVE_PATH_SEGMENTS = new Set([
62
+ '.ssh',
63
+ '.aws',
64
+ '.gnupg',
65
+ '.kube',
66
+ '.docker',
67
+ '.config',
68
+ '.npm',
69
+ '.cargo',
70
+ '.gem',
71
+ '.netrc',
72
+ 'private',
73
+ 'secrets',
74
+ 'credentials',
75
+ ]);
76
+ function hasSensitivePathSegment(absolutePath) {
77
+ // Split on any path separator; check each segment case-insensitively.
78
+ const segments = absolutePath.split(/[/\\]/).filter(Boolean);
79
+ return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
80
+ }
81
+ /**
82
+ * Returns true when `absolutePath` is the root itself or one of its
83
+ * descendants after both paths have been resolved.
84
+ */
85
+ function isPathWithinRoot(absolutePath, root) {
86
+ const relative = path_1.default.relative(root, absolutePath);
87
+ return (relative === '' ||
88
+ (relative.length > 0 &&
89
+ !relative.startsWith('..') &&
90
+ !path_1.default.isAbsolute(relative)));
91
+ }
92
+ /**
93
+ * Copy one agent-planned asset file into a variant worktree at the
94
+ * project-relative `destination`. The agent decides routing
95
+ * (`destination` like `public/assets/avatar.glb`) so all variants share
96
+ * a consistent layout. Fails loudly when the source is missing or the
97
+ * destination tries to escape the worktree — silent fallbacks here
98
+ * would re-introduce the broken-asset failure mode this routing is
99
+ * supposed to prevent.
100
+ *
101
+ * Security: `entry.source` comes from `report_source_plan` and is
102
+ * agent-controlled. To prevent a prompt-injected source plan from
103
+ * exfiltrating arbitrary readable host files (SSH keys, dotfiles,
104
+ * cloud credentials, project secrets) into the generated app, the
105
+ * source must (1) be an absolute path that resolves via realpath
106
+ * (no traversal, no symlinks pointing outside the resolved location),
107
+ * (2) be a regular file (not a device, socket, fifo, or directory),
108
+ * (3) live under the approved asset root, (4) have an extension on the
109
+ * asset allowlist.
110
+ */
111
+ function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
112
+ if (!assetSourceRoot) {
113
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
114
+ }
115
+ if (!path_1.default.isAbsolute(assetSourceRoot)) {
116
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
117
+ }
118
+ if (!path_1.default.isAbsolute(entry.source)) {
119
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
120
+ }
121
+ const ext = path_1.default.extname(entry.source).toLowerCase();
122
+ if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
123
+ 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.`);
124
+ }
125
+ // lstat first: a symlink whose target lies outside the source path
126
+ // could otherwise smuggle in arbitrary files. Reject symlinks outright;
127
+ // realpath would resolve them but the user-visible intent of an
128
+ // assetPlan entry is to name a concrete file.
129
+ let lstat;
130
+ try {
131
+ lstat = fs_1.default.lstatSync(entry.source);
132
+ }
133
+ catch {
134
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
135
+ }
136
+ if (lstat.isSymbolicLink()) {
137
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
138
+ }
139
+ if (!lstat.isFile()) {
140
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
141
+ }
142
+ // Resolve realpath as additional defense: even with no symlinks at the
143
+ // leaf, an intermediate directory could be a symlink. Cross-check the
144
+ // file kind on the resolved path.
145
+ let resolvedSource;
146
+ try {
147
+ resolvedSource = fs_1.default.realpathSync(entry.source);
148
+ }
149
+ catch {
150
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
151
+ }
152
+ let resolvedAssetSourceRoot;
153
+ try {
154
+ resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
155
+ }
156
+ catch {
157
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
158
+ }
159
+ const resolvedStat = fs_1.default.statSync(resolvedSource);
160
+ if (!resolvedStat.isFile()) {
161
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
162
+ }
163
+ // Symlinked parent directory defense: even though we rejected a symlink
164
+ // leaf and confirmed the resolved file is regular, an intermediate dir
165
+ // could have been a symlink that quietly forwards into a sensitive
166
+ // ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
167
+ // NEITHER the user-supplied path NOR its realpath traverses a known
168
+ // sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
169
+ // re-verify the extension on the resolved path so a `.glb` symlink
170
+ // chain cannot smuggle in a `.json` realpath.
171
+ if (hasSensitivePathSegment(entry.source) ||
172
+ hasSensitivePathSegment(resolvedSource)) {
173
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
174
+ }
175
+ if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
176
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
177
+ }
178
+ const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
179
+ if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
180
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
181
+ }
182
+ const normalizedDest = path_1.default.normalize(entry.destination);
183
+ if (normalizedDest.startsWith('..') ||
184
+ path_1.default.isAbsolute(normalizedDest)) {
185
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
186
+ }
187
+ const absDest = path_1.default.join(worktreePath, normalizedDest);
188
+ fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
189
+ fs_1.default.copyFileSync(resolvedSource, absDest);
190
+ }
191
+ const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
19
192
  const NOOP_TELEMETRY = { track: () => undefined };
20
193
  /**
21
194
  * Wraps SessionStore for the operations that have side effects: approve
@@ -37,7 +210,30 @@ class AgentVariantsOrchestrator {
37
210
  telemetry;
38
211
  installDependencies;
39
212
  materializeProject;
213
+ previewQaRunner;
214
+ switchPreviewPort;
215
+ variantHistory;
40
216
  resources = new Map();
217
+ /**
218
+ * Committed dev servers from prior sessions that survived teardown. The
219
+ * orchestrator outlives any single session — once a new session starts, the
220
+ * prior committed dev server is no longer needed (the next session opens
221
+ * its own preview chip), so stop it before the new session's first preview
222
+ * goes live. Keyed by sessionId for diagnostics; values are kept alive
223
+ * intentionally between commit and next `propose` / `shutdown`.
224
+ */
225
+ lingeringCommittedDevServers = new Map();
226
+ /**
227
+ * Per-session in-flight commit chain. Two overlapping commitVariant calls
228
+ * would otherwise both pass the `getVariantPick` idempotency check (the
229
+ * pick isn't recorded until after `applyDiffToProject`) and double-invoke
230
+ * `git apply` — which corrupts the working tree on the second run. The
231
+ * chain serializes commits per session so the second caller waits for the
232
+ * first to finish and then hits the idempotency check (returning
233
+ * `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
234
+ * for a different one).
235
+ */
236
+ inflightCommits = new Map();
41
237
  /** Most recent agent-variants sessionId — read by the iframe chip via
42
238
  * GET /api/variants/active. Cleared on cancel or commit. */
43
239
  activeSessionId = null;
@@ -54,9 +250,18 @@ class AgentVariantsOrchestrator {
54
250
  deps.installDependencies ?? defaultInstallDependencies;
55
251
  this.materializeProject =
56
252
  deps.materializeProject ?? defaultMaterializeProject;
253
+ this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
254
+ this.switchPreviewPort = deps.switchPreviewPort;
255
+ this.variantHistory = deps.variantHistory ?? new VariantHistoryService_1.VariantHistoryService();
57
256
  }
58
257
  // --- Pure delegations (no side effects) ---------------------------------
59
258
  propose(args) {
259
+ // A new session means any lingering committed dev server from a prior
260
+ // session is no longer needed — the next session opens its own preview.
261
+ // Best-effort stop; failures here don't block propose.
262
+ void this.stopLingeringCommittedDevServers().catch((err) => {
263
+ log.warn('Stopping lingering committed dev servers failed', err);
264
+ });
60
265
  const result = this.store.propose(args);
61
266
  this.activeSessionId = result.sessionId;
62
267
  this.ensureResources(result.sessionId);
@@ -99,8 +304,10 @@ class AgentVariantsOrchestrator {
99
304
  ? this.store.getSummary(sessionId)
100
305
  : null;
101
306
  const variants = this.getVariants(sessionId);
102
- const projectContext = toActiveProjectContext(this.store.getProjectContext(sessionId));
307
+ const sessionProjectContext = this.store.getProjectContext(sessionId);
308
+ const projectContext = toActiveProjectContext(sessionProjectContext);
103
309
  const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
310
+ const artifacts = buildSessionArtifacts(sessionProjectContext);
104
311
  return {
105
312
  active: true,
106
313
  sessionId,
@@ -111,6 +318,7 @@ class AgentVariantsOrchestrator {
111
318
  variants,
112
319
  projectContext,
113
320
  ...(destinationPath ? { destinationPath } : {}),
321
+ ...(artifacts.length > 0 ? { artifacts } : {}),
114
322
  };
115
323
  }
116
324
  emitChange() {
@@ -134,14 +342,14 @@ class AgentVariantsOrchestrator {
134
342
  this.emitChange();
135
343
  return result;
136
344
  }
137
- reportSourceContext(args) {
138
- const result = this.store.reportSourceContext(args);
345
+ reportSourcePlan(args) {
346
+ const result = this.store.reportSourcePlan(args);
139
347
  this.telemetry.trackAgentVariantsSourceContextQuality?.({
140
348
  sessionId: args.sessionId,
141
- sourceUrlCount: args.sourceContext.sourceRoles.length,
142
- artifactCount: args.sourceContext.sourceFindings.length,
143
- hasScreenshotReferences: (args.sourceContext.screenshotReferences?.length ?? 0) > 0,
144
- preserveBrand: args.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
349
+ sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
350
+ artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
351
+ hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
352
+ preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
145
353
  });
146
354
  this.emitChange();
147
355
  return result;
@@ -226,14 +434,20 @@ class AgentVariantsOrchestrator {
226
434
  if (!preview && port) {
227
435
  preview = { kind: 'dev_server', port };
228
436
  }
437
+ const qa = resources?.qaResults.get(variant.workItemId);
229
438
  const isSucceeded = variant.status === 'succeeded';
439
+ const qaFailed = qa?.status === 'failed';
230
440
  const canView = Boolean(preview) || (isSucceeded && Boolean(port));
231
- const canCommit = isSucceeded;
441
+ const canCommit = isSucceeded && !qaFailed;
442
+ const commitDisabledReason = qaFailed
443
+ ? qa?.summary ?? 'Variant failed QA'
444
+ : 'Wait for a successful variant';
232
445
  return {
233
446
  ...variant,
234
447
  design: enrichDesignSource(variant.design),
235
448
  ...(preview ? { preview } : {}),
236
449
  port,
450
+ ...(qa ? { qa } : {}),
237
451
  actions: {
238
452
  view: canView
239
453
  ? { enabled: true }
@@ -247,7 +461,7 @@ class AgentVariantsOrchestrator {
247
461
  ? { enabled: true }
248
462
  : {
249
463
  enabled: false,
250
- reason: 'Wait for a successful variant',
464
+ reason: commitDisabledReason,
251
465
  },
252
466
  },
253
467
  };
@@ -344,26 +558,40 @@ class AgentVariantsOrchestrator {
344
558
  * work items via continue_variants(action="request_work").
345
559
  */
346
560
  async startUnified(args) {
347
- const count = args.count ?? 4;
561
+ const count = args.briefs?.length ?? args.count ?? 4;
348
562
  const projectContext = args.projectContext ?? { kind: 'existing' };
563
+ const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
564
+ const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
565
+ Boolean(sourceContext?.sourceArtifacts?.length) ||
566
+ Boolean(sourceContext?.sourceIntent) ||
567
+ Boolean(sourceContext?.artifact);
568
+ if (isSourceGrounded) {
569
+ 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.');
570
+ }
349
571
  const proposeResult = this.propose({
350
572
  prompt: args.prompt,
351
573
  count,
352
574
  target: args.target,
353
575
  projectContext,
354
576
  });
355
- if (proposeResult.stage === 'awaiting_source_research') {
356
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-research sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
357
- }
358
577
  if (proposeResult.stage !== 'awaiting_briefs' ||
359
578
  !proposeResult.briefWorkItem) {
360
579
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
361
580
  }
362
- const briefs = Array.from({ length: count }, (_, i) => ({
363
- briefId: `v${i + 1}`,
364
- label: `Variant ${i + 1}`,
365
- body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
366
- }));
581
+ const briefs = args.briefs
582
+ ? args.briefs.map((b, i) => ({
583
+ briefId: `v${i + 1}`,
584
+ label: b.label,
585
+ body: b.body,
586
+ ...(b.visualReferenceUrl
587
+ ? { visualReferenceUrl: b.visualReferenceUrl }
588
+ : {}),
589
+ }))
590
+ : Array.from({ length: count }, (_, i) => ({
591
+ briefId: `v${i + 1}`,
592
+ label: `Variant ${i + 1}`,
593
+ body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
594
+ }));
367
595
  this.store.reportBriefs({
368
596
  sessionId: proposeResult.sessionId,
369
597
  workItemId: proposeResult.briefWorkItem.id,
@@ -420,14 +648,21 @@ class AgentVariantsOrchestrator {
420
648
  };
421
649
  }
422
650
  async reportComplete(args) {
423
- const result = this.store.reportComplete(args);
651
+ // QA gate: for `succeeded` static_preview reports, run preview QA
652
+ // synchronously before recording success in the store. A failed QA
653
+ // verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
654
+ // so the variant never reaches `ready` and `getVariants` can disable
655
+ // its commit action.
656
+ const qaOverride = await this.evaluateQaForReport(args);
657
+ const effectiveArgs = qaOverride?.overrideArgs ?? args;
658
+ const result = this.store.reportComplete(effectiveArgs);
424
659
  this.emitChange();
425
660
  // Per-variant telemetry on terminal item statuses (skip 'running'
426
661
  // heartbeats and the brief work item — only code_gen / scaffold_base
427
662
  // matter for the funnel).
428
- if (args.status === 'succeeded' ||
429
- args.status === 'failed' ||
430
- args.status === 'cancelled') {
663
+ if (effectiveArgs.status === 'succeeded' ||
664
+ effectiveArgs.status === 'failed' ||
665
+ effectiveArgs.status === 'cancelled') {
431
666
  const resources = this.resources.get(args.sessionId);
432
667
  const leasedAt = resources?.leasedAt.get(args.workItemId);
433
668
  const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
@@ -440,12 +675,12 @@ class AgentVariantsOrchestrator {
440
675
  source: 'mcp',
441
676
  sessionId: args.sessionId,
442
677
  workItemId: args.workItemId,
443
- status: args.status,
678
+ status: effectiveArgs.status,
444
679
  attempt: args.attempt,
445
680
  durationMs,
446
681
  approveToCompleteMs,
447
- hasError: Boolean(args.error),
448
- errorCode: args.error?.code ?? null,
682
+ hasError: Boolean(effectiveArgs.error),
683
+ errorCode: effectiveArgs.error?.code ?? null,
449
684
  isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
450
685
  projectContextKind,
451
686
  tokensIn: args.tokensIn ?? null,
@@ -476,7 +711,7 @@ class AgentVariantsOrchestrator {
476
711
  });
477
712
  }
478
713
  }
479
- if (args.status === 'succeeded') {
714
+ if (effectiveArgs.status === 'succeeded') {
480
715
  void this.handleSucceededReport({
481
716
  sessionId: args.sessionId,
482
717
  workItemId: args.workItemId,
@@ -506,6 +741,9 @@ class AgentVariantsOrchestrator {
506
741
  fromStage: stageBefore,
507
742
  });
508
743
  this.emitChange();
744
+ void this.markPersistedVariantsCancelled(args.sessionId).catch((err) => {
745
+ log.warn(`markPersistedVariantsCancelled failed for ${args.sessionId}`, err);
746
+ });
509
747
  void this.teardownSession(args.sessionId, 'cancel').catch((err) => {
510
748
  log.error(`teardownSession failed for ${args.sessionId}`, err);
511
749
  });
@@ -533,6 +771,11 @@ class AgentVariantsOrchestrator {
533
771
  alreadyTerminal: result.alreadyTerminal,
534
772
  reason: args.reason ?? null,
535
773
  });
774
+ if (!result.alreadyTerminal) {
775
+ void this.markPersistedVariantCancelled(args.sessionId, args.variantId).catch((err) => {
776
+ log.warn(`markPersistedVariantCancelled failed for ${args.sessionId}/${args.variantId}`, err);
777
+ });
778
+ }
536
779
  this.emitChange();
537
780
  return {
538
781
  sessionId: args.sessionId,
@@ -549,12 +792,67 @@ class AgentVariantsOrchestrator {
549
792
  * the call returns duplicate=true without re-enqueueing.
550
793
  */
551
794
  async commitVariant(args) {
795
+ // Serialize per-session so two overlapping commits can't both run
796
+ // `applyDiffToProject` (or the fresh rename) before the first records
797
+ // its pick. The second caller waits for the first to settle, then runs
798
+ // commitVariantImpl which hits the idempotency check in
799
+ // `getVariantPick` / `recordVariantPick`.
800
+ const prior = this.inflightCommits.get(args.sessionId);
801
+ const run = (async () => {
802
+ if (prior) {
803
+ await prior.catch(() => undefined);
804
+ }
805
+ return this.commitVariantImpl(args);
806
+ })();
807
+ this.inflightCommits.set(args.sessionId, run);
808
+ try {
809
+ return await run;
810
+ }
811
+ finally {
812
+ if (this.inflightCommits.get(args.sessionId) === run) {
813
+ this.inflightCommits.delete(args.sessionId);
814
+ }
815
+ }
816
+ }
817
+ async commitVariantImpl(args) {
552
818
  // Idempotent path — SessionStore.recordVariantPick is the source of truth
553
819
  // for which variant the user picked. If the same variant is being
554
820
  // committed again, replay the previously stored envelope's metadata
555
821
  // without going back through resources (which may have been torn down).
556
822
  const existingPick = this.store.getVariantPick(args.sessionId);
557
823
  if (existingPick && existingPick.variantId === args.variantId) {
824
+ // Retry-safe history flip. If the first commit attempt enqueued
825
+ // successfully but the history-persist task crashed (it's
826
+ // fire-and-forget — see persistVariantHistoryAtCommit call site),
827
+ // the chosen variant stays at `completed` instead of `committed`
828
+ // on disk. Re-run the flip on every duplicate attempt; the
829
+ // terminal-status guard in `markStatus` makes this safely
830
+ // idempotent.
831
+ const projectContext = this.store.getProjectContext(args.sessionId);
832
+ let historyProjectPath;
833
+ if (projectContext.kind === 'fresh') {
834
+ historyProjectPath = projectContext.workspaceRoot;
835
+ }
836
+ else {
837
+ try {
838
+ historyProjectPath =
839
+ (await this.resolveEnv(args.sessionId))?.projectPath;
840
+ }
841
+ catch {
842
+ historyProjectPath = undefined;
843
+ }
844
+ }
845
+ if (historyProjectPath) {
846
+ void this.persistVariantHistoryAtCommit({
847
+ sessionId: args.sessionId,
848
+ chosenVariantId: args.variantId,
849
+ projectPath: historyProjectPath,
850
+ projectKind: projectContext.kind,
851
+ destinationPath: existingPick.destinationPath ?? historyProjectPath,
852
+ }).catch((err) => {
853
+ log.warn(`persistVariantHistoryAtCommit (duplicate retry) failed for session ${args.sessionId}`, err);
854
+ });
855
+ }
558
856
  return {
559
857
  enqueued: false,
560
858
  duplicate: true,
@@ -573,39 +871,160 @@ class AgentVariantsOrchestrator {
573
871
  if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
574
872
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
575
873
  }
576
- const env = await this.resolveEnv(args.sessionId);
577
874
  const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
578
875
  const projectContext = this.store.getProjectContext(args.sessionId);
579
876
  let payload;
580
877
  let envelopeDestination;
581
878
  let changedFilesCount;
582
879
  if (projectContext.kind === 'fresh') {
583
- // Fresh sessions: static preview HTML is the deliverable. Write index.html
584
- // to the destination directory.
585
880
  const destinationPath = projectContext.workspacePath;
586
881
  this.assertDestinationAvailable(destinationPath);
587
- const staticPreview = resources.staticPreviews.get(args.variantId);
588
- if (!staticPreview) {
589
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
590
- }
591
- try {
592
- fs_1.default.mkdirSync(destinationPath, { recursive: true });
593
- fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview.html, 'utf8');
882
+ const freshMode = projectContext.executionPlan?.mode === 'vite_app'
883
+ ? 'vite_app'
884
+ : 'static_preview';
885
+ if (freshMode === 'vite_app') {
886
+ // Vite_app: the deliverable is the entire variant worktree, not a
887
+ // single HTML file. When the worktree lives on the same volume as
888
+ // the destination (the common case — provisionFreshWorktrees scaffolds
889
+ // under destinationParent/.rivet-variants/) we can rename it into
890
+ // place. That moves the entire tree — including the materialized
891
+ // node_modules — in O(1) with no copy, and the rename is atomic.
892
+ // For cross-volume cases (user passed a destinationParent on a
893
+ // different mount than the .rivet-variants dir, e.g. tests stubbing
894
+ // workspacePath to a tmp dir), fall back to the recursive-copy +
895
+ // background-install path so the feature still works.
896
+ const record = resources.worktrees.get(args.variantId);
897
+ if (!record) {
898
+ throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
899
+ }
900
+ let usedRename = false;
901
+ let isDependencyInstallRequired = false;
902
+ try {
903
+ if (areSameVolume(record.worktreePath, destinationPath)) {
904
+ // Vite's dev server writes its module cache to `<worktreePath>/.vite/`
905
+ // and recreates that directory on the fly whenever it's missing. If
906
+ // we rename a worktree out from under a running dev server, Vite
907
+ // will materialize an empty `.vite/` shell back at the OLD path —
908
+ // leaving behind orphan stub dirs in `.rivet-variants/` and
909
+ // confusing later cleanup. Stop every variant's dev server first
910
+ // (including the chosen variant, which is about to move) so no
911
+ // process is holding the old paths open during the rename.
912
+ //
913
+ // We stop them eagerly here rather than relying on teardownSession
914
+ // because teardown runs AFTER the rename — too late to prevent the
915
+ // stub-dir race.
916
+ await this.stopAllDevServersForSession(resources);
917
+ // node_modules in fresh variants 1..N is a symlink into variant 0
918
+ // (see runBackgroundInstall). After rename, variant 0's
919
+ // node_modules vanishes during cleanup of the sibling worktrees,
920
+ // leaving the chosen variant with a dangling link. Resolve the
921
+ // symlink to a real directory first so the destination has real
922
+ // deps and the rename is self-contained.
923
+ isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
924
+ await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
925
+ usedRename = true;
926
+ }
927
+ else {
928
+ await this.materializeProject(record.worktreePath, destinationPath);
929
+ }
930
+ }
931
+ catch (err) {
932
+ const message = err instanceof Error ? err.message : String(err);
933
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
934
+ }
935
+ changedFilesCount = countWorktreeFiles(destinationPath);
936
+ if (!usedRename) {
937
+ // Cross-volume fallback: materializeProject excludes node_modules
938
+ // so we have to re-install at the destination. Don't await the
939
+ // install — the agent's commit_variant call should return
940
+ // immediately — but chain the dev-server start to it so the iframe
941
+ // recovers without manual intervention once deps are ready.
942
+ void this.installDependencies(destinationPath)
943
+ .then(() => this.startCommittedDevServer({
944
+ resources,
945
+ destinationPath,
946
+ }))
947
+ .catch((err) => {
948
+ log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
949
+ });
950
+ }
951
+ else if (isDependencyInstallRequired) {
952
+ await this.installDependencies(destinationPath);
953
+ }
954
+ if (usedRename) {
955
+ // Preserve the unchosen variants as runnable siblings of the
956
+ // chosen project. Best-effort: failures here log and continue so
957
+ // a partial history never blocks the commit handoff.
958
+ try {
959
+ this.preserveUnchosenVariants({
960
+ sessionId: args.sessionId,
961
+ chosenVariantId: args.variantId,
962
+ destinationPath,
963
+ });
964
+ }
965
+ catch (err) {
966
+ log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
967
+ }
968
+ // Stopped every dev server pre-rename to dodge the .vite/ stub-dir
969
+ // race. The iframe is still pointed at the (now-dead) chosen
970
+ // variant's port via the proxy, so without a fresh server at the
971
+ // destination the user would see "preview disconnected" the moment
972
+ // they commit. Spawn one and retarget the proxy. Best-effort: if
973
+ // it fails, the user can `npm run dev` themselves at destination.
974
+ await this.startCommittedDevServer({
975
+ resources,
976
+ destinationPath,
977
+ });
978
+ }
979
+ payload = {
980
+ kind: 'project-created',
981
+ destinationPath,
982
+ changedFilesCount,
983
+ note: usedRename
984
+ ? 'Vite app moved to destinationPath (rename); node_modules came along.'
985
+ : 'Vite app materialized at destinationPath; `npm install` running in the background.',
986
+ };
987
+ envelopeDestination = destinationPath;
594
988
  }
595
- catch (err) {
596
- const message = err instanceof Error ? err.message : String(err);
597
- throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
989
+ else {
990
+ // Static_preview: HTML is the entire deliverable. Write index.html.
991
+ // Prefer the in-memory record; fall back to the persisted history at
992
+ // `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/files/index.html`
993
+ // so a process restart between report_variant_complete and commit
994
+ // doesn't strand the variant.
995
+ const staticPreview = resources.staticPreviews.get(args.variantId);
996
+ const htmlFromSnapshot = await this.variantHistory.readStaticPreview({
997
+ projectPath: projectContext.workspaceRoot,
998
+ sessionId: args.sessionId,
999
+ variantId: args.variantId,
1000
+ });
1001
+ if (!staticPreview && !htmlFromSnapshot) {
1002
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
1003
+ }
1004
+ try {
1005
+ fs_1.default.mkdirSync(destinationPath, { recursive: true });
1006
+ fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
1007
+ }
1008
+ catch (err) {
1009
+ const message = err instanceof Error ? err.message : String(err);
1010
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
1011
+ }
1012
+ changedFilesCount = 1;
1013
+ payload = {
1014
+ kind: 'project-created',
1015
+ destinationPath,
1016
+ changedFilesCount,
1017
+ note: 'Static preview written to index.html at destinationPath.',
1018
+ };
1019
+ envelopeDestination = destinationPath;
598
1020
  }
599
- changedFilesCount = 1;
600
- payload = {
601
- kind: 'project-created',
602
- destinationPath,
603
- changedFilesCount,
604
- note: 'Static preview written to index.html at destinationPath.',
605
- };
606
- envelopeDestination = destinationPath;
607
1021
  }
608
1022
  else {
1023
+ // Existing project: diff against the user's repo. resolveEnv lives in
1024
+ // this branch only — fresh sessions don't have a user-supplied project
1025
+ // and the bridge.getProjectPath() it reads from would either be stale
1026
+ // or empty for them.
1027
+ const env = await this.resolveEnv(args.sessionId);
609
1028
  const record = resources.worktrees.get(args.variantId);
610
1029
  if (!record) {
611
1030
  throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
@@ -614,11 +1033,26 @@ class AgentVariantsOrchestrator {
614
1033
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
615
1034
  }
616
1035
  changedFilesCount = countDiffFiles(record.diff);
1036
+ // Apply the diff to the user's working tree right now so the variant
1037
+ // lands on the current branch (uncommitted). Previously this just
1038
+ // enqueued the diff for an agent to apply; non-MCP sessions had no
1039
+ // agent and the variant was effectively lost.
1040
+ if (!this.worktrees.applyDiffToProject) {
1041
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
1042
+ }
1043
+ try {
1044
+ await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
1045
+ }
1046
+ catch (err) {
1047
+ const message = err instanceof Error ? err.message : String(err);
1048
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
1049
+ }
617
1050
  payload = {
618
- kind: 'diff',
1051
+ kind: 'diff-applied',
619
1052
  diff: record.diff,
620
1053
  target: input.target,
621
1054
  changedFilesCount,
1055
+ note: 'Variant diff applied to the user\'s working tree (uncommitted).',
622
1056
  };
623
1057
  envelopeDestination = env.projectPath;
624
1058
  }
@@ -637,6 +1071,25 @@ class AgentVariantsOrchestrator {
637
1071
  sessionId: args.sessionId,
638
1072
  envelope,
639
1073
  });
1074
+ // History: flip every persisted variant in this session to its terminal
1075
+ // status. Variants were persisted at code_gen success time; this only
1076
+ // patches the status (+ destinationPath on the chosen one). For fresh
1077
+ // sessions, history lives at `<workspaceRoot>/.rivet/variants/` — the
1078
+ // user's working dir — so all variants from any session in the workspace
1079
+ // accumulate in one place, not per-subproject. Best-effort, never blocks
1080
+ // the commit.
1081
+ const historyProjectPath = projectContext.kind === 'fresh'
1082
+ ? projectContext.workspaceRoot
1083
+ : envelopeDestination;
1084
+ void this.persistVariantHistoryAtCommit({
1085
+ sessionId: args.sessionId,
1086
+ chosenVariantId: args.variantId,
1087
+ projectPath: historyProjectPath,
1088
+ projectKind: projectContext.kind,
1089
+ destinationPath: envelopeDestination,
1090
+ }).catch((err) => {
1091
+ log.warn(`persistVariantHistoryAtCommit failed for session ${args.sessionId}`, err);
1092
+ });
640
1093
  const enqueueResult = this.adapter.enqueue(envelope);
641
1094
  resources.committedVariantIds.add(args.variantId);
642
1095
  if (this.activeSessionId === args.sessionId) {
@@ -678,14 +1131,20 @@ class AgentVariantsOrchestrator {
678
1131
  /**
679
1132
  * Ensure the user-facing destination path can receive the new project.
680
1133
  * Rejects when the path exists and is non-empty.
1134
+ *
1135
+ * `.rivet/` (pre-commit snapshots + history manifests) and `.gitignore`
1136
+ * (written by VariantHistoryService.ensureGitignore when variants persist
1137
+ * at success time) are both tolerated — neither is user-authored content
1138
+ * that would be clobbered by the materialize step.
681
1139
  */
682
1140
  assertDestinationAvailable(destinationPath) {
683
1141
  if (!fs_1.default.existsSync(destinationPath))
684
1142
  return;
685
1143
  const entries = fs_1.default.readdirSync(destinationPath);
686
- if (entries.length === 0)
1144
+ const userVisibleEntries = entries.filter((entry) => entry !== '.rivet' && entry !== '.gitignore');
1145
+ if (userVisibleEntries.length === 0)
687
1146
  return;
688
- throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${entries.length} entries) — refuse to materialize.`);
1147
+ throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
689
1148
  }
690
1149
  /** Read the captured diff for a code-gen variant, if available. */
691
1150
  getVariantDiff(sessionId, variantId) {
@@ -695,8 +1154,13 @@ class AgentVariantsOrchestrator {
695
1154
  async provisionWorktrees(sessionId, approveResult) {
696
1155
  const projectContext = this.store.getProjectContext(sessionId);
697
1156
  if (projectContext.kind === 'fresh') {
698
- // Fresh sessions use static_preview as the final deliverable no
699
- // worktrees, no scaffold, no npm install needed.
1157
+ // Fresh + static_preview: no worktrees, no scaffold, HTML is the
1158
+ // deliverable. Fresh + vite_app: provision a Vite skeleton per variant
1159
+ // and copy the agent-planned assetPlan files before the agent leases
1160
+ // its code_gen items.
1161
+ if (approveResult.scaffoldBaseWorkItemId) {
1162
+ await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
1163
+ }
700
1164
  return;
701
1165
  }
702
1166
  await this.provisionExistingWorktrees(sessionId, approveResult);
@@ -769,8 +1233,19 @@ class AgentVariantsOrchestrator {
769
1233
  designContext: summarizeDesignContext(designContext),
770
1234
  });
771
1235
  log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
772
- const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext);
1236
+ // destinationParent for fresh worktrees: the user's workspace root
1237
+ // (sibling to `.rivet/`). Keeping the worktree on the same volume as
1238
+ // the materialize destination turns commit into a directory rename
1239
+ // instead of a recursive copy. `path.dirname(workspacePath)` *used*
1240
+ // to equal `workspaceRoot`, but after nesting subprojects under
1241
+ // `<workspaceRoot>/.rivet/<slug>/` the dirname is now `.rivet/`,
1242
+ // which would stage worktrees inside `.rivet/.rivet-variants/`.
1243
+ // Use workspaceRoot directly so staging lives at
1244
+ // `<workspaceRoot>/.rivet-variants/` as originally intended.
1245
+ const destinationParent = projectContext.workspaceRoot;
1246
+ const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
773
1247
  resources.scaffoldBaseWorkItemId = scaffoldId;
1248
+ resources.freshDestinationParent = destinationParent;
774
1249
  // Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
775
1250
  // work item is internal — no dedicated worktree.
776
1251
  for (let i = 0; i < codeGenIds.length; i++) {
@@ -779,6 +1254,45 @@ class AgentVariantsOrchestrator {
779
1254
  worktreePath: paths[i],
780
1255
  });
781
1256
  }
1257
+ // Copy agent-planned assets (e.g. avatar.glb) into each worktree so
1258
+ // generated code can reference them via plan.referenceAs. Done before
1259
+ // the background install so the assets are on disk by the time the
1260
+ // agent leases its code_gen item.
1261
+ // If copy throws (e.g. source asset deleted between planning and
1262
+ // provisioning), fail scaffold_base immediately so dependent code_gen
1263
+ // items cascade DEPENDENCY_FAILED instead of leaving the session
1264
+ // permanently stuck in `work_items_ready`.
1265
+ const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
1266
+ if (assetPlan.length > 0) {
1267
+ try {
1268
+ for (const worktreePath of paths) {
1269
+ for (const entry of assetPlan) {
1270
+ copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
1271
+ }
1272
+ }
1273
+ }
1274
+ catch (err) {
1275
+ const message = err instanceof Error ? err.message : String(err);
1276
+ log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
1277
+ trackScaffoldFailed(this.telemetry, {
1278
+ sessionId,
1279
+ errorCode: 'SCAFFOLD_FAILED',
1280
+ durationMs: Date.now() - scaffoldStartedAt,
1281
+ });
1282
+ try {
1283
+ this.store.failInternal({
1284
+ sessionId,
1285
+ workItemId: scaffoldId,
1286
+ error: { code: 'SCAFFOLD_FAILED', message },
1287
+ });
1288
+ this.emitChange();
1289
+ }
1290
+ catch (storeErr) {
1291
+ log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
1292
+ }
1293
+ return;
1294
+ }
1295
+ }
782
1296
  // Background install: don't await. The orchestrator finishes the
783
1297
  // approve→provision call immediately; the agent polls for scaffold
784
1298
  // completion via continue_variants(action='check').
@@ -794,7 +1308,22 @@ class AgentVariantsOrchestrator {
794
1308
  async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
795
1309
  const installStartedAt = Date.now();
796
1310
  try {
797
- await Promise.all(worktreePaths.map((p) => this.installDependencies(p)));
1311
+ const [firstWorktree, ...rest] = worktreePaths;
1312
+ await this.installDependencies(firstWorktree);
1313
+ await Promise.all(rest.map(async (p) => {
1314
+ const target = path_1.default.join(firstWorktree, 'node_modules');
1315
+ const link = path_1.default.join(p, 'node_modules');
1316
+ // The fresh template has no node_modules until install runs, so
1317
+ // the link target is guaranteed absent in `p` — no need to remove
1318
+ // first.
1319
+ try {
1320
+ fs_1.default.symlinkSync(target, link, 'dir');
1321
+ }
1322
+ catch (symlinkErr) {
1323
+ log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
1324
+ await this.installDependencies(p);
1325
+ }
1326
+ }));
798
1327
  }
799
1328
  catch (err) {
800
1329
  const message = err instanceof Error ? err.message : String(err);
@@ -835,6 +1364,65 @@ class AgentVariantsOrchestrator {
835
1364
  variantCount: worktreePaths.length,
836
1365
  });
837
1366
  }
1367
+ /**
1368
+ * Gate `succeeded` reports for `static_preview` items through preview QA
1369
+ * before they reach the store. Returns either `null` (caller continues
1370
+ * with the original args — non-static-preview report, non-succeeded
1371
+ * status, no parseable HTML, or QA passed) or an override carrying a
1372
+ * `failed` rewrite of the report. The QA result is also memoized on the
1373
+ * session resources so `getVariants` can surface it.
1374
+ *
1375
+ * Only fresh-project sessions go through this gate today; existing-
1376
+ * project `code_gen` variants produce a diff with no preview document
1377
+ * to inspect, so they skip QA and behave exactly as before.
1378
+ */
1379
+ async evaluateQaForReport(args) {
1380
+ if (args.status !== 'succeeded')
1381
+ return null;
1382
+ if (!this.store.hasSession(args.sessionId))
1383
+ return null;
1384
+ const projectContext = this.store.getProjectContext(args.sessionId);
1385
+ if (projectContext.kind !== 'fresh')
1386
+ return null;
1387
+ const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
1388
+ if (!parsed)
1389
+ return null;
1390
+ let qa;
1391
+ try {
1392
+ qa = await this.previewQaRunner({
1393
+ sessionId: args.sessionId,
1394
+ workItemId: args.workItemId,
1395
+ html: parsed.html,
1396
+ });
1397
+ }
1398
+ catch (err) {
1399
+ const message = err instanceof Error ? err.message : String(err);
1400
+ qa = {
1401
+ status: 'failed',
1402
+ issues: [
1403
+ {
1404
+ kind: 'preview_unavailable',
1405
+ detail: 'qa_runner_error',
1406
+ message,
1407
+ },
1408
+ ],
1409
+ summary: `Preview QA runner threw: ${message}`,
1410
+ };
1411
+ }
1412
+ const resources = this.ensureResources(args.sessionId);
1413
+ resources.qaResults.set(args.workItemId, qa);
1414
+ if (qa.status === 'failed') {
1415
+ return {
1416
+ qa,
1417
+ overrideArgs: {
1418
+ ...args,
1419
+ status: 'failed',
1420
+ error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
1421
+ },
1422
+ };
1423
+ }
1424
+ return { qa };
1425
+ }
838
1426
  async handleSucceededReport(args) {
839
1427
  const { sessionId, workItemId } = args;
840
1428
  const resources = this.resources.get(sessionId);
@@ -850,6 +1438,20 @@ class AgentVariantsOrchestrator {
850
1438
  html: staticPreview.html,
851
1439
  };
852
1440
  resources.staticPreviews.set(workItemId, record);
1441
+ if (this.store.getProjectContext(sessionId).kind === 'fresh') {
1442
+ // History at `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`
1443
+ // is the sole on-disk record. The legacy per-subproject snapshot
1444
+ // tree (`<slug>/.rivet/<variantName>/`) is no longer written —
1445
+ // it duplicated this data in a parallel layout and cluttered
1446
+ // `.rivet/` with slug-named directories before the user ever
1447
+ // committed.
1448
+ this.persistCompletedFreshVariant({
1449
+ sessionId,
1450
+ workItemId,
1451
+ }).catch((err) => {
1452
+ log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
1453
+ });
1454
+ }
853
1455
  const leasedAt = resources.leasedAt.get(workItemId);
854
1456
  trackStaticPreviewCompleted(this.telemetry, {
855
1457
  sessionId,
@@ -881,6 +1483,31 @@ class AgentVariantsOrchestrator {
881
1483
  catch (err) {
882
1484
  log.warn(`getDiff failed for ${record.worktreePath}`, err);
883
1485
  }
1486
+ // History: persist every completed variant immediately to
1487
+ // `<projectPath>/.rivet/variants/`. Existing projects pass the captured
1488
+ // diff (or an empty string when capture itself failed — the variant
1489
+ // still succeeded code-gen-wise, and the history row is the only
1490
+ // record the UI has). Fresh-project variants copy their worktree
1491
+ // (vite_app) or the staged HTML (static_preview). Best-effort — a
1492
+ // failure here must never block dev-server startup or the user's pick
1493
+ // flow.
1494
+ if (!isFresh) {
1495
+ this.persistCompletedExistingVariant({
1496
+ sessionId,
1497
+ workItemId,
1498
+ diff: record.diff ?? '',
1499
+ }).catch((err) => {
1500
+ log.warn(`persistCompletedExistingVariant failed for ${sessionId}/${workItemId}`, err);
1501
+ });
1502
+ }
1503
+ else {
1504
+ this.persistCompletedFreshVariant({
1505
+ sessionId,
1506
+ workItemId,
1507
+ }).catch((err) => {
1508
+ log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
1509
+ });
1510
+ }
884
1511
  // Bring up a dev server in the variant's worktree so the user can cycle
885
1512
  // through live variants in the iframe via the chip. Failures here are
886
1513
  // logged but non-fatal — the user can still pick by reading the diff.
@@ -890,6 +1517,19 @@ class AgentVariantsOrchestrator {
890
1517
  const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
891
1518
  record.port = port;
892
1519
  record.devServerProcess = proc;
1520
+ // If the dev server dies on its own (crash, OOM, Vite hard-fail),
1521
+ // clear the port so the iframe stops routing the chip onto a dead
1522
+ // socket. Without this the proxy retargets onto an unreachable port
1523
+ // and the chip shows "upstream_unreachable" instead of the accurate
1524
+ // "Preview is unavailable for this variant".
1525
+ proc.once('exit', (code, signal) => {
1526
+ if (record.devServerProcess !== proc)
1527
+ return;
1528
+ record.port = undefined;
1529
+ record.devServerProcess = undefined;
1530
+ log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
1531
+ this.emitChange();
1532
+ });
893
1533
  this.emitChange();
894
1534
  trackFreshDevServerStarted(this.telemetry, {
895
1535
  sessionId,
@@ -907,6 +1547,219 @@ class AgentVariantsOrchestrator {
907
1547
  log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
908
1548
  }
909
1549
  }
1550
+ /**
1551
+ * Persist a completed existing-project code_gen variant into
1552
+ * `<env.projectPath>/.rivet/variants/<sessionId>/<variantId>/`. Called from
1553
+ * `handleSucceededReport` once the worktree diff has been captured but
1554
+ * before the user picks. Status is `completed` until `commitVariant` or a
1555
+ * cancellation transitions it.
1556
+ */
1557
+ async persistCompletedExistingVariant(args) {
1558
+ const projectContext = this.store.getProjectContext(args.sessionId);
1559
+ if (projectContext.kind !== 'existing')
1560
+ return;
1561
+ let projectPath;
1562
+ try {
1563
+ const env = await this.resolveEnv(args.sessionId);
1564
+ projectPath = env.projectPath;
1565
+ }
1566
+ catch (err) {
1567
+ log.warn(`persistCompletedExistingVariant: resolveEnv failed for ${args.sessionId}`, err);
1568
+ return;
1569
+ }
1570
+ const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1571
+ const sessionPrompt = this.store.getPrompt(args.sessionId);
1572
+ await this.variantHistory.persistVariant({
1573
+ projectPath,
1574
+ sessionId: args.sessionId,
1575
+ variantId: args.workItemId,
1576
+ label: input.briefLabel,
1577
+ brief: input.briefBody,
1578
+ sessionPrompt,
1579
+ kind: 'diff',
1580
+ diff: args.diff,
1581
+ changedFilesCount: countDiffFiles(args.diff),
1582
+ projectKind: 'existing',
1583
+ });
1584
+ }
1585
+ /**
1586
+ * Persist a completed fresh-project variant into
1587
+ * `<projectContext.workspacePath>/.rivet/variants/<sessionId>/<variantId>/`.
1588
+ * Called from `handleSucceededReport` right after the per-variant snapshot
1589
+ * lands on disk — for static_preview that's the `.rivet/<slug>/` snapshot
1590
+ * dir; for vite_app fresh variants it's the worktree itself. Status starts
1591
+ * as 'completed' and transitions to 'committed' / 'rejected' / 'cancelled'
1592
+ * via `markStatus` at commit or teardown time.
1593
+ *
1594
+ * Running at success time (rather than commit time) means the history panel
1595
+ * populates live as variants generate — including when the user never picks
1596
+ * one. Mirrors the existing-project persistCompletedExistingVariant flow.
1597
+ */
1598
+ async persistCompletedFreshVariant(args) {
1599
+ const projectContext = this.store.getProjectContext(args.sessionId);
1600
+ if (projectContext.kind !== 'fresh')
1601
+ return;
1602
+ // Variant history lives at the workspace root — the user's working
1603
+ // dir (e.g. `fable-eng-demo/`). All variants from any session in this
1604
+ // workspace accumulate at
1605
+ // `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`.
1606
+ const historyProjectPath = projectContext.workspaceRoot;
1607
+ const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1608
+ const sessionPrompt = this.store.getPrompt(args.sessionId);
1609
+ const designArtifact = resolveDesignArtifact(input.designContextEntry);
1610
+ const resources = this.resources.get(args.sessionId);
1611
+ const worktreeRecord = resources?.worktrees.get(args.workItemId);
1612
+ const staticPreview = resources?.staticPreviews.get(args.workItemId);
1613
+ // Vite_app deliverables are full scaffolded worktrees — pass the worktree
1614
+ // directory as sourceDir and let copyDirFiltered handle it (excludes
1615
+ // node_modules, .rivet, etc). Static_preview deliverables are inline HTML
1616
+ // captured in `resources.staticPreviews`; stage them in a tmp dir so the
1617
+ // existing copy path works without poking the user's workspace.
1618
+ let sourceDir = null;
1619
+ let tmpStagingDir = null;
1620
+ if (worktreeRecord && fs_1.default.existsSync(worktreeRecord.worktreePath)) {
1621
+ sourceDir = worktreeRecord.worktreePath;
1622
+ }
1623
+ else if (staticPreview) {
1624
+ tmpStagingDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), `rivet-variant-${args.workItemId}-`));
1625
+ fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'index.html'), staticPreview.html, 'utf8');
1626
+ fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'brief.md'), `# ${input.briefLabel}\n\n${input.briefBody}\n`, 'utf8');
1627
+ sourceDir = tmpStagingDir;
1628
+ }
1629
+ if (!sourceDir) {
1630
+ log.warn(`persistCompletedFreshVariant: no source for ${args.workItemId} (session ${args.sessionId}); skipping`);
1631
+ return;
1632
+ }
1633
+ // For static_preview, only `index.html` is a real deliverable — `brief.md`
1634
+ // is implementation-detail staging that we copy alongside it. Hardcode
1635
+ // the count so the history row matches what `commit_variant` reports
1636
+ // (always 1) instead of double-counting the brief.
1637
+ const changedFilesCount = staticPreview
1638
+ ? 1
1639
+ : countWorktreeFiles(sourceDir);
1640
+ try {
1641
+ await this.variantHistory.persistVariant({
1642
+ projectPath: historyProjectPath,
1643
+ sessionId: args.sessionId,
1644
+ variantId: args.workItemId,
1645
+ label: input.briefLabel,
1646
+ brief: input.briefBody,
1647
+ sessionPrompt,
1648
+ kind: 'project-created',
1649
+ sourceDir,
1650
+ changedFilesCount,
1651
+ projectKind: 'fresh',
1652
+ designMarkdown: designArtifact?.markdown,
1653
+ designSource: designArtifact?.source,
1654
+ });
1655
+ }
1656
+ finally {
1657
+ if (tmpStagingDir) {
1658
+ try {
1659
+ fs_1.default.rmSync(tmpStagingDir, { recursive: true, force: true });
1660
+ }
1661
+ catch (err) {
1662
+ log.warn(`Failed to clean up variant staging dir ${tmpStagingDir}: ${err instanceof Error ? err.message : String(err)}`);
1663
+ }
1664
+ }
1665
+ }
1666
+ }
1667
+ /**
1668
+ * At commit time, flip persisted variant manifests to terminal statuses
1669
+ * ('committed' for the chosen, 'rejected' for the rest). Both existing- and
1670
+ * fresh-project variants have already been persisted at code_gen success
1671
+ * time, so this is a pure status update — no source-dir copy required.
1672
+ * Also records the destination path on the chosen variant so history can
1673
+ * surface "this variant became <path>".
1674
+ */
1675
+ async persistVariantHistoryAtCommit(args) {
1676
+ const variants = this.store.getVariants(args.sessionId);
1677
+ for (const variant of variants) {
1678
+ if (variant.status !== 'succeeded')
1679
+ continue;
1680
+ const workItemId = variant.workItemId;
1681
+ const isChosen = workItemId === args.chosenVariantId;
1682
+ const status = isChosen
1683
+ ? 'committed'
1684
+ : 'rejected';
1685
+ await this.variantHistory.markStatus({
1686
+ projectPath: args.projectPath,
1687
+ sessionId: args.sessionId,
1688
+ variantId: workItemId,
1689
+ status,
1690
+ destinationPath: isChosen ? args.destinationPath : undefined,
1691
+ });
1692
+ }
1693
+ }
1694
+ /**
1695
+ * Flip persisted variant manifests to status='cancelled' for every variant
1696
+ * in the session that was already snapshotted to disk. Fresh- and existing-
1697
+ * project variants both persist at success time now, so both need their
1698
+ * manifests flipped here. Best-effort — missing manifests are silently
1699
+ * skipped by VariantHistoryService.markStatus.
1700
+ */
1701
+ async markPersistedVariantsCancelled(sessionId) {
1702
+ if (!this.store.hasSession(sessionId))
1703
+ return;
1704
+ const projectPath = await this.resolveHistoryProjectPath(sessionId);
1705
+ if (!projectPath)
1706
+ return;
1707
+ const variants = this.store.getVariants(sessionId);
1708
+ for (const variant of variants) {
1709
+ await this.variantHistory.markStatus({
1710
+ projectPath,
1711
+ sessionId,
1712
+ variantId: variant.workItemId,
1713
+ status: 'cancelled',
1714
+ });
1715
+ }
1716
+ }
1717
+ async markPersistedVariantCancelled(sessionId, variantId) {
1718
+ if (!this.store.hasSession(sessionId))
1719
+ return;
1720
+ const projectPath = await this.resolveHistoryProjectPath(sessionId);
1721
+ if (!projectPath)
1722
+ return;
1723
+ await this.variantHistory.markStatus({
1724
+ projectPath,
1725
+ sessionId,
1726
+ variantId,
1727
+ status: 'cancelled',
1728
+ });
1729
+ }
1730
+ /**
1731
+ * Resolve the project path that owns `.rivet/variants/` for a session.
1732
+ * Existing sessions: the user's project (via `resolveEnv`). Fresh sessions:
1733
+ * the *workspace root*, which is the parent of `workspacePath` — variants
1734
+ * accumulate there across sessions instead of being scattered under each
1735
+ * subproject. Must match what `persistCompletedFreshVariant` writes to.
1736
+ */
1737
+ async resolveHistoryProjectPath(sessionId) {
1738
+ const projectContext = this.store.getProjectContext(sessionId);
1739
+ if (projectContext.kind === 'fresh') {
1740
+ return projectContext.workspaceRoot;
1741
+ }
1742
+ try {
1743
+ const env = await this.resolveEnv(sessionId);
1744
+ return env.projectPath;
1745
+ }
1746
+ catch (err) {
1747
+ log.warn(`resolveHistoryProjectPath: resolveEnv failed for ${sessionId}`, err);
1748
+ return null;
1749
+ }
1750
+ }
1751
+ readManifest(manifestPath) {
1752
+ if (!fs_1.default.existsSync(manifestPath)) {
1753
+ return undefined;
1754
+ }
1755
+ try {
1756
+ const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
1757
+ return JSON.parse(raw);
1758
+ }
1759
+ catch {
1760
+ return undefined;
1761
+ }
1762
+ }
910
1763
  /**
911
1764
  * Resolve dev server invocation for a worktree. Fresh-project worktrees
912
1765
  * always use the Vite template's npm command at the worktree root; existing
@@ -941,6 +1794,301 @@ class AgentVariantsOrchestrator {
941
1794
  env: { PORT: String(port) },
942
1795
  };
943
1796
  }
1797
+ /**
1798
+ * After the chosen variant has been renamed into `destinationPath`, move
1799
+ * each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
1800
+ * folder and repoint its `node_modules` symlink at the chosen project's
1801
+ * real install. Writes a manifest documenting all 5 variants (the chosen
1802
+ * one with `folder: null` and `chosenPath: '../<slug>'`).
1803
+ *
1804
+ * Sets `vitePreservedSiblings = true` on session resources so the
1805
+ * subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
1806
+ * delete the just-preserved history). Dev servers still get stopped.
1807
+ */
1808
+ preserveUnchosenVariants(args) {
1809
+ const resources = this.resources.get(args.sessionId);
1810
+ if (!resources)
1811
+ return;
1812
+ const destinationParent = path_1.default.dirname(args.destinationPath);
1813
+ const projectSlug = path_1.default.basename(args.destinationPath);
1814
+ const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
1815
+ const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
1816
+ const briefs = this.store.getBriefs(args.sessionId);
1817
+ const variants = this.store.getVariants(args.sessionId);
1818
+ const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
1819
+ const manifestEntries = [];
1820
+ let chosenSlug = '';
1821
+ for (const variant of variants) {
1822
+ const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1823
+ const label = input.briefLabel ?? variant.label ?? 'variant';
1824
+ const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
1825
+ const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
1826
+ const numericPrefix = String(briefIdx + 1).padStart(2, '0');
1827
+ const folderName = `${numericPrefix}-${slug}`;
1828
+ if (variant.workItemId === args.chosenVariantId) {
1829
+ chosenSlug = slug;
1830
+ manifestEntries.push({
1831
+ variantId: variant.workItemId,
1832
+ label,
1833
+ slug,
1834
+ folder: null,
1835
+ isChosen: true,
1836
+ chosenPath: `../${projectSlug}`,
1837
+ });
1838
+ continue;
1839
+ }
1840
+ const record = resources.worktrees.get(variant.workItemId);
1841
+ if (!record) {
1842
+ // No worktree (e.g. failed variant) — still list it in manifest with
1843
+ // null folder so the user can see why it's absent.
1844
+ manifestEntries.push({
1845
+ variantId: variant.workItemId,
1846
+ label,
1847
+ slug,
1848
+ folder: null,
1849
+ isChosen: false,
1850
+ });
1851
+ continue;
1852
+ }
1853
+ if (!fs_1.default.existsSync(record.worktreePath)) {
1854
+ manifestEntries.push({
1855
+ variantId: variant.workItemId,
1856
+ label,
1857
+ slug,
1858
+ folder: null,
1859
+ isChosen: false,
1860
+ });
1861
+ continue;
1862
+ }
1863
+ try {
1864
+ const newPath = this.worktrees.moveWorktreeToHistory
1865
+ ? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
1866
+ : (() => {
1867
+ fs_1.default.mkdirSync(historyDir, { recursive: true });
1868
+ const dest = path_1.default.join(historyDir, folderName);
1869
+ fs_1.default.renameSync(record.worktreePath, dest);
1870
+ return dest;
1871
+ })();
1872
+ // Repoint node_modules at the chosen project's real install.
1873
+ try {
1874
+ if (this.worktrees.repointNodeModulesSymlink) {
1875
+ this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
1876
+ }
1877
+ else {
1878
+ const link = path_1.default.join(newPath, 'node_modules');
1879
+ try {
1880
+ fs_1.default.rmSync(link, { force: true, recursive: true });
1881
+ }
1882
+ catch {
1883
+ /* may not exist */
1884
+ }
1885
+ const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
1886
+ fs_1.default.symlinkSync(target, link, 'dir');
1887
+ }
1888
+ }
1889
+ catch (err) {
1890
+ log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
1891
+ }
1892
+ // Update the in-memory record so teardown doesn't try to operate on
1893
+ // the stale path.
1894
+ record.worktreePath = newPath;
1895
+ manifestEntries.push({
1896
+ variantId: variant.workItemId,
1897
+ label,
1898
+ slug,
1899
+ folder: folderName,
1900
+ isChosen: false,
1901
+ });
1902
+ }
1903
+ catch (err) {
1904
+ log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
1905
+ manifestEntries.push({
1906
+ variantId: variant.workItemId,
1907
+ label,
1908
+ slug,
1909
+ folder: null,
1910
+ isChosen: false,
1911
+ });
1912
+ }
1913
+ }
1914
+ const manifest = {
1915
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1916
+ createdAt: new Date().toISOString(),
1917
+ sessionId: args.sessionId,
1918
+ chosenVariantId: args.chosenVariantId,
1919
+ chosenSlug: chosenSlug || projectSlug,
1920
+ variants: manifestEntries,
1921
+ };
1922
+ try {
1923
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
1924
+ }
1925
+ catch (err) {
1926
+ log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
1927
+ }
1928
+ resources.vitePreservedSiblings = true;
1929
+ }
1930
+ /**
1931
+ * Rename `sourceWorktreePath` into `destinationPath`, then replace the
1932
+ * worktree's `.git` with a fresh-history init + initial commit. Matches
1933
+ * the historical materializeProject behavior of "user sees a fresh repo
1934
+ * at destinationPath" so we don't surprise them with a `rivet: variant
1935
+ * baseline` commit they didn't intend.
1936
+ *
1937
+ * The rename is the load-bearing operation: it moves the entire variant
1938
+ * tree (template files + materialized node_modules) in O(1) when the
1939
+ * source and destination share a volume. The git reset that follows is
1940
+ * best-effort housekeeping; a failure there leaves files in place.
1941
+ */
1942
+ async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
1943
+ // destinationPath must not exist for fs.rename to behave as a move.
1944
+ // assertDestinationAvailable already enforced that any existing directory
1945
+ // contains only `.rivet`, which we preserve across the move.
1946
+ const destinationParent = path_1.default.dirname(destinationPath);
1947
+ await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
1948
+ let preservedRivetParent;
1949
+ const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
1950
+ if (fs_1.default.existsSync(destinationPath)) {
1951
+ preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
1952
+ if (fs_1.default.existsSync(existingRivetPath)) {
1953
+ await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
1954
+ }
1955
+ await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
1956
+ }
1957
+ try {
1958
+ await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
1959
+ }
1960
+ catch (err) {
1961
+ if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
1962
+ await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
1963
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1964
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1965
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1966
+ }
1967
+ }
1968
+ throw err;
1969
+ }
1970
+ if (preservedRivetParent) {
1971
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1972
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1973
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1974
+ }
1975
+ await fs_1.default.promises.rm(preservedRivetParent, {
1976
+ recursive: true,
1977
+ force: true,
1978
+ });
1979
+ }
1980
+ // Replace the variant-baseline git history with a fresh init so the
1981
+ // user doesn't inherit a `rivet: variant baseline` commit.
1982
+ const dotGit = path_1.default.join(destinationPath, '.git');
1983
+ try {
1984
+ fs_1.default.rmSync(dotGit, { recursive: true, force: true });
1985
+ }
1986
+ catch {
1987
+ // Best-effort — leaving the baseline .git is preferable to failing
1988
+ // the commit after we've moved the tree.
1989
+ }
1990
+ try {
1991
+ const git = (0, simple_git_1.simpleGit)(destinationPath);
1992
+ await git.raw(['init']);
1993
+ await git.raw(['add', '-A']);
1994
+ await git
1995
+ .raw([
1996
+ '-c',
1997
+ 'user.name=Rivet',
1998
+ '-c',
1999
+ 'user.email=hello@tryrivet.design',
2000
+ 'commit',
2001
+ '--no-gpg-sign',
2002
+ '-m',
2003
+ 'Initial commit (created with Rivet)',
2004
+ ])
2005
+ .catch(() => {
2006
+ // Best-effort: missing user identity or empty tree leaves the
2007
+ // commit step un-applied. Files are still in place.
2008
+ });
2009
+ }
2010
+ catch (err) {
2011
+ log.warn(`Initial git history setup at ${destinationPath} failed`, err);
2012
+ }
2013
+ }
2014
+ /**
2015
+ * Stop every variant dev server currently running for `resources`. Clears
2016
+ * the `devServerProcess` / `port` fields on each record so subsequent
2017
+ * teardown logic doesn't try to stop them a second time. Called from
2018
+ * `commitVariant` (vite_app rename path) BEFORE the worktree directory is
2019
+ * renamed — leaving Vite alive across the rename leaks empty `.vite/`
2020
+ * stub dirs at the old paths.
2021
+ */
2022
+ async stopAllDevServersForSession(resources) {
2023
+ const stops = [];
2024
+ for (const record of resources.worktrees.values()) {
2025
+ if (!record.devServerProcess)
2026
+ continue;
2027
+ const proc = record.devServerProcess;
2028
+ stops.push(this.worktrees
2029
+ .stopDevServer(proc)
2030
+ .catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
2031
+ record.devServerProcess = undefined;
2032
+ record.port = undefined;
2033
+ }
2034
+ await Promise.all(stops);
2035
+ }
2036
+ /**
2037
+ * Spawn a fresh dev server at the user-facing destinationPath and retarget
2038
+ * the proxy so the iframe stays live across commit. Stores the process on
2039
+ * `resources.committedDevServer` so teardown can hand it off to the
2040
+ * lingering registry instead of killing it. Best-effort: a failure here is
2041
+ * non-fatal — the commit still succeeds; the user just has to run
2042
+ * `npm run dev` themselves to bring the preview back.
2043
+ */
2044
+ async startCommittedDevServer(args) {
2045
+ try {
2046
+ const port = await this.worktrees.getFreePort();
2047
+ const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
2048
+ 'run',
2049
+ 'dev',
2050
+ '--',
2051
+ '--port',
2052
+ String(port),
2053
+ '--host',
2054
+ FRESH_DEV_SERVER_HOST,
2055
+ ], { PORT: String(port) });
2056
+ args.resources.committedDevServer = {
2057
+ proc,
2058
+ port,
2059
+ path: args.destinationPath,
2060
+ };
2061
+ try {
2062
+ this.switchPreviewPort?.(port);
2063
+ }
2064
+ catch (err) {
2065
+ log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
2066
+ }
2067
+ log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
2068
+ }
2069
+ catch (err) {
2070
+ log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
2071
+ }
2072
+ }
2073
+ /**
2074
+ * Stop any committed dev servers that were left alive past their session's
2075
+ * teardown. Called at the start of a new `propose` (so the next session's
2076
+ * preview doesn't compete with stale processes) and exposed for explicit
2077
+ * shutdown hooks. Best-effort: per-server failures are logged but don't
2078
+ * propagate.
2079
+ */
2080
+ async stopLingeringCommittedDevServers() {
2081
+ const entries = [...this.lingeringCommittedDevServers.entries()];
2082
+ this.lingeringCommittedDevServers.clear();
2083
+ await Promise.all(entries.map(async ([sessionId, entry]) => {
2084
+ try {
2085
+ await this.worktrees.stopDevServer(entry.proc);
2086
+ }
2087
+ catch (err) {
2088
+ log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
2089
+ }
2090
+ }));
2091
+ }
944
2092
  async teardownSession(sessionId, reason) {
945
2093
  const resources = this.resources.get(sessionId);
946
2094
  if (!resources)
@@ -950,17 +2098,46 @@ class AgentVariantsOrchestrator {
950
2098
  resources.cleanupStarted = true;
951
2099
  log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
952
2100
  // Stop dev servers in parallel; ignore individual failures.
2101
+ // NOTE: For the fresh vite_app rename path, commitVariant already stopped
2102
+ // these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
2103
+ // stub dirs at the old worktree paths). In that case devServerProcess is
2104
+ // already cleared and this loop is a no-op — exactly the desired behavior.
953
2105
  const stops = [...resources.worktrees.values()]
954
2106
  .filter((r) => r.devServerProcess)
955
2107
  .map((r) => this.worktrees
956
2108
  .stopDevServer(r.devServerProcess)
957
2109
  .catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
958
2110
  await Promise.all(stops);
959
- try {
960
- await this.worktrees.cleanupSession(sessionId);
2111
+ // The committed dev server (spawned at destinationPath after a fresh
2112
+ // vite_app commit) intentionally outlives this teardown — the iframe is
2113
+ // pointed at it via the proxy and the user just chose this project. Hand
2114
+ // it off to the lingering registry; it'll be stopped on the next
2115
+ // `propose` or explicit shutdown.
2116
+ if (resources.committedDevServer && reason === 'committed') {
2117
+ this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
961
2118
  }
962
- catch (err) {
963
- log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
2119
+ else if (resources.committedDevServer) {
2120
+ // Non-committed teardown (cancel/shutdown) with a committed dev server
2121
+ // shouldn't happen in current code paths, but stop it defensively so we
2122
+ // don't leak procs.
2123
+ try {
2124
+ await this.worktrees.stopDevServer(resources.committedDevServer.proc);
2125
+ }
2126
+ catch (err) {
2127
+ log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
2128
+ }
2129
+ }
2130
+ // When a vite_app commit already moved the unchosen worktrees into a
2131
+ // sibling history dir, calling cleanupSession would re-delete them. Skip
2132
+ // the worktree removal step in that case — the move already cleared the
2133
+ // .rivet-variants/ entries for the chosen + preserved variants.
2134
+ if (!resources.vitePreservedSiblings) {
2135
+ try {
2136
+ await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
2137
+ }
2138
+ catch (err) {
2139
+ log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
2140
+ }
964
2141
  }
965
2142
  this.resources.delete(sessionId);
966
2143
  }
@@ -975,6 +2152,8 @@ class AgentVariantsOrchestrator {
975
2152
  committedVariantIds: new Set(),
976
2153
  startedAt: Date.now(),
977
2154
  leasedAt: new Map(),
2155
+ qaResults: new Map(),
2156
+ vitePreservedSiblings: false,
978
2157
  };
979
2158
  this.resources.set(sessionId, r);
980
2159
  }
@@ -1026,11 +2205,33 @@ function parseStaticPreviewOutput(output) {
1026
2205
  };
1027
2206
  }
1028
2207
  function buildStaticPreviewDocument(input) {
1029
- if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
1030
- return input.html;
1031
- }
1032
2208
  const style = input.css ? `<style>\n${input.css}\n</style>` : '';
1033
2209
  const script = input.js ? `<script>\n${input.js}\n</script>` : '';
2210
+ if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
2211
+ // Full document: inject css before </head> and js before </body>. The
2212
+ // agent often passes a complete `<!doctype html>...` blob with css/js
2213
+ // alongside; without this they're silently dropped and the variant ships
2214
+ // unstyled and non-interactive. Falls back to appending to the end if
2215
+ // the closing tag isn't found.
2216
+ let doc = input.html;
2217
+ if (style) {
2218
+ if (/<\/head>/i.test(doc)) {
2219
+ doc = doc.replace(/<\/head>/i, () => `${style}\n</head>`);
2220
+ }
2221
+ else {
2222
+ doc += `\n${style}`;
2223
+ }
2224
+ }
2225
+ if (script) {
2226
+ if (/<\/body>/i.test(doc)) {
2227
+ doc = doc.replace(/<\/body>/i, () => `${script}\n</body>`);
2228
+ }
2229
+ else {
2230
+ doc += `\n${script}`;
2231
+ }
2232
+ }
2233
+ return doc;
2234
+ }
1034
2235
  return `<!doctype html>
1035
2236
  <html lang="en">
1036
2237
  <head>
@@ -1048,6 +2249,35 @@ function countDiffFiles(diff) {
1048
2249
  // Each file in a unified diff starts with "diff --git ".
1049
2250
  return (diff.match(/^diff --git /gm) ?? []).length;
1050
2251
  }
2252
+ /**
2253
+ * Count source files under a materialized fresh-project destination,
2254
+ * skipping install artifacts (`node_modules`, `.git`, build outputs) so the
2255
+ * commit telemetry / chip count reflects the agent-visible surface area
2256
+ * rather than the install footprint.
2257
+ */
2258
+ function countWorktreeFiles(destinationPath) {
2259
+ let count = 0;
2260
+ const walk = (dir) => {
2261
+ let entries;
2262
+ try {
2263
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
2264
+ }
2265
+ catch {
2266
+ return;
2267
+ }
2268
+ for (const entry of entries) {
2269
+ if (MATERIALIZE_EXCLUDE.has(entry.name))
2270
+ continue;
2271
+ const full = path_1.default.join(dir, entry.name);
2272
+ if (entry.isDirectory())
2273
+ walk(full);
2274
+ else if (entry.isFile())
2275
+ count += 1;
2276
+ }
2277
+ };
2278
+ walk(destinationPath);
2279
+ return count;
2280
+ }
1051
2281
  const enrichDesignSource = (design) => {
1052
2282
  if (!design || design.kind !== 'slug')
1053
2283
  return design;
@@ -1063,6 +2293,7 @@ const toActiveProjectContext = (projectContext) => {
1063
2293
  return {
1064
2294
  kind: 'fresh',
1065
2295
  workspacePath: projectContext.workspacePath,
2296
+ workspaceRoot: projectContext.workspaceRoot,
1066
2297
  framework: projectContext.framework,
1067
2298
  designContext: projectContext.designContext?.map((entry) => entry.kind === 'slug'
1068
2299
  ? { kind: 'slug', slug: entry.slug }
@@ -1075,6 +2306,122 @@ const toActiveProjectContext = (projectContext) => {
1075
2306
  },
1076
2307
  }
1077
2308
  : {}),
2309
+ ...(projectContext.executionPlan
2310
+ ? {
2311
+ executionPlan: {
2312
+ mode: projectContext.executionPlan.mode,
2313
+ confidence: projectContext.executionPlan.confidence,
2314
+ reason: projectContext.executionPlan.reason,
2315
+ assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
2316
+ runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
2317
+ },
2318
+ }
2319
+ : {}),
2320
+ };
2321
+ };
2322
+ /**
2323
+ * Resolve the user-facing supporting artifacts for a session.
2324
+ *
2325
+ * For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
2326
+ * turned into a `design_context` artifact carrying the full DESIGN.md
2327
+ * markdown:
2328
+ * - `slug` entries resolve bundled catalog markdown via the design catalog.
2329
+ * - `markdown` entries (Agent Browser / inspiration extractor output) carry
2330
+ * their stored markdown verbatim.
2331
+ * Slots whose markdown can't be resolved are skipped so the UI never renders
2332
+ * a metadata-only DESIGN.md row.
2333
+ */
2334
+ const buildSessionArtifacts = (projectContext) => {
2335
+ if (projectContext.kind !== 'fresh')
2336
+ return [];
2337
+ const designContext = projectContext.designContext;
2338
+ if (!designContext || designContext.length === 0)
2339
+ return [];
2340
+ const artifactsByContent = new Map();
2341
+ designContext.forEach((entry, slot) => {
2342
+ if (!entry)
2343
+ return;
2344
+ if (entry.kind === 'slug') {
2345
+ const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
2346
+ if (!markdown)
2347
+ return;
2348
+ const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
2349
+ addDesignContextArtifact(artifactsByContent, {
2350
+ id: `design_context:${slot}:${entry.slug}`,
2351
+ kind: 'design_context',
2352
+ label: catalogEntry?.name ?? entry.slug,
2353
+ ...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
2354
+ status: 'ready',
2355
+ source: 'static',
2356
+ contentType: 'text/markdown',
2357
+ content: markdown,
2358
+ });
2359
+ return;
2360
+ }
2361
+ if (!entry.content)
2362
+ return;
2363
+ addDesignContextArtifact(artifactsByContent, {
2364
+ id: `design_context:${slot}:${entry.label}`,
2365
+ kind: 'design_context',
2366
+ label: entry.label,
2367
+ status: 'ready',
2368
+ source: 'agent_browser',
2369
+ contentType: 'text/markdown',
2370
+ content: entry.content,
2371
+ });
2372
+ });
2373
+ return [...artifactsByContent.values()].map((artifact) => {
2374
+ if (artifact.usedByVariantCount <= 1)
2375
+ return artifact;
2376
+ return {
2377
+ ...artifact,
2378
+ summary: artifact.summary
2379
+ ? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
2380
+ : `Used by ${artifact.usedByVariantCount} variants.`,
2381
+ };
2382
+ });
2383
+ };
2384
+ const addDesignContextArtifact = (artifactsByContent, artifact) => {
2385
+ if (!artifact.content)
2386
+ return;
2387
+ const existing = artifactsByContent.get(artifact.content);
2388
+ if (existing) {
2389
+ existing.usedByVariantCount += 1;
2390
+ return;
2391
+ }
2392
+ artifactsByContent.set(artifact.content, {
2393
+ ...artifact,
2394
+ usedByVariantCount: 1,
2395
+ });
2396
+ };
2397
+ /**
2398
+ * Resolve a per-variant design context entry into the raw DESIGN.md markdown
2399
+ * the worktree scaffolder writes, plus a small `designSource` descriptor for
2400
+ * the variant manifest. Slug entries resolve through the bundled catalog;
2401
+ * markdown entries (Agent Browser / inspiration extractor output) carry their
2402
+ * stored markdown verbatim. Returns null when the entry is missing or the
2403
+ * slug doesn't resolve to bundled markdown.
2404
+ */
2405
+ const resolveDesignArtifact = (entry) => {
2406
+ if (!entry)
2407
+ return null;
2408
+ if (entry.kind === 'markdown') {
2409
+ return {
2410
+ markdown: entry.content,
2411
+ source: { kind: 'markdown', label: entry.label },
2412
+ };
2413
+ }
2414
+ const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
2415
+ if (!markdown)
2416
+ return null;
2417
+ const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
2418
+ return {
2419
+ markdown,
2420
+ source: {
2421
+ kind: 'slug',
2422
+ slug: entry.slug,
2423
+ label: catalogEntry?.name ?? entry.slug,
2424
+ },
1078
2425
  };
1079
2426
  };
1080
2427
  const summarizeDesignContext = (designContext) => {
@@ -1196,6 +2543,87 @@ const defaultInstallDependencies = (worktreePath) => {
1196
2543
  });
1197
2544
  });
1198
2545
  };
2546
+ /**
2547
+ * True when `a` and `b` (or the closest existing ancestor of each) live on
2548
+ * the same filesystem volume. Used by commitVariant to decide between an
2549
+ * O(1) rename and a recursive copy-with-reinstall fallback.
2550
+ *
2551
+ * `destinationPath` for fresh sessions does not exist yet (we'd fail the
2552
+ * "destination must be empty" guard otherwise), so we compare against its
2553
+ * parent directory which is guaranteed to exist (createZeroToOneTool either
2554
+ * created it or validated it).
2555
+ */
2556
+ function areSameVolume(a, b) {
2557
+ try {
2558
+ const statA = fs_1.default.statSync(a);
2559
+ const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
2560
+ const statB = fs_1.default.statSync(parentB);
2561
+ return statA.dev === statB.dev;
2562
+ }
2563
+ catch {
2564
+ return false;
2565
+ }
2566
+ }
2567
+ /**
2568
+ * Materialize node_modules from symlink → real directory at the given path,
2569
+ * so a subsequent rename of that path leaves a self-contained tree.
2570
+ *
2571
+ * Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
2572
+ * runBackgroundInstall). When the user picks variant K and we rename it
2573
+ * into the destination, the sibling worktrees get torn down — which would
2574
+ * otherwise leave a dangling symlink in the destination if variant K was
2575
+ * the one with the symlink (K > 0).
2576
+ *
2577
+ * Returns true when the destination must run a dependency install after the
2578
+ * rename because node_modules is missing or points nowhere.
2579
+ */
2580
+ async function resolveNodeModulesSymlink(worktreePath) {
2581
+ const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
2582
+ let lstat;
2583
+ try {
2584
+ lstat = await fs_1.default.promises.lstat(nodeModulesPath);
2585
+ }
2586
+ catch {
2587
+ return true;
2588
+ }
2589
+ if (!lstat.isSymbolicLink())
2590
+ return false;
2591
+ let target;
2592
+ try {
2593
+ target = await fs_1.default.promises.realpath(nodeModulesPath);
2594
+ }
2595
+ catch {
2596
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2597
+ return true;
2598
+ }
2599
+ // Stage the copy under a sibling temp name so a mid-copy failure (disk
2600
+ // full, EACCES, etc.) leaves the original symlink intact rather than
2601
+ // wiping it and leaving the worktree with no node_modules at all.
2602
+ // The final swap is then atomic on the same filesystem (rename + unlink).
2603
+ const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
2604
+ try {
2605
+ await fs_1.default.promises.cp(target, stagingPath, {
2606
+ recursive: true,
2607
+ dereference: false,
2608
+ });
2609
+ }
2610
+ catch (err) {
2611
+ // Best-effort cleanup of the partial staging dir; leave the symlink
2612
+ // intact so the caller can re-run materialization.
2613
+ await fs_1.default.promises
2614
+ .rm(stagingPath, { recursive: true, force: true })
2615
+ .catch(() => { });
2616
+ throw err;
2617
+ }
2618
+ // Swap: remove the symlink, then rename the staging dir into place.
2619
+ // Both operations are local fs ops on the same parent — fast and
2620
+ // unlikely to fail independently, but if the rename fails the symlink
2621
+ // is already gone, so prefer to leave the staging dir on disk over a
2622
+ // half-broken worktree (the caller will surface the throw).
2623
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2624
+ await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
2625
+ return false;
2626
+ }
1199
2627
  const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
1200
2628
  await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
1201
2629
  await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {