rivet-design 0.9.4 → 0.9.6

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 (66) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +11 -2
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +85 -22
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +70 -3
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +790 -132
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  9. package/dist/mcp/agent-variants/contracts.d.ts +495 -129
  10. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  11. package/dist/mcp/agent-variants/contracts.js +120 -37
  12. package/dist/mcp/agent-variants/contracts.js.map +1 -1
  13. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +40 -15
  14. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
  15. package/dist/mcp/agent-variants/createZeroToOneTool.js +24 -8
  16. package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -1
  17. package/dist/mcp/agent-variants/tools.d.ts +17 -2
  18. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  19. package/dist/mcp/agent-variants/tools.js +129 -15
  20. package/dist/mcp/agent-variants/tools.js.map +1 -1
  21. package/dist/mcp/server.d.ts.map +1 -1
  22. package/dist/mcp/server.js +13 -7
  23. package/dist/mcp/server.js.map +1 -1
  24. package/dist/prompts/agentModPrompts.d.ts.map +1 -1
  25. package/dist/prompts/agentModPrompts.js +9 -8
  26. package/dist/prompts/agentModPrompts.js.map +1 -1
  27. package/dist/proxy-middleware/proxy-config.d.ts +2 -2
  28. package/dist/proxy-middleware/proxy-config.d.ts.map +1 -1
  29. package/dist/proxy-middleware/proxy-config.js +66 -22
  30. package/dist/proxy-middleware/proxy-config.js.map +1 -1
  31. package/dist/routes/agentVariants.d.ts +2 -13
  32. package/dist/routes/agentVariants.d.ts.map +1 -1
  33. package/dist/routes/agentVariants.js +233 -3
  34. package/dist/routes/agentVariants.js.map +1 -1
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +58 -1
  37. package/dist/server.js.map +1 -1
  38. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  39. package/dist/services/ProjectDetectionService.js +12 -0
  40. package/dist/services/ProjectDetectionService.js.map +1 -1
  41. package/dist/services/VariantHistoryService.d.ts +180 -0
  42. package/dist/services/VariantHistoryService.d.ts.map +1 -0
  43. package/dist/services/VariantHistoryService.js +515 -0
  44. package/dist/services/VariantHistoryService.js.map +1 -0
  45. package/dist/services/agent/AgentCore.d.ts +1 -1
  46. package/dist/services/agent/AgentCore.d.ts.map +1 -1
  47. package/dist/services/agent/AgentCore.js +24 -1
  48. package/dist/services/agent/AgentCore.js.map +1 -1
  49. package/dist/services/agent/AgentModService.d.ts +1 -1
  50. package/dist/services/agent/AgentModService.js +1 -1
  51. package/dist/utils/skills/claude-skill.d.ts +1 -1
  52. package/dist/utils/skills/claude-skill.js +1 -1
  53. package/dist/utils/skills/cursor-rules.d.ts +1 -1
  54. package/dist/utils/skills/cursor-rules.js +1 -1
  55. package/dist/utils/skills/describe-motion-protocol.d.ts +11 -0
  56. package/dist/utils/skills/describe-motion-protocol.d.ts.map +1 -0
  57. package/dist/utils/skills/describe-motion-protocol.js +216 -0
  58. package/dist/utils/skills/describe-motion-protocol.js.map +1 -0
  59. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
  60. package/dist/utils/skills/shared-variants-protocol.js +23 -17
  61. package/dist/utils/skills/shared-variants-protocol.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/ui/dist/assets/main-BX1XfsOq.css +1 -0
  64. package/src/ui/dist/assets/{main-CpX7fB64.js → main-CO7W1r28.js} +38 -38
  65. package/src/ui/dist/index.html +2 -2
  66. package/src/ui/dist/assets/main-Qqe2_oMT.css +0 -1
@@ -4,9 +4,11 @@ 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;
7
8
  const crypto_1 = require("crypto");
8
9
  const events_1 = require("events");
9
10
  const fs_1 = __importDefault(require("fs"));
11
+ const os_1 = __importDefault(require("os"));
10
12
  const path_1 = __importDefault(require("path"));
11
13
  const child_process_1 = require("child_process");
12
14
  const simple_git_1 = require("simple-git");
@@ -17,8 +19,11 @@ const contracts_1 = require("./contracts");
17
19
  const viteReactTs_1 = require("../../services/templates/viteReactTs");
18
20
  const designCatalog_1 = require("../../services/templates/designCatalog");
19
21
  const previewQa_1 = require("./previewQa");
22
+ const VariantHistoryService_1 = require("../../services/VariantHistoryService");
20
23
  const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
21
24
  const FRESH_DEV_SERVER_HOST = '127.0.0.1';
25
+ const DESIGN_CONTEXT_ROUTE_SEGMENT = 'design-md';
26
+ const DESIGN_CONTEXT_VIEW_SEGMENT = 'view';
22
27
  /**
23
28
  * Allowlist of asset file extensions an agent-planned source may have.
24
29
  * `assetPlan` is sized for large local *assets* (3D models, images,
@@ -36,15 +41,36 @@ const FRESH_DEV_SERVER_HOST = '127.0.0.1';
36
41
  */
37
42
  const ALLOWED_ASSET_EXTENSIONS = new Set([
38
43
  // 3D / models
39
- '.glb', '.gltf', '.obj', '.fbx', '.usdz',
44
+ '.glb',
45
+ '.gltf',
46
+ '.obj',
47
+ '.fbx',
48
+ '.usdz',
40
49
  // images
41
- '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
50
+ '.png',
51
+ '.jpg',
52
+ '.jpeg',
53
+ '.gif',
54
+ '.webp',
55
+ '.svg',
56
+ '.avif',
57
+ '.bmp',
58
+ '.ico',
42
59
  // video
43
- '.mp4', '.webm', '.mov',
60
+ '.mp4',
61
+ '.webm',
62
+ '.mov',
44
63
  // audio
45
- '.mp3', '.wav', '.ogg', '.m4a',
64
+ '.mp3',
65
+ '.wav',
66
+ '.ogg',
67
+ '.m4a',
46
68
  // fonts
47
- '.woff', '.woff2', '.ttf', '.otf', '.eot',
69
+ '.woff',
70
+ '.woff2',
71
+ '.ttf',
72
+ '.otf',
73
+ '.eot',
48
74
  // PDFs
49
75
  '.pdf',
50
76
  ]);
@@ -177,8 +203,7 @@ function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
177
203
  throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
178
204
  }
179
205
  const normalizedDest = path_1.default.normalize(entry.destination);
180
- if (normalizedDest.startsWith('..') ||
181
- path_1.default.isAbsolute(normalizedDest)) {
206
+ if (normalizedDest.startsWith('..') || path_1.default.isAbsolute(normalizedDest)) {
182
207
  throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
183
208
  }
184
209
  const absDest = path_1.default.join(worktreePath, normalizedDest);
@@ -209,6 +234,7 @@ class AgentVariantsOrchestrator {
209
234
  materializeProject;
210
235
  previewQaRunner;
211
236
  switchPreviewPort;
237
+ variantHistory;
212
238
  resources = new Map();
213
239
  /**
214
240
  * Committed dev servers from prior sessions that survived teardown. The
@@ -248,6 +274,7 @@ class AgentVariantsOrchestrator {
248
274
  deps.materializeProject ?? defaultMaterializeProject;
249
275
  this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
250
276
  this.switchPreviewPort = deps.switchPreviewPort;
277
+ this.variantHistory = deps.variantHistory ?? new VariantHistoryService_1.VariantHistoryService();
251
278
  }
252
279
  // --- Pure delegations (no side effects) ---------------------------------
253
280
  propose(args) {
@@ -301,8 +328,10 @@ class AgentVariantsOrchestrator {
301
328
  const variants = this.getVariants(sessionId);
302
329
  const sessionProjectContext = this.store.getProjectContext(sessionId);
303
330
  const projectContext = toActiveProjectContext(sessionProjectContext);
304
- const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
305
- const artifacts = buildSessionArtifacts(sessionProjectContext);
331
+ const destinationPath = projectContext.kind === 'fresh'
332
+ ? projectContext.workspacePath
333
+ : undefined;
334
+ const artifacts = buildSessionArtifacts(sessionId, sessionProjectContext);
306
335
  return {
307
336
  active: true,
308
337
  sessionId,
@@ -424,7 +453,9 @@ class AgentVariantsOrchestrator {
424
453
  };
425
454
  }
426
455
  }
427
- catch { /* work item may not exist in edge cases */ }
456
+ catch {
457
+ /* work item may not exist in edge cases */
458
+ }
428
459
  }
429
460
  if (!preview && port) {
430
461
  preview = { kind: 'dev_server', port };
@@ -435,7 +466,7 @@ class AgentVariantsOrchestrator {
435
466
  const canView = Boolean(preview) || (isSucceeded && Boolean(port));
436
467
  const canCommit = isSucceeded && !qaFailed;
437
468
  const commitDisabledReason = qaFailed
438
- ? qa?.summary ?? 'Variant failed QA'
469
+ ? (qa?.summary ?? 'Variant failed QA')
439
470
  : 'Wait for a successful variant';
440
471
  return {
441
472
  ...variant,
@@ -481,7 +512,9 @@ class AgentVariantsOrchestrator {
481
512
  }
482
513
  getStaticPreviewHtml(sessionId, workItemId) {
483
514
  // Primary: from the staticPreviews Map populated by handleSucceededReport.
484
- const fromMap = this.resources.get(sessionId)?.staticPreviews.get(workItemId)?.html;
515
+ const fromMap = this.resources
516
+ .get(sessionId)
517
+ ?.staticPreviews.get(workItemId)?.html;
485
518
  if (fromMap)
486
519
  return fromMap;
487
520
  // Fallback: read directly from the work item's stored output — available
@@ -496,6 +529,16 @@ class AgentVariantsOrchestrator {
496
529
  return undefined;
497
530
  }
498
531
  }
532
+ /** Resolve the DESIGN.md markdown behind an artifact link. */
533
+ getDesignContextMarkdown(sessionId, artifactId) {
534
+ const artifact = findDesignContextArtifact(this.store.getProjectContext(sessionId), artifactId);
535
+ return artifact?.content;
536
+ }
537
+ /** Build the raw-plus-rendered DesignMD document for an artifact link. */
538
+ getDesignContextViewerHtml(sessionId, artifactId) {
539
+ const artifact = findDesignContextArtifact(this.store.getProjectContext(sessionId), artifactId);
540
+ return artifact ? buildDesignContextViewerDocument(artifact) : undefined;
541
+ }
499
542
  getStaticPreviewByBriefId(sessionId, briefId) {
500
543
  const resources = this.resources.get(sessionId);
501
544
  if (!resources)
@@ -554,8 +597,12 @@ class AgentVariantsOrchestrator {
554
597
  */
555
598
  async startUnified(args) {
556
599
  const count = args.briefs?.length ?? args.count ?? 4;
557
- const projectContext = args.projectContext ?? { kind: 'existing' };
558
- const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
600
+ const projectContext = args.projectContext ?? {
601
+ kind: 'existing',
602
+ };
603
+ const sourceContext = projectContext.kind === 'fresh'
604
+ ? projectContext.sourceContext
605
+ : undefined;
559
606
  const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
560
607
  Boolean(sourceContext?.sourceArtifacts?.length) ||
561
608
  Boolean(sourceContext?.sourceIntent) ||
@@ -736,6 +783,9 @@ class AgentVariantsOrchestrator {
736
783
  fromStage: stageBefore,
737
784
  });
738
785
  this.emitChange();
786
+ void this.markPersistedVariantsCancelled(args.sessionId).catch((err) => {
787
+ log.warn(`markPersistedVariantsCancelled failed for ${args.sessionId}`, err);
788
+ });
739
789
  void this.teardownSession(args.sessionId, 'cancel').catch((err) => {
740
790
  log.error(`teardownSession failed for ${args.sessionId}`, err);
741
791
  });
@@ -763,6 +813,11 @@ class AgentVariantsOrchestrator {
763
813
  alreadyTerminal: result.alreadyTerminal,
764
814
  reason: args.reason ?? null,
765
815
  });
816
+ if (!result.alreadyTerminal) {
817
+ void this.markPersistedVariantCancelled(args.sessionId, args.variantId).catch((err) => {
818
+ log.warn(`markPersistedVariantCancelled failed for ${args.sessionId}/${args.variantId}`, err);
819
+ });
820
+ }
766
821
  this.emitChange();
767
822
  return {
768
823
  sessionId: args.sessionId,
@@ -808,6 +863,38 @@ class AgentVariantsOrchestrator {
808
863
  // without going back through resources (which may have been torn down).
809
864
  const existingPick = this.store.getVariantPick(args.sessionId);
810
865
  if (existingPick && existingPick.variantId === args.variantId) {
866
+ // Retry-safe history flip. If the first commit attempt enqueued
867
+ // successfully but the history-persist task crashed (it's
868
+ // fire-and-forget — see persistVariantHistoryAtCommit call site),
869
+ // the chosen variant stays at `completed` instead of `committed`
870
+ // on disk. Re-run the flip on every duplicate attempt; the
871
+ // terminal-status guard in `markStatus` makes this safely
872
+ // idempotent.
873
+ const projectContext = this.store.getProjectContext(args.sessionId);
874
+ let historyProjectPath;
875
+ if (projectContext.kind === 'fresh') {
876
+ historyProjectPath = projectContext.workspaceRoot;
877
+ }
878
+ else {
879
+ try {
880
+ historyProjectPath = (await this.resolveEnv(args.sessionId))
881
+ ?.projectPath;
882
+ }
883
+ catch {
884
+ historyProjectPath = undefined;
885
+ }
886
+ }
887
+ if (historyProjectPath) {
888
+ void this.persistVariantHistoryAtCommit({
889
+ sessionId: args.sessionId,
890
+ chosenVariantId: args.variantId,
891
+ projectPath: historyProjectPath,
892
+ projectKind: projectContext.kind,
893
+ destinationPath: existingPick.destinationPath ?? historyProjectPath,
894
+ }).catch((err) => {
895
+ log.warn(`persistVariantHistoryAtCommit (duplicate retry) failed for session ${args.sessionId}`, err);
896
+ });
897
+ }
811
898
  return {
812
899
  enqueued: false,
813
900
  duplicate: true,
@@ -824,23 +911,18 @@ class AgentVariantsOrchestrator {
824
911
  }
825
912
  const variantSnapshot = this.getVariants(args.sessionId).find((variant) => variant.workItemId === args.variantId);
826
913
  if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
827
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
914
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ??
915
+ 'Variant is not committable');
828
916
  }
829
917
  const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
830
918
  const projectContext = this.store.getProjectContext(args.sessionId);
831
919
  let payload;
832
920
  let envelopeDestination;
833
921
  let changedFilesCount;
834
- let freshVariantFolderName;
922
+ let runnablePaths;
835
923
  if (projectContext.kind === 'fresh') {
836
924
  const destinationPath = projectContext.workspacePath;
837
925
  this.assertDestinationAvailable(destinationPath);
838
- const variantFolderName = this.getFreshVariantFolderName({
839
- sessionId: args.sessionId,
840
- variantId: args.variantId,
841
- variantName: input.briefLabel,
842
- });
843
- freshVariantFolderName = variantFolderName;
844
926
  const freshMode = projectContext.executionPlan?.mode === 'vite_app'
845
927
  ? 'vite_app'
846
928
  : 'static_preview';
@@ -918,7 +1000,7 @@ class AgentVariantsOrchestrator {
918
1000
  // chosen project. Best-effort: failures here log and continue so
919
1001
  // a partial history never blocks the commit handoff.
920
1002
  try {
921
- this.preserveUnchosenVariants({
1003
+ runnablePaths = this.preserveUnchosenVariants({
922
1004
  sessionId: args.sessionId,
923
1005
  chosenVariantId: args.variantId,
924
1006
  destinationPath,
@@ -950,11 +1032,16 @@ class AgentVariantsOrchestrator {
950
1032
  }
951
1033
  else {
952
1034
  // Static_preview: HTML is the entire deliverable. Write index.html.
1035
+ // Prefer the in-memory record; fall back to the persisted history at
1036
+ // `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/files/index.html`
1037
+ // so a process restart between report_variant_complete and commit
1038
+ // doesn't strand the variant.
953
1039
  const staticPreview = resources.staticPreviews.get(args.variantId);
954
- const persistedSnapshotIndexPath = (0, createProjectArtifacts_1.createProjectVariantIndexPath)(destinationPath, variantFolderName);
955
- const htmlFromSnapshot = fs_1.default.existsSync(persistedSnapshotIndexPath)
956
- ? fs_1.default.readFileSync(persistedSnapshotIndexPath, 'utf8')
957
- : null;
1040
+ const htmlFromSnapshot = await this.variantHistory.readStaticPreview({
1041
+ projectPath: projectContext.workspaceRoot,
1042
+ sessionId: args.sessionId,
1043
+ variantId: args.variantId,
1044
+ });
958
1045
  if (!staticPreview && !htmlFromSnapshot) {
959
1046
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
960
1047
  }
@@ -1009,7 +1096,7 @@ class AgentVariantsOrchestrator {
1009
1096
  diff: record.diff,
1010
1097
  target: input.target,
1011
1098
  changedFilesCount,
1012
- note: 'Variant diff applied to the user\'s working tree (uncommitted).',
1099
+ note: "Variant diff applied to the user's working tree (uncommitted).",
1013
1100
  };
1014
1101
  envelopeDestination = env.projectPath;
1015
1102
  }
@@ -1028,30 +1115,37 @@ class AgentVariantsOrchestrator {
1028
1115
  sessionId: args.sessionId,
1029
1116
  envelope,
1030
1117
  });
1031
- if (projectContext.kind === 'fresh') {
1032
- // Manifest persistence is bookkeeping for the .rivet/ artifacts and must
1033
- // never strand the session `recordVariantPick` above has already marked
1034
- // this variant as the selection, so a retry would short-circuit via the
1035
- // `duplicate: true` path and skip the enqueue below. Catch and log
1036
- // instead of letting a filesystem hiccup block the handoff to pending-
1037
- // changes.
1038
- try {
1039
- this.persistFreshSelectionMetadata({
1040
- sessionId: args.sessionId,
1041
- variantId: args.variantId,
1042
- variantName: input.briefLabel,
1043
- projectPath: projectContext.workspacePath,
1044
- variantFolderName: freshVariantFolderName ?? (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel),
1045
- changedFilesCount,
1046
- });
1047
- }
1048
- catch (err) {
1049
- log.warn(`persistFreshSelectionMetadata failed for session ${args.sessionId} variant ${args.variantId} — continuing with enqueue`, err);
1050
- }
1051
- }
1118
+ // History: flip every persisted variant in this session to its terminal
1119
+ // status. Variants were persisted at code_gen success time; this only
1120
+ // patches the status (+ destinationPath on the chosen one). For fresh
1121
+ // sessions, history lives at `<workspaceRoot>/.rivet/variants/` the
1122
+ // user's working dir so all variants from any session in the workspace
1123
+ // accumulate in one place, not per-subproject. Best-effort, never blocks
1124
+ // the commit.
1125
+ const historyProjectPath = projectContext.kind === 'fresh'
1126
+ ? projectContext.workspaceRoot
1127
+ : envelopeDestination;
1128
+ void this.persistVariantHistoryAtCommit({
1129
+ sessionId: args.sessionId,
1130
+ chosenVariantId: args.variantId,
1131
+ projectPath: historyProjectPath,
1132
+ projectKind: projectContext.kind,
1133
+ destinationPath: envelopeDestination,
1134
+ runnablePaths,
1135
+ }).catch((err) => {
1136
+ log.warn(`persistVariantHistoryAtCommit failed for session ${args.sessionId}`, err);
1137
+ });
1052
1138
  const enqueueResult = this.adapter.enqueue(envelope);
1053
1139
  resources.committedVariantIds.add(args.variantId);
1054
- if (this.activeSessionId === args.sessionId) {
1140
+ // For fresh `new-project` commits the committed variant IS the user's
1141
+ // final result — there's no dev server to fall back to. Keep the session
1142
+ // active so SSE keeps pushing snapshots with the chosen variant; the UI's
1143
+ // iframe stays on it instead of unmounting to about:blank (which would
1144
+ // surface a misleading "Preview isn't connected" overlay). For diff /
1145
+ // diff-applied commits the user moves on to their own dev server, so
1146
+ // clearing is correct — that's the original behavior preserved below.
1147
+ if (this.activeSessionId === args.sessionId &&
1148
+ payload.kind !== 'project-created') {
1055
1149
  this.activeSessionId = null;
1056
1150
  }
1057
1151
  const dwellMsFromTerminal = resources.terminalAt
@@ -1090,12 +1184,17 @@ class AgentVariantsOrchestrator {
1090
1184
  /**
1091
1185
  * Ensure the user-facing destination path can receive the new project.
1092
1186
  * Rejects when the path exists and is non-empty.
1187
+ *
1188
+ * `.rivet/` (pre-commit snapshots + history manifests) and `.gitignore`
1189
+ * (written by VariantHistoryService.ensureGitignore when variants persist
1190
+ * at success time) are both tolerated — neither is user-authored content
1191
+ * that would be clobbered by the materialize step.
1093
1192
  */
1094
1193
  assertDestinationAvailable(destinationPath) {
1095
1194
  if (!fs_1.default.existsSync(destinationPath))
1096
1195
  return;
1097
1196
  const entries = fs_1.default.readdirSync(destinationPath);
1098
- const userVisibleEntries = entries.filter((entry) => entry !== '.rivet');
1197
+ const userVisibleEntries = entries.filter((entry) => entry !== '.rivet' && entry !== '.gitignore');
1099
1198
  if (userVisibleEntries.length === 0)
1100
1199
  return;
1101
1200
  throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
@@ -1187,10 +1286,16 @@ class AgentVariantsOrchestrator {
1187
1286
  designContext: summarizeDesignContext(designContext),
1188
1287
  });
1189
1288
  log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
1190
- // destinationParent for fresh worktrees: same parent the materialized
1191
- // project will land in. Keeping the worktree on the same volume turns
1192
- // commit into a directory rename instead of a recursive copy.
1193
- const destinationParent = path_1.default.dirname(projectContext.workspacePath);
1289
+ // destinationParent for fresh worktrees: the user's workspace root
1290
+ // (sibling to `.rivet/`). Keeping the worktree on the same volume as
1291
+ // the materialize destination turns commit into a directory rename
1292
+ // instead of a recursive copy. `path.dirname(workspacePath)` *used*
1293
+ // to equal `workspaceRoot`, but after nesting subprojects under
1294
+ // `<workspaceRoot>/.rivet/<slug>/` the dirname is now `.rivet/`,
1295
+ // which would stage worktrees inside `.rivet/.rivet-variants/`.
1296
+ // Use workspaceRoot directly so staging lives at
1297
+ // `<workspaceRoot>/.rivet-variants/` as originally intended.
1298
+ const destinationParent = projectContext.workspaceRoot;
1194
1299
  const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
1195
1300
  resources.scaffoldBaseWorkItemId = scaffoldId;
1196
1301
  resources.freshDestinationParent = destinationParent;
@@ -1387,10 +1492,17 @@ class AgentVariantsOrchestrator {
1387
1492
  };
1388
1493
  resources.staticPreviews.set(workItemId, record);
1389
1494
  if (this.store.getProjectContext(sessionId).kind === 'fresh') {
1390
- this.persistFreshVariantArtifacts({
1495
+ // History at `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`
1496
+ // is the sole on-disk record. The legacy per-subproject snapshot
1497
+ // tree (`<slug>/.rivet/<variantName>/`) is no longer written —
1498
+ // it duplicated this data in a parallel layout and cluttered
1499
+ // `.rivet/` with slug-named directories before the user ever
1500
+ // committed.
1501
+ this.persistCompletedFreshVariant({
1391
1502
  sessionId,
1392
1503
  workItemId,
1393
- html: staticPreview.html,
1504
+ }).catch((err) => {
1505
+ log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
1394
1506
  });
1395
1507
  }
1396
1508
  const leasedAt = resources.leasedAt.get(workItemId);
@@ -1424,6 +1536,31 @@ class AgentVariantsOrchestrator {
1424
1536
  catch (err) {
1425
1537
  log.warn(`getDiff failed for ${record.worktreePath}`, err);
1426
1538
  }
1539
+ // History: persist every completed variant immediately to
1540
+ // `<projectPath>/.rivet/variants/`. Existing projects pass the captured
1541
+ // diff (or an empty string when capture itself failed — the variant
1542
+ // still succeeded code-gen-wise, and the history row is the only
1543
+ // record the UI has). Fresh-project variants copy their worktree
1544
+ // (vite_app) or the staged HTML (static_preview). Best-effort — a
1545
+ // failure here must never block dev-server startup or the user's pick
1546
+ // flow.
1547
+ if (!isFresh) {
1548
+ this.persistCompletedExistingVariant({
1549
+ sessionId,
1550
+ workItemId,
1551
+ diff: record.diff ?? '',
1552
+ }).catch((err) => {
1553
+ log.warn(`persistCompletedExistingVariant failed for ${sessionId}/${workItemId}`, err);
1554
+ });
1555
+ }
1556
+ else {
1557
+ this.persistCompletedFreshVariant({
1558
+ sessionId,
1559
+ workItemId,
1560
+ }).catch((err) => {
1561
+ log.warn(`persistCompletedFreshVariant failed for ${sessionId}/${workItemId}`, err);
1562
+ });
1563
+ }
1427
1564
  // Bring up a dev server in the variant's worktree so the user can cycle
1428
1565
  // through live variants in the iframe via the chip. Failures here are
1429
1566
  // logged but non-fatal — the user can still pick by reading the diff.
@@ -1463,80 +1600,229 @@ class AgentVariantsOrchestrator {
1463
1600
  log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
1464
1601
  }
1465
1602
  }
1466
- persistFreshVariantArtifacts(args) {
1603
+ /**
1604
+ * Persist a completed existing-project code_gen variant into
1605
+ * `<env.projectPath>/.rivet/variants/<sessionId>/<variantId>/`. Called from
1606
+ * `handleSucceededReport` once the worktree diff has been captured but
1607
+ * before the user picks. Status is `completed` until `commitVariant` or a
1608
+ * cancellation transitions it.
1609
+ */
1610
+ async persistCompletedExistingVariant(args) {
1467
1611
  const projectContext = this.store.getProjectContext(args.sessionId);
1468
- if (projectContext.kind !== 'fresh') {
1612
+ if (projectContext.kind !== 'existing')
1469
1613
  return;
1614
+ let env;
1615
+ try {
1616
+ env = await this.resolveEnv(args.sessionId);
1470
1617
  }
1471
- const projectPath = projectContext.workspacePath;
1472
- const now = new Date().toISOString();
1473
- const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(projectPath);
1474
- const existingProjectManifest = this.readManifest(projectManifestPath);
1475
- (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1476
- createdAt: existingProjectManifest?.createdAt ?? now,
1477
- selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1478
- latestVariantSessionId: args.sessionId,
1479
- selectedVariantId: existingProjectManifest?.selectedVariantId,
1480
- }));
1481
- const briefInput = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1482
- const variantFolderName = this.getFreshVariantFolderName({
1483
- sessionId: args.sessionId,
1484
- variantId: args.workItemId,
1485
- variantName: briefInput.briefLabel,
1486
- });
1487
- const snapshotPath = (0, createProjectArtifacts_1.createProjectVariantSnapshotPath)(projectPath, variantFolderName);
1488
- fs_1.default.rmSync(snapshotPath, { recursive: true, force: true });
1489
- fs_1.default.mkdirSync(snapshotPath, { recursive: true });
1490
- const briefPath = (0, createProjectArtifacts_1.createProjectVariantBriefPath)(projectPath, variantFolderName);
1491
- fs_1.default.writeFileSync(briefPath, `# ${briefInput.briefLabel}\n\n${briefInput.briefBody}\n`, 'utf8');
1492
- const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(projectPath, variantFolderName);
1493
- (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1494
- schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1495
- createdAt: now,
1618
+ catch (err) {
1619
+ log.warn(`persistCompletedExistingVariant: resolveEnv failed for ${args.sessionId}`, err);
1620
+ return;
1621
+ }
1622
+ const record = this.resources.get(args.sessionId)?.worktrees.get(args.workItemId);
1623
+ let sourceDir;
1624
+ if (env.framework === 'static' && record) {
1625
+ try {
1626
+ sourceDir = await this.worktrees.getProjectCwdInWorktree(record.worktreePath);
1627
+ }
1628
+ catch (err) {
1629
+ log.warn(`persistCompletedExistingVariant: static snapshot path failed for ${args.sessionId}/${args.workItemId}`, err);
1630
+ }
1631
+ }
1632
+ const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1633
+ const sessionPrompt = this.store.getPrompt(args.sessionId);
1634
+ await this.variantHistory.persistVariant({
1635
+ projectPath: env.projectPath,
1496
1636
  sessionId: args.sessionId,
1497
1637
  variantId: args.workItemId,
1498
- variantName: briefInput.briefLabel,
1499
- variantFolderName,
1500
- changedFilesCount: 1,
1501
- });
1502
- fs_1.default.writeFileSync((0, createProjectArtifacts_1.createProjectVariantIndexPath)(projectPath, variantFolderName), args.html, 'utf8');
1503
- }
1504
- persistFreshSelectionMetadata(args) {
1505
- const now = new Date().toISOString();
1506
- const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(args.projectPath);
1507
- const existingProjectManifest = this.readManifest(projectManifestPath);
1508
- (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1509
- createdAt: existingProjectManifest?.createdAt ?? now,
1510
- selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1511
- latestVariantSessionId: args.sessionId,
1512
- selectedVariantId: args.variantId,
1513
- }));
1514
- const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(args.projectPath, args.variantFolderName);
1515
- const existingVariantManifest = this.readManifest(variantManifestPath);
1516
- (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1517
- ...existingVariantManifest,
1518
- schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1519
- createdAt: existingVariantManifest?.createdAt ?? now,
1520
- sessionId: args.sessionId,
1521
- variantId: args.variantId,
1522
- variantName: args.variantName,
1523
- variantFolderName: args.variantFolderName,
1524
- changedFilesCount: args.changedFilesCount,
1525
- selectedVariantId: args.variantId,
1638
+ label: input.briefLabel,
1639
+ brief: input.briefBody,
1640
+ sessionPrompt,
1641
+ kind: 'diff',
1642
+ diff: args.diff,
1643
+ sourceDir,
1644
+ preview: sourceDir
1645
+ ? { kind: 'static', entryPath: 'index.html' }
1646
+ : { kind: 'none', reason: 'diff_only' },
1647
+ changedFilesCount: countDiffFiles(args.diff),
1648
+ projectKind: 'existing',
1526
1649
  });
1527
1650
  }
1528
- getFreshVariantFolderName(args) {
1529
- const baseSlug = (0, createProjectArtifacts_1.createProjectVariantSlug)(args.variantName);
1651
+ /**
1652
+ * Persist a completed fresh-project variant into
1653
+ * `<projectContext.workspacePath>/.rivet/variants/<sessionId>/<variantId>/`.
1654
+ * Called from `handleSucceededReport` right after the per-variant snapshot
1655
+ * lands on disk — for static_preview that's the `.rivet/<slug>/` snapshot
1656
+ * dir; for vite_app fresh variants it's the worktree itself. Status starts
1657
+ * as 'completed' and transitions to 'committed' / 'rejected' / 'cancelled'
1658
+ * via `markStatus` at commit or teardown time.
1659
+ *
1660
+ * Running at success time (rather than commit time) means the history panel
1661
+ * populates live as variants generate — including when the user never picks
1662
+ * one. Mirrors the existing-project persistCompletedExistingVariant flow.
1663
+ */
1664
+ async persistCompletedFreshVariant(args) {
1665
+ const projectContext = this.store.getProjectContext(args.sessionId);
1666
+ if (projectContext.kind !== 'fresh')
1667
+ return;
1668
+ // Variant history lives at the workspace root — the user's working
1669
+ // dir (e.g. `fable-eng-demo/`). All variants from any session in this
1670
+ // workspace accumulate at
1671
+ // `<workspaceRoot>/.rivet/variants/<sessionId>/<variantId>/`.
1672
+ const historyProjectPath = projectContext.workspaceRoot;
1673
+ const input = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1674
+ const sessionPrompt = this.store.getPrompt(args.sessionId);
1675
+ const designArtifact = resolveDesignArtifact(input.designContextEntry);
1676
+ const resources = this.resources.get(args.sessionId);
1677
+ const worktreeRecord = resources?.worktrees.get(args.workItemId);
1678
+ const staticPreview = resources?.staticPreviews.get(args.workItemId);
1679
+ // Vite_app deliverables are full scaffolded worktrees — pass the worktree
1680
+ // directory as sourceDir and let copyDirFiltered handle it (excludes
1681
+ // node_modules, .rivet, etc). Static_preview deliverables are inline HTML
1682
+ // captured in `resources.staticPreviews`; stage them in a tmp dir so the
1683
+ // existing copy path works without poking the user's workspace.
1684
+ let sourceDir = null;
1685
+ let tmpStagingDir = null;
1686
+ if (worktreeRecord && fs_1.default.existsSync(worktreeRecord.worktreePath)) {
1687
+ sourceDir = worktreeRecord.worktreePath;
1688
+ }
1689
+ else if (staticPreview) {
1690
+ tmpStagingDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), `rivet-variant-${args.workItemId}-`));
1691
+ fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'index.html'), staticPreview.html, 'utf8');
1692
+ fs_1.default.writeFileSync(path_1.default.join(tmpStagingDir, 'brief.md'), `# ${input.briefLabel}\n\n${input.briefBody}\n`, 'utf8');
1693
+ sourceDir = tmpStagingDir;
1694
+ }
1695
+ if (!sourceDir) {
1696
+ log.warn(`persistCompletedFreshVariant: no source for ${args.workItemId} (session ${args.sessionId}); skipping`);
1697
+ return;
1698
+ }
1699
+ // For static_preview, only `index.html` is a real deliverable — `brief.md`
1700
+ // is implementation-detail staging that we copy alongside it. Hardcode
1701
+ // the count so the history row matches what `commit_variant` reports
1702
+ // (always 1) instead of double-counting the brief.
1703
+ const changedFilesCount = staticPreview ? 1 : countWorktreeFiles(sourceDir);
1704
+ const preview = staticPreview
1705
+ ? { kind: 'static', entryPath: 'index.html' }
1706
+ : {
1707
+ kind: 'dev-server',
1708
+ command: {
1709
+ cwd: '.',
1710
+ packageManager: 'npm',
1711
+ script: 'dev',
1712
+ },
1713
+ };
1714
+ try {
1715
+ await this.variantHistory.persistVariant({
1716
+ projectPath: historyProjectPath,
1717
+ sessionId: args.sessionId,
1718
+ variantId: args.workItemId,
1719
+ label: input.briefLabel,
1720
+ brief: input.briefBody,
1721
+ sessionPrompt,
1722
+ kind: 'project-created',
1723
+ sourceDir,
1724
+ preview,
1725
+ changedFilesCount,
1726
+ projectKind: 'fresh',
1727
+ designMarkdown: designArtifact?.markdown,
1728
+ designSource: designArtifact?.source,
1729
+ });
1730
+ }
1731
+ finally {
1732
+ if (tmpStagingDir) {
1733
+ try {
1734
+ fs_1.default.rmSync(tmpStagingDir, { recursive: true, force: true });
1735
+ }
1736
+ catch (err) {
1737
+ log.warn(`Failed to clean up variant staging dir ${tmpStagingDir}: ${err instanceof Error ? err.message : String(err)}`);
1738
+ }
1739
+ }
1740
+ }
1741
+ }
1742
+ /**
1743
+ * At commit time, flip persisted variant manifests to terminal statuses
1744
+ * ('committed' for the chosen, 'rejected' for the rest). Both existing- and
1745
+ * fresh-project variants have already been persisted at code_gen success
1746
+ * time, so this is a pure status update — no source-dir copy required.
1747
+ * Also records the destination path on the chosen variant so history can
1748
+ * surface "this variant became <path>".
1749
+ */
1750
+ async persistVariantHistoryAtCommit(args) {
1530
1751
  const variants = this.store.getVariants(args.sessionId);
1531
- const matchingVariants = variants.filter((variant) => {
1532
- const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1533
- return (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel ?? '') === baseSlug;
1752
+ for (const variant of variants) {
1753
+ if (variant.status !== 'succeeded')
1754
+ continue;
1755
+ const workItemId = variant.workItemId;
1756
+ const isChosen = workItemId === args.chosenVariantId;
1757
+ const status = isChosen
1758
+ ? 'committed'
1759
+ : 'rejected';
1760
+ await this.variantHistory.markStatus({
1761
+ projectPath: args.projectPath,
1762
+ sessionId: args.sessionId,
1763
+ variantId: workItemId,
1764
+ status,
1765
+ destinationPath: isChosen ? args.destinationPath : undefined,
1766
+ runnablePath: args.runnablePaths?.get(workItemId),
1767
+ });
1768
+ }
1769
+ }
1770
+ /**
1771
+ * Flip persisted variant manifests to status='cancelled' for every variant
1772
+ * in the session that was already snapshotted to disk. Fresh- and existing-
1773
+ * project variants both persist at success time now, so both need their
1774
+ * manifests flipped here. Best-effort — missing manifests are silently
1775
+ * skipped by VariantHistoryService.markStatus.
1776
+ */
1777
+ async markPersistedVariantsCancelled(sessionId) {
1778
+ if (!this.store.hasSession(sessionId))
1779
+ return;
1780
+ const projectPath = await this.resolveHistoryProjectPath(sessionId);
1781
+ if (!projectPath)
1782
+ return;
1783
+ const variants = this.store.getVariants(sessionId);
1784
+ for (const variant of variants) {
1785
+ await this.variantHistory.markStatus({
1786
+ projectPath,
1787
+ sessionId,
1788
+ variantId: variant.workItemId,
1789
+ status: 'cancelled',
1790
+ });
1791
+ }
1792
+ }
1793
+ async markPersistedVariantCancelled(sessionId, variantId) {
1794
+ if (!this.store.hasSession(sessionId))
1795
+ return;
1796
+ const projectPath = await this.resolveHistoryProjectPath(sessionId);
1797
+ if (!projectPath)
1798
+ return;
1799
+ await this.variantHistory.markStatus({
1800
+ projectPath,
1801
+ sessionId,
1802
+ variantId,
1803
+ status: 'cancelled',
1534
1804
  });
1535
- const index = matchingVariants.findIndex((variant) => variant.workItemId === args.variantId);
1536
- if (index <= 0) {
1537
- return baseSlug;
1805
+ }
1806
+ /**
1807
+ * Resolve the project path that owns `.rivet/variants/` for a session.
1808
+ * Existing sessions: the user's project (via `resolveEnv`). Fresh sessions:
1809
+ * the *workspace root*, which is the parent of `workspacePath` — variants
1810
+ * accumulate there across sessions instead of being scattered under each
1811
+ * subproject. Must match what `persistCompletedFreshVariant` writes to.
1812
+ */
1813
+ async resolveHistoryProjectPath(sessionId) {
1814
+ const projectContext = this.store.getProjectContext(sessionId);
1815
+ if (projectContext.kind === 'fresh') {
1816
+ return projectContext.workspaceRoot;
1817
+ }
1818
+ try {
1819
+ const env = await this.resolveEnv(sessionId);
1820
+ return env.projectPath;
1821
+ }
1822
+ catch (err) {
1823
+ log.warn(`resolveHistoryProjectPath: resolveEnv failed for ${sessionId}`, err);
1824
+ return null;
1538
1825
  }
1539
- return `${baseSlug}-${index + 1}`;
1540
1826
  }
1541
1827
  readManifest(manifestPath) {
1542
1828
  if (!fs_1.default.existsSync(manifestPath)) {
@@ -1597,8 +1883,9 @@ class AgentVariantsOrchestrator {
1597
1883
  */
1598
1884
  preserveUnchosenVariants(args) {
1599
1885
  const resources = this.resources.get(args.sessionId);
1886
+ const runnablePaths = new Map();
1600
1887
  if (!resources)
1601
- return;
1888
+ return runnablePaths;
1602
1889
  const destinationParent = path_1.default.dirname(args.destinationPath);
1603
1890
  const projectSlug = path_1.default.basename(args.destinationPath);
1604
1891
  const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
@@ -1617,6 +1904,7 @@ class AgentVariantsOrchestrator {
1617
1904
  const folderName = `${numericPrefix}-${slug}`;
1618
1905
  if (variant.workItemId === args.chosenVariantId) {
1619
1906
  chosenSlug = slug;
1907
+ runnablePaths.set(variant.workItemId, projectSlug);
1620
1908
  manifestEntries.push({
1621
1909
  variantId: variant.workItemId,
1622
1910
  label,
@@ -1682,6 +1970,7 @@ class AgentVariantsOrchestrator {
1682
1970
  // Update the in-memory record so teardown doesn't try to operate on
1683
1971
  // the stale path.
1684
1972
  record.worktreePath = newPath;
1973
+ runnablePaths.set(variant.workItemId, `${path_1.default.basename(historyDir)}/${folderName}`);
1685
1974
  manifestEntries.push({
1686
1975
  variantId: variant.workItemId,
1687
1976
  label,
@@ -1716,6 +2005,7 @@ class AgentVariantsOrchestrator {
1716
2005
  log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
1717
2006
  }
1718
2007
  resources.vitePreservedSiblings = true;
2008
+ return runnablePaths;
1719
2009
  }
1720
2010
  /**
1721
2011
  * Rename `sourceWorktreePath` into `destinationPath`, then replace the
@@ -1995,11 +2285,33 @@ function parseStaticPreviewOutput(output) {
1995
2285
  };
1996
2286
  }
1997
2287
  function buildStaticPreviewDocument(input) {
1998
- if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
1999
- return input.html;
2000
- }
2001
2288
  const style = input.css ? `<style>\n${input.css}\n</style>` : '';
2002
2289
  const script = input.js ? `<script>\n${input.js}\n</script>` : '';
2290
+ if (/<!doctype html>|<html[\s>]/i.test(input.html)) {
2291
+ // Full document: inject css before </head> and js before </body>. The
2292
+ // agent often passes a complete `<!doctype html>...` blob with css/js
2293
+ // alongside; without this they're silently dropped and the variant ships
2294
+ // unstyled and non-interactive. Falls back to appending to the end if
2295
+ // the closing tag isn't found.
2296
+ let doc = input.html;
2297
+ if (style) {
2298
+ if (/<\/head>/i.test(doc)) {
2299
+ doc = doc.replace(/<\/head>/i, () => `${style}\n</head>`);
2300
+ }
2301
+ else {
2302
+ doc += `\n${style}`;
2303
+ }
2304
+ }
2305
+ if (script) {
2306
+ if (/<\/body>/i.test(doc)) {
2307
+ doc = doc.replace(/<\/body>/i, () => `${script}\n</body>`);
2308
+ }
2309
+ else {
2310
+ doc += `\n${script}`;
2311
+ }
2312
+ }
2313
+ return doc;
2314
+ }
2003
2315
  return `<!doctype html>
2004
2316
  <html lang="en">
2005
2317
  <head>
@@ -2061,6 +2373,7 @@ const toActiveProjectContext = (projectContext) => {
2061
2373
  return {
2062
2374
  kind: 'fresh',
2063
2375
  workspacePath: projectContext.workspacePath,
2376
+ workspaceRoot: projectContext.workspaceRoot,
2064
2377
  framework: projectContext.framework,
2065
2378
  designContext: projectContext.designContext?.map((entry) => entry.kind === 'slug'
2066
2379
  ? { kind: 'slug', slug: entry.slug }
@@ -2090,15 +2403,26 @@ const toActiveProjectContext = (projectContext) => {
2090
2403
  * Resolve the user-facing supporting artifacts for a session.
2091
2404
  *
2092
2405
  * For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
2093
- * turned into a `design_context` artifact carrying the full DESIGN.md
2094
- * markdown:
2406
+ * turned into a `design_context` artifact that links to Rivet-hosted
2407
+ * DESIGN.md views:
2095
2408
  * - `slug` entries resolve bundled catalog markdown via the design catalog.
2096
2409
  * - `markdown` entries (Agent Browser / inspiration extractor output) carry
2097
- * their stored markdown verbatim.
2410
+ * their stored markdown when the linked route is requested.
2098
2411
  * Slots whose markdown can't be resolved are skipped so the UI never renders
2099
2412
  * a metadata-only DESIGN.md row.
2100
2413
  */
2101
- const buildSessionArtifacts = (projectContext) => {
2414
+ const buildSessionArtifacts = (sessionId, projectContext) => {
2415
+ const artifacts = buildResolvedDesignContextArtifacts(projectContext);
2416
+ return artifacts.map(({ content: _content, ...artifact }) => ({
2417
+ ...artifact,
2418
+ rawUrl: buildDesignContextRawUrl(sessionId, artifact.id),
2419
+ viewUrl: buildDesignContextViewUrl(sessionId, artifact.id),
2420
+ }));
2421
+ };
2422
+ const findDesignContextArtifact = (projectContext, artifactId) => {
2423
+ return buildResolvedDesignContextArtifacts(projectContext).find((artifact) => artifact.id === artifactId);
2424
+ };
2425
+ const buildResolvedDesignContextArtifacts = (projectContext) => {
2102
2426
  if (projectContext.kind !== 'fresh')
2103
2427
  return [];
2104
2428
  const designContext = projectContext.designContext;
@@ -2117,7 +2441,9 @@ const buildSessionArtifacts = (projectContext) => {
2117
2441
  id: `design_context:${slot}:${entry.slug}`,
2118
2442
  kind: 'design_context',
2119
2443
  label: catalogEntry?.name ?? entry.slug,
2120
- ...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
2444
+ ...(catalogEntry?.description
2445
+ ? { summary: catalogEntry.description }
2446
+ : {}),
2121
2447
  status: 'ready',
2122
2448
  source: 'static',
2123
2449
  contentType: 'text/markdown',
@@ -2149,8 +2475,6 @@ const buildSessionArtifacts = (projectContext) => {
2149
2475
  });
2150
2476
  };
2151
2477
  const addDesignContextArtifact = (artifactsByContent, artifact) => {
2152
- if (!artifact.content)
2153
- return;
2154
2478
  const existing = artifactsByContent.get(artifact.content);
2155
2479
  if (existing) {
2156
2480
  existing.usedByVariantCount += 1;
@@ -2161,6 +2485,340 @@ const addDesignContextArtifact = (artifactsByContent, artifact) => {
2161
2485
  usedByVariantCount: 1,
2162
2486
  });
2163
2487
  };
2488
+ const buildDesignContextRawUrl = (sessionId, artifactId) => {
2489
+ return `/api/variants/${encodeURIComponent(sessionId)}/${DESIGN_CONTEXT_ROUTE_SEGMENT}/${encodeURIComponent(artifactId)}`;
2490
+ };
2491
+ const buildDesignContextViewUrl = (sessionId, artifactId) => {
2492
+ return `${buildDesignContextRawUrl(sessionId, artifactId)}/${DESIGN_CONTEXT_VIEW_SEGMENT}`;
2493
+ };
2494
+ const buildDesignContextViewerDocument = (artifact) => {
2495
+ const title = `${artifact.label} DESIGN.md`;
2496
+ const visualHtml = renderDesignMarkdown(artifact.content);
2497
+ const rawMarkdown = escapeHtml(artifact.content);
2498
+ return `<!doctype html>
2499
+ <html lang="en">
2500
+ <head>
2501
+ <meta charset="utf-8" />
2502
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2503
+ <title>${escapeHtml(title)}</title>
2504
+ <style>
2505
+ :root {
2506
+ color-scheme: light;
2507
+ --ink: #201b16;
2508
+ --muted: #75695e;
2509
+ --paper: #fbf7ef;
2510
+ --panel: #fffdf8;
2511
+ --rule: #eadfcf;
2512
+ --accent: #e45d2f;
2513
+ --accent-soft: #ffe0d3;
2514
+ --code: #2a2521;
2515
+ }
2516
+ * { box-sizing: border-box; }
2517
+ body {
2518
+ margin: 0;
2519
+ min-height: 100vh;
2520
+ background:
2521
+ radial-gradient(circle at top left, rgba(228, 93, 47, 0.18), transparent 36rem),
2522
+ linear-gradient(135deg, #fbf7ef 0%, #f2eadc 100%);
2523
+ color: var(--ink);
2524
+ font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
2525
+ }
2526
+ main {
2527
+ width: min(1440px, calc(100vw - 40px));
2528
+ margin: 0 auto;
2529
+ padding: 40px 0;
2530
+ }
2531
+ header {
2532
+ display: flex;
2533
+ align-items: center;
2534
+ justify-content: space-between;
2535
+ gap: 24px;
2536
+ margin-bottom: 24px;
2537
+ border-bottom: 1px solid var(--rule);
2538
+ padding-bottom: 18px;
2539
+ }
2540
+ h1 {
2541
+ margin: 0;
2542
+ max-width: 780px;
2543
+ font-size: clamp(2rem, 5vw, 4.5rem);
2544
+ letter-spacing: -0.06em;
2545
+ line-height: 0.92;
2546
+ }
2547
+ .mode-input {
2548
+ position: absolute;
2549
+ opacity: 0;
2550
+ pointer-events: none;
2551
+ }
2552
+ .mode-toggle {
2553
+ display: inline-flex;
2554
+ flex-shrink: 0;
2555
+ gap: 4px;
2556
+ border: 1px solid var(--rule);
2557
+ border-radius: 999px;
2558
+ background: rgba(255, 253, 248, 0.72);
2559
+ padding: 4px;
2560
+ box-shadow: 0 12px 30px rgba(61, 44, 26, 0.08);
2561
+ }
2562
+ .mode-toggle label {
2563
+ position: relative;
2564
+ cursor: pointer;
2565
+ border-radius: 999px;
2566
+ padding: 8px 14px;
2567
+ color: var(--muted);
2568
+ font: 600 0.74rem/1.2 ui-sans-serif, system-ui, sans-serif;
2569
+ letter-spacing: 0.12em;
2570
+ text-transform: uppercase;
2571
+ transition:
2572
+ background 160ms ease,
2573
+ color 160ms ease;
2574
+ }
2575
+ .mode-toggle label:has(input:checked) {
2576
+ background: var(--ink);
2577
+ color: var(--paper);
2578
+ }
2579
+ .viewer {
2580
+ display: block;
2581
+ }
2582
+ .viewer-panel {
2583
+ display: none;
2584
+ }
2585
+ main:has(#designmd-visual-mode:checked) .visual-panel,
2586
+ main:has(#designmd-raw-mode:checked) .raw-panel {
2587
+ display: block;
2588
+ }
2589
+ .panel {
2590
+ overflow: hidden;
2591
+ border: 1px solid var(--rule);
2592
+ border-radius: 18px;
2593
+ background: color-mix(in srgb, var(--panel) 92%, white);
2594
+ box-shadow: 0 20px 60px rgba(61, 44, 26, 0.12);
2595
+ }
2596
+ .panel-title {
2597
+ display: flex;
2598
+ align-items: center;
2599
+ justify-content: space-between;
2600
+ border-bottom: 1px solid var(--rule);
2601
+ padding: 12px 16px;
2602
+ color: var(--muted);
2603
+ font: 700 0.72rem/1 ui-sans-serif, system-ui, sans-serif;
2604
+ letter-spacing: 0.12em;
2605
+ text-transform: uppercase;
2606
+ }
2607
+ .visual {
2608
+ padding: 24px;
2609
+ }
2610
+ .visual h1,
2611
+ .visual h2,
2612
+ .visual h3 {
2613
+ margin: 1.3em 0 0.45em;
2614
+ letter-spacing: -0.04em;
2615
+ line-height: 1;
2616
+ }
2617
+ .visual h1:first-child,
2618
+ .visual h2:first-child,
2619
+ .visual h3:first-child {
2620
+ margin-top: 0;
2621
+ }
2622
+ .visual h1 { font-size: 2.25rem; }
2623
+ .visual h2 { font-size: 1.6rem; }
2624
+ .visual h3 { font-size: 1.18rem; }
2625
+ .visual p,
2626
+ .visual li,
2627
+ .visual blockquote {
2628
+ color: #3d342c;
2629
+ font-size: 1rem;
2630
+ line-height: 1.62;
2631
+ }
2632
+ .visual ul {
2633
+ display: grid;
2634
+ gap: 8px;
2635
+ padding-left: 1.1rem;
2636
+ }
2637
+ .visual blockquote {
2638
+ margin: 18px 0;
2639
+ border-left: 4px solid var(--accent);
2640
+ padding-left: 14px;
2641
+ color: var(--muted);
2642
+ }
2643
+ .visual code {
2644
+ border-radius: 6px;
2645
+ background: var(--accent-soft);
2646
+ padding: 0.12rem 0.34rem;
2647
+ font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
2648
+ font-size: 0.92em;
2649
+ }
2650
+ pre {
2651
+ margin: 0;
2652
+ max-height: calc(100vh - 190px);
2653
+ overflow: auto;
2654
+ background: #181512;
2655
+ color: #f9ead7;
2656
+ padding: 20px;
2657
+ font: 0.78rem/1.55 ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
2658
+ white-space: pre-wrap;
2659
+ word-break: break-word;
2660
+ }
2661
+ @media (max-width: 900px) {
2662
+ main { width: min(100vw - 24px, 760px); padding: 24px 0; }
2663
+ header { align-items: start; flex-direction: column; }
2664
+ pre { max-height: 520px; }
2665
+ }
2666
+ </style>
2667
+ </head>
2668
+ <body>
2669
+ <main>
2670
+ <header>
2671
+ <h1>${escapeHtml(title)}</h1>
2672
+ <div class="mode-toggle" aria-label="DesignMD view mode">
2673
+ <label>
2674
+ <input class="mode-input" type="radio" name="designmd-view-mode" id="designmd-visual-mode" checked />
2675
+ <span>Visual</span>
2676
+ </label>
2677
+ <label>
2678
+ <input class="mode-input" type="radio" name="designmd-view-mode" id="designmd-raw-mode" />
2679
+ <span>Raw</span>
2680
+ </label>
2681
+ </div>
2682
+ </header>
2683
+ <section class="viewer" aria-label="DesignMD artifact">
2684
+ <article class="panel viewer-panel visual-panel" id="designmd-visual">
2685
+ <div class="panel-title"><span>Visual</span><span>Rendered DESIGN.md</span></div>
2686
+ <div class="visual">${visualHtml}</div>
2687
+ </article>
2688
+ <article class="panel viewer-panel raw-panel" id="designmd-raw">
2689
+ <div class="panel-title"><span>Raw</span><span>Markdown source</span></div>
2690
+ <pre>${rawMarkdown}</pre>
2691
+ </article>
2692
+ </section>
2693
+ </main>
2694
+ </body>
2695
+ </html>`;
2696
+ };
2697
+ const renderDesignMarkdown = (markdown) => {
2698
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n');
2699
+ const output = [];
2700
+ const paragraph = [];
2701
+ const listItems = [];
2702
+ const codeLines = [];
2703
+ let isCodeBlock = false;
2704
+ const flushParagraph = () => {
2705
+ if (paragraph.length === 0)
2706
+ return;
2707
+ output.push(`<p>${renderInlineMarkdown(paragraph.join(' '))}</p>`);
2708
+ paragraph.length = 0;
2709
+ };
2710
+ const flushList = () => {
2711
+ if (listItems.length === 0)
2712
+ return;
2713
+ output.push(`<ul>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</ul>`);
2714
+ listItems.length = 0;
2715
+ };
2716
+ const flushCode = () => {
2717
+ if (codeLines.length === 0)
2718
+ return;
2719
+ output.push(`<pre>${escapeHtml(codeLines.join('\n'))}</pre>`);
2720
+ codeLines.length = 0;
2721
+ };
2722
+ lines.forEach((line) => {
2723
+ if (line.trim().startsWith('```')) {
2724
+ if (isCodeBlock) {
2725
+ flushCode();
2726
+ isCodeBlock = false;
2727
+ return;
2728
+ }
2729
+ flushParagraph();
2730
+ flushList();
2731
+ isCodeBlock = true;
2732
+ return;
2733
+ }
2734
+ if (isCodeBlock) {
2735
+ codeLines.push(line);
2736
+ return;
2737
+ }
2738
+ const trimmed = line.trim();
2739
+ if (!trimmed) {
2740
+ flushParagraph();
2741
+ flushList();
2742
+ return;
2743
+ }
2744
+ if (/^---+$/.test(trimmed)) {
2745
+ flushParagraph();
2746
+ flushList();
2747
+ output.push('<hr />');
2748
+ return;
2749
+ }
2750
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
2751
+ if (headingMatch) {
2752
+ flushParagraph();
2753
+ flushList();
2754
+ const level = Math.min(headingMatch[1].length, 3);
2755
+ output.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
2756
+ return;
2757
+ }
2758
+ const listMatch = trimmed.match(/^[-*]\s+(.+)$/);
2759
+ if (listMatch) {
2760
+ flushParagraph();
2761
+ listItems.push(listMatch[1]);
2762
+ return;
2763
+ }
2764
+ const quoteMatch = trimmed.match(/^>\s?(.+)$/);
2765
+ if (quoteMatch) {
2766
+ flushParagraph();
2767
+ flushList();
2768
+ output.push(`<blockquote>${renderInlineMarkdown(quoteMatch[1])}</blockquote>`);
2769
+ return;
2770
+ }
2771
+ paragraph.push(trimmed);
2772
+ });
2773
+ flushCode();
2774
+ flushParagraph();
2775
+ flushList();
2776
+ return output.join('\n');
2777
+ };
2778
+ const renderInlineMarkdown = (value) => {
2779
+ return escapeHtml(value)
2780
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
2781
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
2782
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>');
2783
+ };
2784
+ const escapeHtml = (value) => {
2785
+ return value
2786
+ .replace(/&/g, '&amp;')
2787
+ .replace(/</g, '&lt;')
2788
+ .replace(/>/g, '&gt;')
2789
+ .replace(/"/g, '&quot;')
2790
+ .replace(/'/g, '&#39;');
2791
+ };
2792
+ /**
2793
+ * Resolve a per-variant design context entry into the raw DESIGN.md markdown
2794
+ * the worktree scaffolder writes, plus a small `designSource` descriptor for
2795
+ * the variant manifest. Slug entries resolve through the bundled catalog;
2796
+ * markdown entries (Agent Browser / inspiration extractor output) carry their
2797
+ * stored markdown verbatim. Returns null when the entry is missing or the
2798
+ * slug doesn't resolve to bundled markdown.
2799
+ */
2800
+ const resolveDesignArtifact = (entry) => {
2801
+ if (!entry)
2802
+ return null;
2803
+ if (entry.kind === 'markdown') {
2804
+ return {
2805
+ markdown: entry.content,
2806
+ source: { kind: 'markdown', label: entry.label },
2807
+ };
2808
+ }
2809
+ const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
2810
+ if (!markdown)
2811
+ return null;
2812
+ const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
2813
+ return {
2814
+ markdown,
2815
+ source: {
2816
+ kind: 'slug',
2817
+ slug: entry.slug,
2818
+ label: catalogEntry?.name ?? entry.slug,
2819
+ },
2820
+ };
2821
+ };
2164
2822
  const summarizeDesignContext = (designContext) => {
2165
2823
  if (!designContext)
2166
2824
  return null;