scene-capability-engine 3.3.18 → 3.3.21

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.
@@ -3,6 +3,7 @@ const crypto = require('crypto');
3
3
  const { spawnSync } = require('child_process');
4
4
  const fs = require('fs-extra');
5
5
  const chalk = require('chalk');
6
+ const { SessionStore } = require('../runtime/session-store');
6
7
 
7
8
  const STUDIO_JOB_API_VERSION = 'sce.studio.job/v0.1';
8
9
  const STAGE_ORDER = ['plan', 'generate', 'apply', 'verify', 'release'];
@@ -101,11 +102,90 @@ function normalizeGateStep(step) {
101
102
  };
102
103
  }
103
104
 
105
+ function createGateFailureFingerprint(failure = {}, context = {}) {
106
+ const basis = JSON.stringify({
107
+ stage: normalizeString(context.stage),
108
+ profile: normalizeString(context.profile),
109
+ job_id: normalizeString(context.job_id),
110
+ step_id: normalizeString(failure.id),
111
+ command: normalizeString(failure.command),
112
+ exit_code: Number.isFinite(Number(failure.exit_code)) ? Number(failure.exit_code) : null,
113
+ skip_reason: normalizeString(failure.skip_reason),
114
+ stderr: normalizeString(failure?.output?.stderr || '').slice(0, 400)
115
+ });
116
+ const digest = crypto.createHash('sha1').update(basis).digest('hex').slice(0, 16);
117
+ return `studio-gate-${digest}`;
118
+ }
119
+
120
+ async function autoRecordGateFailure(failure = {}, context = {}, dependencies = {}) {
121
+ if (dependencies.autoRecordFailures === false) {
122
+ return null;
123
+ }
124
+
125
+ try {
126
+ const { runErrorbookRecordCommand } = require('./errorbook');
127
+ const stage = normalizeString(context.stage) || 'verify';
128
+ const profile = normalizeString(context.profile) || 'standard';
129
+ const jobId = normalizeString(context.job_id);
130
+ const stepId = normalizeString(failure.id) || normalizeString(failure.name) || 'unknown-step';
131
+ const commandText = normalizeString(failure.command) || 'n/a';
132
+ const skipReason = normalizeString(failure.skip_reason);
133
+ const stderr = normalizeString(failure?.output?.stderr || '');
134
+ const errorText = normalizeString(failure?.output?.error || '');
135
+ const symptom = skipReason
136
+ ? `Required studio ${stage} gate step "${stepId}" is unavailable: ${skipReason}.`
137
+ : `Required studio ${stage} gate step "${stepId}" failed (exit=${failure.exit_code ?? 'n/a'}).`;
138
+ const rootCause = skipReason
139
+ ? `Gate dependency missing or disabled for studio ${stage} profile ${profile}; remediation required before release.`
140
+ : `Gate command execution failure in studio ${stage} profile ${profile}; root cause analysis pending.`;
141
+ const fixActions = [
142
+ `Inspect failed gate step ${stepId} in studio ${stage} stage.`,
143
+ `Rerun gate command: ${commandText}`
144
+ ];
145
+ if (stderr) {
146
+ fixActions.push(`Analyze stderr signal: ${stderr.slice(0, 200)}`);
147
+ } else if (errorText) {
148
+ fixActions.push(`Analyze runtime error signal: ${errorText.slice(0, 200)}`);
149
+ }
150
+
151
+ const title = `[studio:${stage}] gate failure: ${stepId}`;
152
+ const tags = ['studio', 'gate-failure', 'release-blocker', `stage-${stage}`];
153
+ const fingerprint = createGateFailureFingerprint(failure, context);
154
+ const specRef = normalizeString(context.scene_id) || jobId;
155
+
156
+ const result = await runErrorbookRecordCommand({
157
+ title,
158
+ symptom,
159
+ rootCause,
160
+ fixAction: fixActions,
161
+ tags: tags.join(','),
162
+ ontology: 'execution_flow,decision_policy',
163
+ status: 'candidate',
164
+ fingerprint,
165
+ spec: specRef,
166
+ notes: `auto-captured from studio ${stage} gate`
167
+ }, {
168
+ projectPath: dependencies.projectPath || process.cwd(),
169
+ fileSystem: dependencies.fileSystem || fs
170
+ });
171
+
172
+ return {
173
+ errorbook_entry_id: result && result.entry ? result.entry.id : null,
174
+ fingerprint
175
+ };
176
+ } catch (_error) {
177
+ return null;
178
+ }
179
+ }
180
+
104
181
  async function executeGateSteps(steps, dependencies = {}) {
105
182
  const runner = dependencies.commandRunner || defaultCommandRunner;
106
183
  const projectPath = dependencies.projectPath || process.cwd();
107
184
  const env = dependencies.env || process.env;
108
185
  const failOnRequiredSkip = dependencies.failOnRequiredSkip === true;
186
+ const onFailure = typeof dependencies.onFailure === 'function'
187
+ ? dependencies.onFailure
188
+ : null;
109
189
 
110
190
  const normalizedSteps = Array.isArray(steps) ? steps.map((step) => normalizeGateStep(step)) : [];
111
191
  const results = [];
@@ -128,6 +208,14 @@ async function executeGateSteps(steps, dependencies = {}) {
128
208
  ? { stdout: '', stderr: '', error: 'required gate step disabled under strict profile' }
129
209
  : undefined
130
210
  });
211
+ if (skippedAsFailure && onFailure) {
212
+ const failure = results[results.length - 1];
213
+ await Promise.resolve(onFailure({
214
+ reason: 'required_skip',
215
+ step,
216
+ failure
217
+ })).catch(() => {});
218
+ }
131
219
  continue;
132
220
  }
133
221
 
@@ -163,6 +251,14 @@ async function executeGateSteps(steps, dependencies = {}) {
163
251
 
164
252
  if (!passed && step.required) {
165
253
  hasFailure = true;
254
+ if (onFailure) {
255
+ const failure = results[results.length - 1];
256
+ await Promise.resolve(onFailure({
257
+ reason: 'command_failed',
258
+ step,
259
+ failure
260
+ })).catch(() => {});
261
+ }
166
262
  }
167
263
  }
168
264
 
@@ -345,6 +441,30 @@ async function buildReleaseGateSteps(options = {}, dependencies = {}) {
345
441
  required: true
346
442
  });
347
443
 
444
+ const gitManagedGateScript = path.join(projectPath, 'scripts', 'git-managed-gate.js');
445
+ const hasGitManagedGateScript = await fileSystem.pathExists(gitManagedGateScript);
446
+ steps.push({
447
+ id: 'git-managed-gate',
448
+ name: 'git managed release gate',
449
+ command: 'node',
450
+ args: ['scripts/git-managed-gate.js', '--fail-on-violation', '--json'],
451
+ required: true,
452
+ enabled: hasGitManagedGateScript,
453
+ skip_reason: hasGitManagedGateScript ? '' : 'scripts/git-managed-gate.js not found'
454
+ });
455
+
456
+ const errorbookReleaseGateScript = path.join(projectPath, 'scripts', 'errorbook-release-gate.js');
457
+ const hasErrorbookReleaseGateScript = await fileSystem.pathExists(errorbookReleaseGateScript);
458
+ steps.push({
459
+ id: 'errorbook-release-gate',
460
+ name: 'errorbook release gate',
461
+ command: 'node',
462
+ args: ['scripts/errorbook-release-gate.js', '--fail-on-block', '--json'],
463
+ required: true,
464
+ enabled: hasErrorbookReleaseGateScript,
465
+ skip_reason: hasErrorbookReleaseGateScript ? '' : 'scripts/errorbook-release-gate.js not found'
466
+ });
467
+
348
468
  const weeklySummaryPath = path.join(projectPath, '.sce', 'reports', 'release-evidence', 'release-ops-weekly-summary.json');
349
469
  const hasWeeklySummary = await fileSystem.pathExists(weeklySummaryPath);
350
470
  steps.push({
@@ -534,14 +654,16 @@ function buildProgress(job) {
534
654
  }
535
655
 
536
656
  function resolveNextAction(job) {
657
+ const sceneRef = normalizeString(job && job.scene && job.scene.id);
658
+ const planSceneArg = sceneRef || '<scene-id>';
537
659
  if (job.status === 'rolled_back') {
538
- return 'sce studio plan --from-chat <session>';
660
+ return `sce studio plan --scene ${planSceneArg} --from-chat <session>`;
539
661
  }
540
662
  if (!job.stages.plan || job.stages.plan.status !== 'completed') {
541
- return `sce studio plan --from-chat <session> --job ${job.job_id}`;
663
+ return `sce studio plan --scene ${planSceneArg} --from-chat <session> --job ${job.job_id}`;
542
664
  }
543
665
  if (!job.stages.generate || job.stages.generate.status !== 'completed') {
544
- return `sce studio generate --scene <scene-id> --job ${job.job_id}`;
666
+ return `sce studio generate --job ${job.job_id}`;
545
667
  }
546
668
  if (!job.stages.apply || job.stages.apply.status !== 'completed') {
547
669
  const patchBundleId = job.artifacts.patch_bundle_id || '<patch-bundle-id>';
@@ -613,10 +735,14 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
613
735
  const projectPath = dependencies.projectPath || process.cwd();
614
736
  const fileSystem = dependencies.fileSystem || fs;
615
737
  const fromChat = normalizeString(options.fromChat);
738
+ const sceneId = normalizeString(options.scene);
616
739
 
617
740
  if (!fromChat) {
618
741
  throw new Error('--from-chat is required');
619
742
  }
743
+ if (!sceneId) {
744
+ throw new Error('--scene is required');
745
+ }
620
746
 
621
747
  const paths = resolveStudioPaths(projectPath);
622
748
  await ensureStudioDirectories(paths, fileSystem);
@@ -624,11 +750,20 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
624
750
  const jobId = normalizeString(options.job) || createJobId();
625
751
  const now = nowIso();
626
752
  const stages = createStageState();
753
+ const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
754
+ const sceneSessionBinding = await sessionStore.beginSceneSession({
755
+ sceneId,
756
+ objective: normalizeString(options.goal) || `Studio scene cycle for ${sceneId}`,
757
+ tool: normalizeString(options.tool) || 'generic'
758
+ });
627
759
  stages.plan = {
628
760
  status: 'completed',
629
761
  completed_at: now,
630
762
  metadata: {
631
- from_chat: fromChat
763
+ from_chat: fromChat,
764
+ scene_id: sceneId,
765
+ scene_session_id: sceneSessionBinding.session.session_id,
766
+ scene_cycle: sceneSessionBinding.scene_cycle
632
767
  }
633
768
  };
634
769
 
@@ -643,7 +778,14 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
643
778
  goal: normalizeString(options.goal) || null
644
779
  },
645
780
  scene: {
646
- id: null
781
+ id: sceneId
782
+ },
783
+ session: {
784
+ policy: 'mandatory.scene-primary',
785
+ scene_id: sceneId,
786
+ scene_session_id: sceneSessionBinding.session.session_id,
787
+ scene_cycle: sceneSessionBinding.scene_cycle,
788
+ created_new_scene_session: sceneSessionBinding.created_new === true
647
789
  },
648
790
  target: normalizeString(options.target) || 'default',
649
791
  stages,
@@ -657,6 +799,9 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
657
799
  await saveJob(paths, job, fileSystem);
658
800
  await appendStudioEvent(paths, job, 'stage.plan.completed', {
659
801
  from_chat: fromChat,
802
+ scene_id: sceneId,
803
+ scene_session_id: sceneSessionBinding.session.session_id,
804
+ scene_cycle: sceneSessionBinding.scene_cycle,
660
805
  target: job.target
661
806
  }, fileSystem);
662
807
  await writeLatestJob(paths, jobId, fileSystem);
@@ -669,22 +814,27 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
669
814
  async function runStudioGenerateCommand(options = {}, dependencies = {}) {
670
815
  const projectPath = dependencies.projectPath || process.cwd();
671
816
  const fileSystem = dependencies.fileSystem || fs;
672
- const sceneId = normalizeString(options.scene);
673
- if (!sceneId) {
674
- throw new Error('--scene is required');
675
- }
817
+ const sceneArg = normalizeString(options.scene);
676
818
 
677
819
  const paths = resolveStudioPaths(projectPath);
678
820
  await ensureStudioDirectories(paths, fileSystem);
679
821
  const latestJobId = await readLatestJob(paths, fileSystem);
680
822
  const jobId = resolveRequestedJobId(options, latestJobId);
681
823
  if (!jobId) {
682
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
824
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
683
825
  }
684
826
 
685
827
  const job = await loadJob(paths, jobId, fileSystem);
686
828
  ensureNotRolledBack(job, 'generate');
687
829
  ensureStagePrerequisite(job, 'generate', 'plan');
830
+ const jobSceneId = normalizeString(job && job.scene && job.scene.id);
831
+ if (!jobSceneId) {
832
+ throw new Error('Cannot run studio generate: scene is not defined in plan stage');
833
+ }
834
+ if (sceneArg && sceneArg !== jobSceneId) {
835
+ throw new Error(`Scene mismatch: planned scene is "${jobSceneId}" but --scene provided "${sceneArg}"`);
836
+ }
837
+ const sceneId = sceneArg || jobSceneId;
688
838
  const patchBundleId = normalizeString(options.patchBundle) || `patch-${sceneId}-${Date.now()}`;
689
839
 
690
840
  job.scene = job.scene || {};
@@ -723,7 +873,7 @@ async function runStudioApplyCommand(options = {}, dependencies = {}) {
723
873
  const latestJobId = await readLatestJob(paths, fileSystem);
724
874
  const jobId = resolveRequestedJobId(options, latestJobId);
725
875
  if (!jobId) {
726
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
876
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
727
877
  }
728
878
 
729
879
  const job = await loadJob(paths, jobId, fileSystem);
@@ -771,7 +921,7 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
771
921
  const latestJobId = await readLatestJob(paths, fileSystem);
772
922
  const jobId = resolveRequestedJobId(options, latestJobId);
773
923
  if (!jobId) {
774
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
924
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
775
925
  }
776
926
 
777
927
  const profile = normalizeString(options.profile) || 'standard';
@@ -781,6 +931,7 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
781
931
 
782
932
  const verifyReportPath = `${STUDIO_REPORTS_DIR}/verify-${job.job_id}.json`;
783
933
  const verifyStartedAt = nowIso();
934
+ const autoErrorbookRecords = [];
784
935
  const gateSteps = await buildVerifyGateSteps({ profile }, {
785
936
  projectPath,
786
937
  fileSystem
@@ -789,7 +940,26 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
789
940
  projectPath,
790
941
  commandRunner: dependencies.commandRunner,
791
942
  env: dependencies.env,
792
- failOnRequiredSkip: profile === 'strict'
943
+ failOnRequiredSkip: profile === 'strict',
944
+ onFailure: async ({ failure }) => {
945
+ const captured = await autoRecordGateFailure(failure, {
946
+ stage: 'verify',
947
+ profile,
948
+ job_id: job.job_id,
949
+ scene_id: job?.scene?.id
950
+ }, {
951
+ projectPath,
952
+ fileSystem,
953
+ autoRecordFailures: dependencies.autoRecordFailures
954
+ });
955
+ if (captured && captured.errorbook_entry_id) {
956
+ autoErrorbookRecords.push({
957
+ step_id: failure.id,
958
+ entry_id: captured.errorbook_entry_id,
959
+ fingerprint: captured.fingerprint
960
+ });
961
+ }
962
+ }
793
963
  });
794
964
  const verifyCompletedAt = nowIso();
795
965
  const verifyReport = {
@@ -800,7 +970,8 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
800
970
  started_at: verifyStartedAt,
801
971
  completed_at: verifyCompletedAt,
802
972
  passed: gateResult.passed,
803
- steps: gateResult.steps
973
+ steps: gateResult.steps,
974
+ auto_errorbook_records: autoErrorbookRecords
804
975
  };
805
976
 
806
977
  await writeStudioReport(projectPath, verifyReportPath, verifyReport, fileSystem);
@@ -817,13 +988,15 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
817
988
  metadata: {
818
989
  profile,
819
990
  passed: false,
820
- report: verifyReportPath
991
+ report: verifyReportPath,
992
+ auto_errorbook_records: autoErrorbookRecords
821
993
  }
822
994
  };
823
995
  await saveJob(paths, job, fileSystem);
824
996
  await appendStudioEvent(paths, job, 'stage.verify.failed', {
825
997
  profile,
826
- report: verifyReportPath
998
+ report: verifyReportPath,
999
+ auto_errorbook_records: autoErrorbookRecords
827
1000
  }, fileSystem);
828
1001
  await writeLatestJob(paths, jobId, fileSystem);
829
1002
  throw new Error(`studio verify failed: ${gateResult.steps.filter((step) => step.status === 'failed').map((step) => step.id).join(', ')}`);
@@ -833,14 +1006,16 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
833
1006
  ensureStageCompleted(job, 'verify', {
834
1007
  profile,
835
1008
  passed: true,
836
- report: verifyReportPath
1009
+ report: verifyReportPath,
1010
+ auto_errorbook_records: autoErrorbookRecords
837
1011
  });
838
1012
 
839
1013
  await saveJob(paths, job, fileSystem);
840
1014
  await appendStudioEvent(paths, job, 'stage.verify.completed', {
841
1015
  profile,
842
1016
  passed: true,
843
- report: verifyReportPath
1017
+ report: verifyReportPath,
1018
+ auto_errorbook_records: autoErrorbookRecords
844
1019
  }, fileSystem);
845
1020
  await writeLatestJob(paths, jobId, fileSystem);
846
1021
 
@@ -858,7 +1033,7 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
858
1033
  const latestJobId = await readLatestJob(paths, fileSystem);
859
1034
  const jobId = resolveRequestedJobId(options, latestJobId);
860
1035
  if (!jobId) {
861
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
1036
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
862
1037
  }
863
1038
 
864
1039
  const channel = normalizeString(options.channel) || 'dev';
@@ -880,6 +1055,7 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
880
1055
  const profile = normalizeString(options.profile) || 'standard';
881
1056
  const releaseReportPath = `${STUDIO_REPORTS_DIR}/release-${job.job_id}.json`;
882
1057
  const releaseStartedAt = nowIso();
1058
+ const autoErrorbookRecords = [];
883
1059
  const gateSteps = await buildReleaseGateSteps({ profile }, {
884
1060
  projectPath,
885
1061
  fileSystem
@@ -888,7 +1064,26 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
888
1064
  projectPath,
889
1065
  commandRunner: dependencies.commandRunner,
890
1066
  env: dependencies.env,
891
- failOnRequiredSkip: profile === 'strict'
1067
+ failOnRequiredSkip: profile === 'strict',
1068
+ onFailure: async ({ failure }) => {
1069
+ const captured = await autoRecordGateFailure(failure, {
1070
+ stage: 'release',
1071
+ profile,
1072
+ job_id: job.job_id,
1073
+ scene_id: job?.scene?.id
1074
+ }, {
1075
+ projectPath,
1076
+ fileSystem,
1077
+ autoRecordFailures: dependencies.autoRecordFailures
1078
+ });
1079
+ if (captured && captured.errorbook_entry_id) {
1080
+ autoErrorbookRecords.push({
1081
+ step_id: failure.id,
1082
+ entry_id: captured.errorbook_entry_id,
1083
+ fingerprint: captured.fingerprint
1084
+ });
1085
+ }
1086
+ }
892
1087
  });
893
1088
  const releaseCompletedAt = nowIso();
894
1089
  const releaseReport = {
@@ -901,7 +1096,8 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
901
1096
  started_at: releaseStartedAt,
902
1097
  completed_at: releaseCompletedAt,
903
1098
  passed: gateResult.passed,
904
- steps: gateResult.steps
1099
+ steps: gateResult.steps,
1100
+ auto_errorbook_records: autoErrorbookRecords
905
1101
  };
906
1102
 
907
1103
  await writeStudioReport(projectPath, releaseReportPath, releaseReport, fileSystem);
@@ -921,7 +1117,8 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
921
1117
  release_ref: releaseRef,
922
1118
  passed: false,
923
1119
  report: releaseReportPath,
924
- auth_required: authResult.required
1120
+ auth_required: authResult.required,
1121
+ auto_errorbook_records: autoErrorbookRecords
925
1122
  }
926
1123
  };
927
1124
  await saveJob(paths, job, fileSystem);
@@ -929,7 +1126,8 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
929
1126
  channel,
930
1127
  release_ref: releaseRef,
931
1128
  report: releaseReportPath,
932
- auth_required: authResult.required
1129
+ auth_required: authResult.required,
1130
+ auto_errorbook_records: autoErrorbookRecords
933
1131
  }, fileSystem);
934
1132
  await writeLatestJob(paths, jobId, fileSystem);
935
1133
  throw new Error(`studio release failed: ${gateResult.steps.filter((step) => step.status === 'failed').map((step) => step.id).join(', ')}`);
@@ -940,15 +1138,38 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
940
1138
  channel,
941
1139
  release_ref: releaseRef,
942
1140
  report: releaseReportPath,
943
- auth_required: authResult.required
1141
+ auth_required: authResult.required,
1142
+ auto_errorbook_records: autoErrorbookRecords
944
1143
  });
945
1144
 
1145
+ const sceneId = normalizeString(job && job.scene && job.scene.id);
1146
+ const sceneSessionId = normalizeString(job && job.session && job.session.scene_session_id);
1147
+ if (sceneId && sceneSessionId) {
1148
+ const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
1149
+ const rollover = await sessionStore.completeSceneSession(sceneId, sceneSessionId, {
1150
+ summary: `Scene ${sceneId} completed by studio release ${releaseRef}`,
1151
+ status: 'completed',
1152
+ jobId: job.job_id,
1153
+ releaseRef,
1154
+ channel,
1155
+ nextObjective: `Next cycle for scene ${sceneId} after release ${releaseRef}`
1156
+ });
1157
+ job.session = {
1158
+ ...(job.session || {}),
1159
+ completed_scene_session_id: rollover.completed_session.session_id,
1160
+ scene_session_id: rollover.next_session ? rollover.next_session.session_id : null,
1161
+ scene_cycle: rollover.next_scene_cycle || null,
1162
+ rolled_over_at: nowIso()
1163
+ };
1164
+ }
1165
+
946
1166
  await saveJob(paths, job, fileSystem);
947
1167
  await appendStudioEvent(paths, job, 'stage.release.completed', {
948
1168
  channel,
949
1169
  release_ref: releaseRef,
950
1170
  report: releaseReportPath,
951
- auth_required: authResult.required
1171
+ auth_required: authResult.required,
1172
+ auto_errorbook_records: autoErrorbookRecords
952
1173
  }, fileSystem);
953
1174
  await writeLatestJob(paths, jobId, fileSystem);
954
1175
 
@@ -966,7 +1187,7 @@ async function runStudioResumeCommand(options = {}, dependencies = {}) {
966
1187
  const latestJobId = await readLatestJob(paths, fileSystem);
967
1188
  const jobId = resolveRequestedJobId(options, latestJobId);
968
1189
  if (!jobId) {
969
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
1190
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
970
1191
  }
971
1192
 
972
1193
  const job = await loadJob(paths, jobId, fileSystem);
@@ -985,7 +1206,7 @@ async function runStudioRollbackCommand(options = {}, dependencies = {}) {
985
1206
  const latestJobId = await readLatestJob(paths, fileSystem);
986
1207
  const jobId = resolveRequestedJobId(options, latestJobId);
987
1208
  if (!jobId) {
988
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
1209
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
989
1210
  }
990
1211
 
991
1212
  const reason = normalizeString(options.reason) || 'manual-rollback';
@@ -1008,6 +1229,19 @@ async function runStudioRollbackCommand(options = {}, dependencies = {}) {
1008
1229
  auth_required: authResult.required
1009
1230
  };
1010
1231
 
1232
+ const sceneSessionId = normalizeString(job && job.session && job.session.scene_session_id);
1233
+ if (sceneSessionId) {
1234
+ const sessionStore = dependencies.sessionStore || new SessionStore(projectPath);
1235
+ await sessionStore.snapshotSession(sceneSessionId, {
1236
+ summary: `Studio rollback: ${reason}`,
1237
+ status: 'rolled_back',
1238
+ payload: {
1239
+ job_id: job.job_id,
1240
+ reason
1241
+ }
1242
+ });
1243
+ }
1244
+
1011
1245
  await saveJob(paths, job, fileSystem);
1012
1246
  await appendStudioEvent(paths, job, 'job.rolled_back', {
1013
1247
  reason
@@ -1050,7 +1284,7 @@ async function runStudioEventsCommand(options = {}, dependencies = {}) {
1050
1284
  const latestJobId = await readLatestJob(paths, fileSystem);
1051
1285
  const jobId = resolveRequestedJobId(options, latestJobId);
1052
1286
  if (!jobId) {
1053
- throw new Error('No studio job found. Run: sce studio plan --from-chat <session>');
1287
+ throw new Error('No studio job found. Run: sce studio plan --scene <scene-id> --from-chat <session>');
1054
1288
  }
1055
1289
 
1056
1290
  const limit = normalizePositiveInteger(options.limit, 50);
@@ -1084,6 +1318,7 @@ function registerStudioCommands(program) {
1084
1318
  studio
1085
1319
  .command('plan')
1086
1320
  .description('Create/refresh a studio plan job from chat context')
1321
+ .requiredOption('--scene <scene-id>', 'Scene identifier (mandatory primary session anchor)')
1087
1322
  .requiredOption('--from-chat <session>', 'Chat session identifier or transcript reference')
1088
1323
  .option('--goal <goal>', 'Optional goal summary')
1089
1324
  .option('--target <target>', 'Target integration profile', 'default')
@@ -1093,8 +1328,8 @@ function registerStudioCommands(program) {
1093
1328
 
1094
1329
  studio
1095
1330
  .command('generate')
1096
- .description('Generate patch bundle metadata for a planned studio job')
1097
- .requiredOption('--scene <scene-id>', 'Scene identifier to generate')
1331
+ .description('Generate patch bundle metadata for a planned studio job (scene inherited from plan)')
1332
+ .option('--scene <scene-id>', 'Optional scene identifier; must match planned scene when provided')
1098
1333
  .option('--target <target>', 'Target integration profile override')
1099
1334
  .option('--patch-bundle <id>', 'Explicit patch bundle id')
1100
1335
  .option('--job <job-id>', 'Studio job id (defaults to latest)')
@@ -0,0 +1,147 @@
1
+ const { resolveSpecSceneBinding } = require('./scene-session-binding');
2
+
3
+ function normalizeString(value) {
4
+ if (typeof value !== 'string') {
5
+ return '';
6
+ }
7
+ return value.trim();
8
+ }
9
+
10
+ function toSpecStatus(specId, orchestratePayload = {}, topLevelStatus = '') {
11
+ const completed = new Set(Array.isArray(orchestratePayload.completed) ? orchestratePayload.completed : []);
12
+ const failed = new Set([
13
+ ...(Array.isArray(orchestratePayload.failed) ? orchestratePayload.failed : []),
14
+ ...(Array.isArray(orchestratePayload.skipped) ? orchestratePayload.skipped : [])
15
+ ]);
16
+
17
+ if (completed.has(specId)) {
18
+ return 'completed';
19
+ }
20
+ if (failed.has(specId)) {
21
+ return 'failed';
22
+ }
23
+
24
+ const orchestrationStatus = normalizeString(orchestratePayload.status).toLowerCase();
25
+ if (orchestrationStatus === 'completed') {
26
+ return 'completed';
27
+ }
28
+ if (orchestrationStatus === 'failed' || orchestrationStatus === 'stopped') {
29
+ return 'failed';
30
+ }
31
+
32
+ const topStatus = normalizeString(topLevelStatus).toLowerCase();
33
+ if (topStatus === 'completed') {
34
+ return 'completed';
35
+ }
36
+ return 'failed';
37
+ }
38
+
39
+ async function bindMultiSpecSceneSession(options = {}, dependencies = {}) {
40
+ const {
41
+ specTargets = [],
42
+ sceneId = null,
43
+ commandName = 'spec-command',
44
+ commandLabel = 'spec-command',
45
+ commandOptions = {},
46
+ runViaOrchestrate
47
+ } = options;
48
+ const {
49
+ projectPath = process.cwd(),
50
+ fileSystem,
51
+ sessionStore
52
+ } = dependencies;
53
+
54
+ if (!Array.isArray(specTargets) || specTargets.length === 0) {
55
+ throw new Error('specTargets is required for bindMultiSpecSceneSession');
56
+ }
57
+ if (typeof runViaOrchestrate !== 'function') {
58
+ throw new Error('runViaOrchestrate callback is required for bindMultiSpecSceneSession');
59
+ }
60
+
61
+ const sceneBinding = await resolveSpecSceneBinding({
62
+ sceneId,
63
+ allowNoScene: false
64
+ }, {
65
+ projectPath,
66
+ fileSystem,
67
+ sessionStore
68
+ });
69
+
70
+ const shouldTrackSessions = commandOptions.dryRun !== true;
71
+ const specSessions = [];
72
+ if (shouldTrackSessions) {
73
+ for (const specId of specTargets) {
74
+ const linked = await sessionStore.startSpecSession({
75
+ sceneId: sceneBinding.scene_id,
76
+ specId,
77
+ objective: `${commandLabel} (orchestrate): ${specId}`
78
+ });
79
+ specSessions.push({
80
+ spec_id: specId,
81
+ spec_session_id: linked.spec_session.session_id
82
+ });
83
+ }
84
+ }
85
+
86
+ try {
87
+ const result = await runViaOrchestrate();
88
+ const orchestratePayload = result && result.orchestrate_result
89
+ ? result.orchestrate_result
90
+ : {};
91
+
92
+ if (shouldTrackSessions) {
93
+ for (const item of specSessions) {
94
+ const specStatus = toSpecStatus(item.spec_id, orchestratePayload, result && result.status);
95
+ await sessionStore.completeSpecSession({
96
+ specSessionRef: item.spec_session_id,
97
+ status: specStatus,
98
+ summary: `${commandLabel} orchestrate ${specStatus}: ${item.spec_id}`,
99
+ payload: {
100
+ command: commandName,
101
+ mode: 'multi-spec-orchestrate',
102
+ spec: item.spec_id,
103
+ orchestration_status: normalizeString(orchestratePayload.status) || null
104
+ }
105
+ });
106
+ }
107
+ }
108
+
109
+ return {
110
+ ...result,
111
+ scene_session: {
112
+ bound: true,
113
+ scene_id: sceneBinding.scene_id,
114
+ scene_cycle: sceneBinding.scene_cycle,
115
+ scene_session_id: sceneBinding.scene_session_id,
116
+ binding_source: sceneBinding.source,
117
+ multi_spec_child_sessions: specSessions
118
+ }
119
+ };
120
+ } catch (error) {
121
+ if (shouldTrackSessions) {
122
+ for (const item of specSessions) {
123
+ try {
124
+ await sessionStore.completeSpecSession({
125
+ specSessionRef: item.spec_session_id,
126
+ status: 'failed',
127
+ summary: `${commandLabel} orchestrate failed: ${item.spec_id}`,
128
+ payload: {
129
+ command: commandName,
130
+ mode: 'multi-spec-orchestrate',
131
+ spec: item.spec_id,
132
+ error: error.message
133
+ }
134
+ });
135
+ } catch (_innerError) {
136
+ // best-effort close
137
+ }
138
+ }
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ module.exports = {
145
+ bindMultiSpecSceneSession,
146
+ toSpecStatus
147
+ };