scene-capability-engine 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,8 @@ const {
11
11
  const { findRelatedSpecs } = require('../spec/related-specs');
12
12
  const { captureTimelineCheckpoint } = require('../runtime/project-timeline');
13
13
  const { runProblemEvaluation } = require('../problem/problem-evaluator');
14
+ const { TaskRefRegistry } = require('../task/task-ref-registry');
15
+ const { getSceStateStore } = require('../state/sce-state-store');
14
16
  const {
15
17
  loadStudioIntakePolicy,
16
18
  runStudioAutoIntake,
@@ -40,8 +42,7 @@ function resolveStudioPaths(projectPath = process.cwd()) {
40
42
  projectPath,
41
43
  studioDir,
42
44
  jobsDir: path.join(studioDir, 'jobs'),
43
- latestFile: path.join(studioDir, 'latest-job.json'),
44
- eventsDir: path.join(studioDir, 'events')
45
+ latestFile: path.join(studioDir, 'latest-job.json')
45
46
  };
46
47
  }
47
48
 
@@ -597,7 +598,6 @@ async function writeStudioReport(projectPath, relativePath, payload, fileSystem
597
598
 
598
599
  async function ensureStudioDirectories(paths, fileSystem = fs) {
599
600
  await fileSystem.ensureDir(paths.jobsDir);
600
- await fileSystem.ensureDir(paths.eventsDir);
601
601
  }
602
602
 
603
603
  async function writeLatestJob(paths, jobId, fileSystem = fs) {
@@ -622,10 +622,6 @@ function getJobFilePath(paths, jobId) {
622
622
  return path.join(paths.jobsDir, `${jobId}.json`);
623
623
  }
624
624
 
625
- function getEventLogFilePath(paths, jobId) {
626
- return path.join(paths.eventsDir, `${jobId}.jsonl`);
627
- }
628
-
629
625
  async function saveJob(paths, job, fileSystem = fs) {
630
626
  const jobFile = getJobFilePath(paths, job.job_id);
631
627
  await fileSystem.writeJson(jobFile, job, { spaces: 2 });
@@ -640,39 +636,28 @@ async function appendStudioEvent(paths, job, eventType, metadata = {}, fileSyste
640
636
  timestamp: nowIso(),
641
637
  metadata
642
638
  };
643
- const eventLine = `${JSON.stringify(event)}\n`;
644
- const eventFile = getEventLogFilePath(paths, job.job_id);
645
- await fileSystem.appendFile(eventFile, eventLine, 'utf8');
639
+ const sceneId = normalizeString(job?.scene?.id) || null;
640
+ const specId = normalizeString(job?.scene?.spec_id) || normalizeString(job?.source?.spec_id) || null;
641
+ const stateStore = getSceStateStore(paths.projectPath, { fileSystem });
642
+ const persisted = await stateStore.appendStudioEvent({
643
+ ...event,
644
+ scene_id: sceneId,
645
+ spec_id: specId
646
+ });
647
+ if (!persisted) {
648
+ throw new Error('Failed to persist studio event into sqlite state store');
649
+ }
650
+ return event;
646
651
  }
647
652
 
648
653
  async function readStudioEvents(paths, jobId, options = {}, fileSystem = fs) {
649
654
  const { limit = 50 } = options;
650
- const eventFile = getEventLogFilePath(paths, jobId);
651
- const exists = await fileSystem.pathExists(eventFile);
652
- if (!exists) {
653
- return [];
655
+ const stateStore = getSceStateStore(paths.projectPath, { fileSystem });
656
+ const events = await stateStore.listStudioEvents(jobId, { limit });
657
+ if (events === null) {
658
+ throw new Error('SQLite state backend unavailable while reading studio events');
654
659
  }
655
-
656
- const content = await fileSystem.readFile(eventFile, 'utf8');
657
- const lines = content
658
- .split(/\r?\n/)
659
- .map((line) => line.trim())
660
- .filter(Boolean);
661
-
662
- const parsed = [];
663
- for (const line of lines) {
664
- try {
665
- const payload = JSON.parse(line);
666
- parsed.push(payload);
667
- } catch (_error) {
668
- // Ignore malformed lines to keep event stream robust.
669
- }
670
- }
671
-
672
- if (limit <= 0) {
673
- return parsed;
674
- }
675
- return parsed.slice(-limit);
660
+ return events;
676
661
  }
677
662
 
678
663
  async function loadJob(paths, jobId, fileSystem = fs) {
@@ -730,6 +715,705 @@ function resolveNextAction(job) {
730
715
  return 'complete';
731
716
  }
732
717
 
718
+ function resolveTaskStage(mode, job, explicitStage = '') {
719
+ const normalizedExplicit = normalizeString(explicitStage);
720
+ if (normalizedExplicit) {
721
+ return normalizedExplicit;
722
+ }
723
+
724
+ const modeSuffix = normalizeString(mode).replace(/^studio-/, '');
725
+ if (modeSuffix && (STAGE_ORDER.includes(modeSuffix) || modeSuffix === 'rollback')) {
726
+ return modeSuffix;
727
+ }
728
+
729
+ if (normalizeString(job && job.status) === 'rolled_back') {
730
+ return 'rollback';
731
+ }
732
+
733
+ for (let i = STAGE_ORDER.length - 1; i >= 0; i -= 1) {
734
+ const stageName = STAGE_ORDER[i];
735
+ const stage = job && job.stages ? job.stages[stageName] : null;
736
+ const status = normalizeString(stage && stage.status);
737
+ if (status && status !== 'pending') {
738
+ return stageName;
739
+ }
740
+ }
741
+
742
+ return 'plan';
743
+ }
744
+
745
+ function normalizeTaskFileChanges(fileChanges = []) {
746
+ const entries = Array.isArray(fileChanges) ? fileChanges : [];
747
+ const seen = new Set();
748
+ const normalized = [];
749
+ for (const entry of entries) {
750
+ const pathRef = normalizeString(entry && entry.path);
751
+ if (!pathRef) {
752
+ continue;
753
+ }
754
+ const line = Number.parseInt(`${entry && entry.line != null ? entry.line : 1}`, 10);
755
+ const normalizedLine = Number.isFinite(line) && line > 0 ? line : 1;
756
+ const diffRef = normalizeString(entry && entry.diffRef) || `${pathRef}:${normalizedLine}`;
757
+ if (seen.has(diffRef)) {
758
+ continue;
759
+ }
760
+ seen.add(diffRef);
761
+ normalized.push({
762
+ path: pathRef,
763
+ line: normalizedLine,
764
+ diffRef
765
+ });
766
+ }
767
+ return normalized;
768
+ }
769
+
770
+ function normalizeTaskCommands(commands = []) {
771
+ const entries = Array.isArray(commands) ? commands : [];
772
+ const normalized = [];
773
+ for (const entry of entries) {
774
+ const cmd = normalizeString(entry && entry.cmd);
775
+ const stdout = typeof (entry && entry.stdout) === 'string' ? entry.stdout : '';
776
+ const stderr = typeof (entry && entry.stderr) === 'string' ? entry.stderr : '';
777
+ const exitCodeRaw = entry && entry.exit_code;
778
+ const exitCode = Number.isFinite(Number(exitCodeRaw))
779
+ ? Number(exitCodeRaw)
780
+ : null;
781
+ const logPath = normalizeString(entry && entry.log_path) || null;
782
+ if (!cmd && !stdout && !stderr && exitCode == null && !logPath) {
783
+ continue;
784
+ }
785
+ normalized.push({
786
+ cmd: cmd || 'n/a',
787
+ exit_code: exitCode,
788
+ stdout,
789
+ stderr,
790
+ log_path: logPath
791
+ });
792
+ }
793
+ return normalized;
794
+ }
795
+
796
+ function buildErrorBundle(entry = {}) {
797
+ const lines = [];
798
+ const pushLine = (key, value) => {
799
+ const normalized = typeof value === 'string' ? value.trim() : `${value || ''}`.trim();
800
+ if (!normalized) {
801
+ return;
802
+ }
803
+ lines.push(`${key}: ${normalized}`);
804
+ };
805
+ pushLine('message', entry.message);
806
+ pushLine('step', entry.step_id || entry.step);
807
+ pushLine('cmd', entry.cmd);
808
+ if (entry.exit_code != null && `${entry.exit_code}`.trim()) {
809
+ lines.push(`exit_code: ${entry.exit_code}`);
810
+ }
811
+ pushLine('skip_reason', entry.skip_reason);
812
+ pushLine('stderr', entry.stderr);
813
+ pushLine('stdout', entry.stdout);
814
+ return lines.join('\n');
815
+ }
816
+
817
+ function normalizeTaskErrors(errors = []) {
818
+ const entries = Array.isArray(errors) ? errors : [];
819
+ const normalized = [];
820
+ for (const entry of entries) {
821
+ const message = normalizeString(entry && entry.message);
822
+ if (!message) {
823
+ continue;
824
+ }
825
+ const errorBundle = normalizeString(entry && entry.error_bundle) || buildErrorBundle(entry);
826
+ normalized.push({
827
+ message,
828
+ error_bundle: errorBundle
829
+ });
830
+ }
831
+ return normalized;
832
+ }
833
+
834
+ function normalizeTaskEvidence(evidence = []) {
835
+ const entries = Array.isArray(evidence) ? evidence : [];
836
+ const normalized = [];
837
+ const seen = new Set();
838
+ for (const entry of entries) {
839
+ if (typeof entry === 'string') {
840
+ const ref = normalizeString(entry);
841
+ if (!ref || seen.has(ref)) {
842
+ continue;
843
+ }
844
+ seen.add(ref);
845
+ normalized.push({
846
+ type: 'reference',
847
+ ref,
848
+ detail: null
849
+ });
850
+ continue;
851
+ }
852
+
853
+ const type = normalizeString(entry && entry.type) || 'reference';
854
+ const ref = normalizeString(entry && entry.ref);
855
+ const detail = normalizeString(entry && entry.detail) || null;
856
+ const key = `${type}:${ref}:${detail || ''}`;
857
+ if ((!ref && !detail) || seen.has(key)) {
858
+ continue;
859
+ }
860
+ seen.add(key);
861
+ normalized.push({
862
+ type,
863
+ ref: ref || null,
864
+ detail
865
+ });
866
+ }
867
+ return normalized;
868
+ }
869
+
870
+ function pickFirstString(values = []) {
871
+ for (const value of values) {
872
+ if (typeof value === 'string' && value.trim()) {
873
+ return value.trim();
874
+ }
875
+ }
876
+ return '';
877
+ }
878
+
879
+ function pickFirstNumber(values = []) {
880
+ for (const value of values) {
881
+ if (value == null || value === '') {
882
+ continue;
883
+ }
884
+ const numberValue = Number(value);
885
+ if (Number.isFinite(numberValue)) {
886
+ return numberValue;
887
+ }
888
+ }
889
+ return null;
890
+ }
891
+
892
+ function extractEventArrayFromPayload(payload) {
893
+ if (Array.isArray(payload)) {
894
+ return payload;
895
+ }
896
+ if (!payload || typeof payload !== 'object') {
897
+ return [];
898
+ }
899
+ const candidateKeys = ['events', 'items', 'data', 'records', 'entries'];
900
+ for (const key of candidateKeys) {
901
+ if (Array.isArray(payload[key])) {
902
+ return payload[key];
903
+ }
904
+ }
905
+ return [payload];
906
+ }
907
+
908
+ async function readOpenHandsEventsFile(openhandsEventsPath, fileSystem = fs) {
909
+ const normalizedPath = normalizeString(openhandsEventsPath);
910
+ if (!normalizedPath) {
911
+ return [];
912
+ }
913
+ const exists = await fileSystem.pathExists(normalizedPath);
914
+ if (!exists) {
915
+ throw new Error(`OpenHands events file not found: ${normalizedPath}`);
916
+ }
917
+
918
+ const content = await fileSystem.readFile(normalizedPath, 'utf8');
919
+ const trimmed = `${content || ''}`.trim();
920
+ if (!trimmed) {
921
+ return [];
922
+ }
923
+
924
+ try {
925
+ const parsed = JSON.parse(trimmed);
926
+ return extractEventArrayFromPayload(parsed);
927
+ } catch (_error) {
928
+ const lines = trimmed
929
+ .split(/\r?\n/)
930
+ .map((line) => line.trim())
931
+ .filter(Boolean);
932
+ const parsedLines = [];
933
+ for (const line of lines) {
934
+ try {
935
+ parsedLines.push(JSON.parse(line));
936
+ } catch (_innerError) {
937
+ // Skip malformed JSONL lines to keep ingestion robust.
938
+ }
939
+ }
940
+ return parsedLines;
941
+ }
942
+ }
943
+
944
+ function normalizeOpenHandsEventRecord(rawEvent, index, jobId) {
945
+ const raw = rawEvent && typeof rawEvent === 'object'
946
+ ? rawEvent
947
+ : { value: rawEvent };
948
+ const eventId = pickFirstString([
949
+ raw.event_id,
950
+ raw.eventId,
951
+ raw.id,
952
+ raw.uuid
953
+ ]) || `oh-evt-${index + 1}`;
954
+ const eventType = pickFirstString([
955
+ raw.event_type,
956
+ raw.eventType,
957
+ raw.type,
958
+ raw.kind,
959
+ raw.action
960
+ ]) || 'unknown';
961
+ const timestamp = pickFirstString([
962
+ raw.timestamp,
963
+ raw.time,
964
+ raw.created_at,
965
+ raw.createdAt,
966
+ raw.ts
967
+ ]) || nowIso();
968
+ return {
969
+ api_version: STUDIO_EVENT_API_VERSION,
970
+ event_id: eventId,
971
+ job_id: jobId,
972
+ event_type: `openhands.${eventType}`,
973
+ timestamp,
974
+ metadata: {
975
+ source: 'openhands',
976
+ raw
977
+ }
978
+ };
979
+ }
980
+
981
+ function tryParseJsonText(value) {
982
+ if (typeof value !== 'string') {
983
+ return null;
984
+ }
985
+ const trimmed = value.trim();
986
+ if (!trimmed) {
987
+ return null;
988
+ }
989
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) {
990
+ return null;
991
+ }
992
+ try {
993
+ return JSON.parse(trimmed);
994
+ } catch (_error) {
995
+ return null;
996
+ }
997
+ }
998
+
999
+ function stringifyCompact(value) {
1000
+ if (value == null) {
1001
+ return '';
1002
+ }
1003
+ if (typeof value === 'string') {
1004
+ return value;
1005
+ }
1006
+ try {
1007
+ return JSON.stringify(value);
1008
+ } catch (_error) {
1009
+ return `${value}`;
1010
+ }
1011
+ }
1012
+
1013
+ function extractOpenHandsCommandCandidates(raw = {}) {
1014
+ const command = pickFirstString([
1015
+ raw.command,
1016
+ raw.cmd,
1017
+ raw.input && raw.input.command,
1018
+ raw.action && raw.action.command,
1019
+ raw.observation && raw.observation.command,
1020
+ raw.tool_input && raw.tool_input.command
1021
+ ]);
1022
+
1023
+ const toolName = pickFirstString([
1024
+ raw.tool_name,
1025
+ raw.toolName,
1026
+ raw.tool && raw.tool.name,
1027
+ raw.name
1028
+ ]);
1029
+ const toolArgs = raw.arguments || raw.tool_input || raw.input || null;
1030
+
1031
+ const cmd = command || (toolName
1032
+ ? `tool:${toolName}${toolArgs ? ` ${stringifyCompact(toolArgs)}` : ''}`
1033
+ : '');
1034
+
1035
+ return {
1036
+ cmd,
1037
+ exit_code: pickFirstNumber([
1038
+ raw.exit_code,
1039
+ raw.exitCode,
1040
+ raw.result && (raw.result.exit_code ?? raw.result.exitCode)
1041
+ ]),
1042
+ stdout: pickFirstString([
1043
+ raw.stdout,
1044
+ raw.output,
1045
+ raw.result && raw.result.stdout,
1046
+ raw.observation && raw.observation.stdout
1047
+ ]),
1048
+ stderr: pickFirstString([
1049
+ raw.stderr,
1050
+ raw.result && raw.result.stderr,
1051
+ raw.error && raw.error.message,
1052
+ typeof raw.error === 'string' ? raw.error : ''
1053
+ ]),
1054
+ log_path: pickFirstString([
1055
+ raw.log_path,
1056
+ raw.logPath,
1057
+ raw.log && raw.log.path
1058
+ ])
1059
+ };
1060
+ }
1061
+
1062
+ function collectOpenHandsFileChangesFromRaw(raw = {}) {
1063
+ const changes = [];
1064
+ const addPath = (pathValue, lineValue, diffRefValue = '') => {
1065
+ const pathRef = normalizeString(pathValue);
1066
+ if (!pathRef) {
1067
+ return;
1068
+ }
1069
+ const line = Number.parseInt(`${lineValue != null ? lineValue : 1}`, 10);
1070
+ const normalizedLine = Number.isFinite(line) && line > 0 ? line : 1;
1071
+ const diffRef = normalizeString(diffRefValue) || `${pathRef}:${normalizedLine}`;
1072
+ changes.push({
1073
+ path: pathRef,
1074
+ line: normalizedLine,
1075
+ diffRef
1076
+ });
1077
+ };
1078
+
1079
+ if (typeof raw.path === 'string') {
1080
+ addPath(raw.path, raw.line || raw.line_number, raw.diff_ref);
1081
+ }
1082
+ if (raw.file && typeof raw.file === 'object') {
1083
+ addPath(raw.file.path || raw.file.name, raw.file.line || raw.file.line_number, raw.file.diffRef || raw.file.diff_ref);
1084
+ }
1085
+
1086
+ const arrayKeys = ['files', 'changed_files', 'modified_files', 'created_files', 'deleted_files'];
1087
+ for (const key of arrayKeys) {
1088
+ const values = Array.isArray(raw[key]) ? raw[key] : [];
1089
+ for (const value of values) {
1090
+ if (typeof value === 'string') {
1091
+ addPath(value, 1);
1092
+ } else if (value && typeof value === 'object') {
1093
+ addPath(value.path || value.file || value.name, value.line || value.line_number, value.diffRef || value.diff_ref);
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ const patchText = pickFirstString([raw.patch, raw.diff]);
1099
+ if (patchText) {
1100
+ const parsedJson = tryParseJsonText(patchText);
1101
+ if (parsedJson && typeof parsedJson === 'object') {
1102
+ const nested = collectOpenHandsFileChangesFromRaw(parsedJson);
1103
+ changes.push(...nested);
1104
+ } else {
1105
+ const lines = patchText.split(/\r?\n/);
1106
+ for (const line of lines) {
1107
+ const match = line.match(/^\+\+\+\s+b\/(.+)$/);
1108
+ if (match && match[1]) {
1109
+ addPath(match[1], 1);
1110
+ }
1111
+ }
1112
+ }
1113
+ }
1114
+
1115
+ return changes;
1116
+ }
1117
+
1118
+ function mapOpenHandsEventsToTaskSignals(openhandsEvents = [], context = {}) {
1119
+ const normalizedEvents = openhandsEvents.map((item, index) =>
1120
+ normalizeOpenHandsEventRecord(item, index, context.jobId)
1121
+ );
1122
+
1123
+ const commands = [];
1124
+ const fileChanges = [];
1125
+ const errors = [];
1126
+ const evidence = [];
1127
+
1128
+ for (const event of normalizedEvents) {
1129
+ const raw = event && event.metadata ? event.metadata.raw || {} : {};
1130
+ const commandCandidate = extractOpenHandsCommandCandidates(raw);
1131
+ if (commandCandidate.cmd || commandCandidate.stdout || commandCandidate.stderr || commandCandidate.exit_code != null) {
1132
+ commands.push(commandCandidate);
1133
+ }
1134
+
1135
+ fileChanges.push(...collectOpenHandsFileChangesFromRaw(raw));
1136
+
1137
+ const eventType = normalizeString(event.event_type).toLowerCase();
1138
+ const failedByType = eventType.includes('error') || eventType.includes('fail');
1139
+ const failedByExit = commandCandidate.exit_code != null && Number(commandCandidate.exit_code) !== 0;
1140
+ const failedByStderr = commandCandidate.stderr.length > 0 && !commandCandidate.stdout;
1141
+ if (failedByType || failedByExit || failedByStderr) {
1142
+ const message = pickFirstString([
1143
+ raw.message,
1144
+ raw.error && raw.error.message,
1145
+ typeof raw.error === 'string' ? raw.error : '',
1146
+ failedByExit ? `OpenHands command failed (exit ${commandCandidate.exit_code})` : '',
1147
+ 'OpenHands event indicates failure'
1148
+ ]);
1149
+ errors.push({
1150
+ message,
1151
+ step_id: event.event_type,
1152
+ cmd: commandCandidate.cmd,
1153
+ exit_code: commandCandidate.exit_code,
1154
+ stderr: commandCandidate.stderr,
1155
+ stdout: commandCandidate.stdout
1156
+ });
1157
+ }
1158
+
1159
+ evidence.push({
1160
+ type: 'openhands-event',
1161
+ ref: event.event_id,
1162
+ detail: event.event_type
1163
+ });
1164
+ }
1165
+
1166
+ return {
1167
+ event: normalizedEvents,
1168
+ commands: normalizeTaskCommands(commands),
1169
+ file_changes: normalizeTaskFileChanges(fileChanges),
1170
+ errors: normalizeTaskErrors(errors),
1171
+ evidence: normalizeTaskEvidence(evidence)
1172
+ };
1173
+ }
1174
+
1175
+ function extractCommandsFromStageMetadata(stageMetadata = {}) {
1176
+ const gateSteps = Array.isArray(stageMetadata && stageMetadata.gate_steps)
1177
+ ? stageMetadata.gate_steps
1178
+ : [];
1179
+ return gateSteps.map((step) => ({
1180
+ cmd: normalizeString(step && step.command) || normalizeString(step && step.id) || 'n/a',
1181
+ exit_code: Number.isFinite(Number(step && step.exit_code))
1182
+ ? Number(step.exit_code)
1183
+ : null,
1184
+ stdout: normalizeString(step?.output?.stdout),
1185
+ stderr: normalizeString(step?.output?.stderr),
1186
+ log_path: normalizeString(stageMetadata.report) || null
1187
+ }));
1188
+ }
1189
+
1190
+ function extractErrorsFromStageMetadata(stageState = {}, stageMetadata = {}) {
1191
+ const directErrors = Array.isArray(stageMetadata && stageMetadata.errors)
1192
+ ? stageMetadata.errors
1193
+ : [];
1194
+ const gateStepErrors = [];
1195
+ const gateSteps = Array.isArray(stageMetadata && stageMetadata.gate_steps)
1196
+ ? stageMetadata.gate_steps
1197
+ : [];
1198
+
1199
+ for (const step of gateSteps) {
1200
+ const status = normalizeString(step && step.status);
1201
+ if (status !== 'failed') {
1202
+ continue;
1203
+ }
1204
+ const stepId = normalizeString(step && step.id) || normalizeString(step && step.name) || 'unknown-step';
1205
+ const stepMessage = `Gate step failed: ${stepId}${step && step.exit_code != null ? ` (exit ${step.exit_code})` : ''}`;
1206
+ gateStepErrors.push({
1207
+ message: stepMessage,
1208
+ step_id: stepId,
1209
+ cmd: normalizeString(step && step.command),
1210
+ exit_code: step && step.exit_code != null ? step.exit_code : null,
1211
+ skip_reason: normalizeString(step && step.skip_reason),
1212
+ stdout: normalizeString(step?.output?.stdout),
1213
+ stderr: normalizeString(step?.output?.stderr)
1214
+ });
1215
+ }
1216
+
1217
+ if (normalizeString(stageState && stageState.status) === 'blocked') {
1218
+ const blockers = Array.isArray(stageMetadata?.problem_evaluation?.blockers)
1219
+ ? stageMetadata.problem_evaluation.blockers
1220
+ : [];
1221
+ if (blockers.length > 0) {
1222
+ gateStepErrors.push({
1223
+ message: `Problem evaluation blocked stage: ${blockers.join(', ')}`,
1224
+ cmd: 'problem-evaluation-policy'
1225
+ });
1226
+ }
1227
+ }
1228
+
1229
+ return [...directErrors, ...gateStepErrors];
1230
+ }
1231
+
1232
+ function collectTaskFileChanges(job = {}, stageName = '', stageMetadata = {}) {
1233
+ const fileChanges = [];
1234
+ if (Array.isArray(stageMetadata && stageMetadata.file_changes)) {
1235
+ fileChanges.push(...stageMetadata.file_changes);
1236
+ }
1237
+
1238
+ const createdSpec = job?.source?.intake?.created_spec;
1239
+ const createdSpecId = createdSpec && createdSpec.created
1240
+ ? normalizeString(createdSpec.spec_id)
1241
+ : '';
1242
+ if (stageName === 'plan' && createdSpecId) {
1243
+ fileChanges.push(
1244
+ { path: `.sce/specs/${createdSpecId}/requirements.md`, line: 1 },
1245
+ { path: `.sce/specs/${createdSpecId}/design.md`, line: 1 },
1246
+ { path: `.sce/specs/${createdSpecId}/tasks.md`, line: 1 },
1247
+ { path: `.sce/specs/${createdSpecId}/custom/problem-domain-chain.json`, line: 1 },
1248
+ { path: `.sce/specs/${createdSpecId}/custom/problem-contract.json`, line: 1 }
1249
+ );
1250
+ }
1251
+
1252
+ const artifacts = job && job.artifacts ? job.artifacts : {};
1253
+ if (stageName === 'plan') {
1254
+ if (normalizeString(artifacts.spec_portfolio_report)) {
1255
+ fileChanges.push({ path: artifacts.spec_portfolio_report, line: 1 });
1256
+ }
1257
+ if (normalizeString(artifacts.spec_scene_index)) {
1258
+ fileChanges.push({ path: artifacts.spec_scene_index, line: 1 });
1259
+ }
1260
+ }
1261
+ if (stageName === 'generate' && normalizeString(artifacts.generate_report)) {
1262
+ fileChanges.push({ path: artifacts.generate_report, line: 1 });
1263
+ }
1264
+ if (stageName === 'verify' && normalizeString(artifacts.verify_report)) {
1265
+ fileChanges.push({ path: artifacts.verify_report, line: 1 });
1266
+ }
1267
+ if (stageName === 'release' && normalizeString(artifacts.release_report)) {
1268
+ fileChanges.push({ path: artifacts.release_report, line: 1 });
1269
+ }
1270
+ return normalizeTaskFileChanges(fileChanges);
1271
+ }
1272
+
1273
+ function collectTaskEvidence(job = {}, stageName = '', stageMetadata = {}) {
1274
+ const evidence = [];
1275
+ const stageReport = normalizeString(stageMetadata && stageMetadata.report);
1276
+ if (stageReport) {
1277
+ evidence.push({ type: 'stage-report', ref: stageReport, detail: stageName });
1278
+ }
1279
+
1280
+ const artifacts = job && job.artifacts ? job.artifacts : {};
1281
+ if (stageName && artifacts.problem_eval_reports && artifacts.problem_eval_reports[stageName]) {
1282
+ evidence.push({
1283
+ type: 'problem-evaluation-report',
1284
+ ref: artifacts.problem_eval_reports[stageName],
1285
+ detail: stageName
1286
+ });
1287
+ }
1288
+
1289
+ if (normalizeString(job?.source?.domain_chain?.chain_path)) {
1290
+ evidence.push({
1291
+ type: 'domain-chain',
1292
+ ref: job.source.domain_chain.chain_path,
1293
+ detail: stageName
1294
+ });
1295
+ }
1296
+ if (normalizeString(job?.source?.problem_contract_path)) {
1297
+ evidence.push({
1298
+ type: 'problem-contract',
1299
+ ref: job.source.problem_contract_path,
1300
+ detail: stageName
1301
+ });
1302
+ }
1303
+ if (Array.isArray(stageMetadata && stageMetadata.auto_errorbook_records)) {
1304
+ for (const item of stageMetadata.auto_errorbook_records) {
1305
+ const entryId = normalizeString(item && item.entry_id);
1306
+ if (!entryId) {
1307
+ continue;
1308
+ }
1309
+ evidence.push({
1310
+ type: 'errorbook-entry',
1311
+ ref: entryId,
1312
+ detail: normalizeString(item && item.step_id) || null
1313
+ });
1314
+ }
1315
+ }
1316
+
1317
+ if (normalizeString(job && job.job_id)) {
1318
+ evidence.push({
1319
+ type: 'event-log',
1320
+ ref: '.sce/state/sce-state.sqlite',
1321
+ detail: `studio_event_stream:job_id=${job.job_id}`
1322
+ });
1323
+ }
1324
+
1325
+ return normalizeTaskEvidence(evidence);
1326
+ }
1327
+
1328
+ function buildTaskSummaryLines(job = {}, stageName = '', taskStatus = '', nextAction = '', taskRef = '') {
1329
+ const sceneId = normalizeString(job?.scene?.id) || 'scene.n/a';
1330
+ const specId = normalizeString(job?.scene?.spec_id) || normalizeString(job?.source?.spec_id) || 'spec.n/a';
1331
+ const progress = buildProgress(job);
1332
+ return [
1333
+ `Stage: ${stageName || 'plan'} | Status: ${taskStatus || 'unknown'}${taskRef ? ` | Ref: ${taskRef}` : ''}`,
1334
+ `Scene: ${sceneId} | Spec: ${specId} | Progress: ${progress.completed}/${progress.total}`,
1335
+ `Next: ${nextAction || 'n/a'}`
1336
+ ];
1337
+ }
1338
+
1339
+ function buildTaskEnvelope(mode, job, options = {}) {
1340
+ const stageName = resolveTaskStage(mode, job, options.stageName);
1341
+ const stageState = stageName && job && job.stages && job.stages[stageName]
1342
+ ? job.stages[stageName]
1343
+ : {};
1344
+ const stageMetadata = stageState && typeof stageState.metadata === 'object' && stageState.metadata
1345
+ ? stageState.metadata
1346
+ : {};
1347
+ const nextAction = resolveNextAction(job);
1348
+
1349
+ const events = Array.isArray(options.events)
1350
+ ? options.events
1351
+ : (options.event ? [options.event] : []);
1352
+ const latestEvent = events.length > 0 ? events[events.length - 1] : null;
1353
+
1354
+ const taskStatus = normalizeString(stageState && stageState.status)
1355
+ || (stageName === 'rollback' && normalizeString(job && job.status) === 'rolled_back'
1356
+ ? 'completed'
1357
+ : normalizeString(job && job.status) || 'unknown');
1358
+ const taskId = normalizeString(options.taskId)
1359
+ || (normalizeString(job && job.job_id)
1360
+ ? `${job.job_id}:${stageName || 'task'}`
1361
+ : null);
1362
+ const goal = normalizeString(job?.source?.goal)
1363
+ || `Studio ${stageName || 'task'} execution`;
1364
+ const sessionId = normalizeString(job?.session?.scene_session_id) || null;
1365
+ const sceneId = normalizeString(job?.scene?.id) || null;
1366
+ const specId = normalizeString(job?.scene?.spec_id) || normalizeString(job?.source?.spec_id) || null;
1367
+ const taskRef = normalizeString(options.taskRef) || null;
1368
+
1369
+ const commands = normalizeTaskCommands([
1370
+ ...(Array.isArray(stageMetadata.commands) ? stageMetadata.commands : []),
1371
+ ...extractCommandsFromStageMetadata(stageMetadata)
1372
+ ]);
1373
+ const errors = normalizeTaskErrors(
1374
+ extractErrorsFromStageMetadata(stageState, stageMetadata)
1375
+ );
1376
+ const fileChanges = collectTaskFileChanges(job, stageName, stageMetadata);
1377
+ const evidence = collectTaskEvidence(job, stageName, stageMetadata);
1378
+
1379
+ const handoff = stageMetadata.handoff && typeof stageMetadata.handoff === 'object'
1380
+ ? stageMetadata.handoff
1381
+ : {
1382
+ stage: stageName,
1383
+ status: taskStatus,
1384
+ completed_at: normalizeString(stageState && stageState.completed_at) || null,
1385
+ report: normalizeString(stageMetadata.report) || null,
1386
+ release_ref: normalizeString(stageMetadata.release_ref) || normalizeString(job?.artifacts?.release_ref) || null
1387
+ };
1388
+
1389
+ const normalizedHandoff = {
1390
+ ...handoff,
1391
+ task_ref: taskRef
1392
+ };
1393
+
1394
+ return {
1395
+ sessionId,
1396
+ sceneId,
1397
+ specId,
1398
+ taskId,
1399
+ taskRef,
1400
+ eventId: normalizeString(latestEvent && latestEvent.event_id) || null,
1401
+ task: {
1402
+ ref: taskRef,
1403
+ goal,
1404
+ status: taskStatus,
1405
+ summary: buildTaskSummaryLines(job, stageName, taskStatus, nextAction, taskRef),
1406
+ handoff: normalizedHandoff,
1407
+ next_action: nextAction,
1408
+ file_changes: fileChanges,
1409
+ commands,
1410
+ errors,
1411
+ evidence
1412
+ },
1413
+ event: events
1414
+ };
1415
+ }
1416
+
733
1417
  function toRelativePosix(projectPath, absolutePath) {
734
1418
  return path.relative(projectPath, absolutePath).replace(/\\/g, '/');
735
1419
  }
@@ -1170,8 +1854,50 @@ function ensureNotRolledBack(job, stageName) {
1170
1854
  }
1171
1855
  }
1172
1856
 
1173
- function buildCommandPayload(mode, job) {
1174
- return {
1857
+ function buildStudioTaskKey(stageName = '') {
1858
+ const normalizedStage = normalizeString(stageName) || 'task';
1859
+ return `studio:${normalizedStage}`;
1860
+ }
1861
+
1862
+ async function resolveTaskReference(mode, job, options = {}) {
1863
+ const explicitTaskRef = normalizeString(options.taskRef);
1864
+ if (explicitTaskRef) {
1865
+ return explicitTaskRef;
1866
+ }
1867
+
1868
+ const sceneId = normalizeString(job?.scene?.id);
1869
+ const specId = normalizeString(job?.scene?.spec_id) || normalizeString(job?.source?.spec_id);
1870
+ if (!sceneId || !specId) {
1871
+ return null;
1872
+ }
1873
+
1874
+ const stageName = resolveTaskStage(mode, job, options.stageName);
1875
+ const taskKey = normalizeString(options.taskKey) || buildStudioTaskKey(stageName);
1876
+ const projectPath = normalizeString(options.projectPath) || process.cwd();
1877
+ const fileSystem = options.fileSystem || fs;
1878
+ const taskRefRegistry = options.taskRefRegistry || new TaskRefRegistry(projectPath, { fileSystem });
1879
+
1880
+ try {
1881
+ const taskRef = await taskRefRegistry.resolveOrCreateRef({
1882
+ sceneId,
1883
+ specId,
1884
+ taskKey,
1885
+ source: 'studio-stage',
1886
+ metadata: {
1887
+ mode: normalizeString(mode) || null,
1888
+ stage: stageName || null,
1889
+ job_id: normalizeString(job?.job_id) || null
1890
+ }
1891
+ });
1892
+ return taskRef.task_ref;
1893
+ } catch (_error) {
1894
+ return null;
1895
+ }
1896
+ }
1897
+
1898
+ async function buildCommandPayload(mode, job, options = {}) {
1899
+ const taskRef = await resolveTaskReference(mode, job, options);
1900
+ const base = {
1175
1901
  mode,
1176
1902
  success: true,
1177
1903
  job_id: job.job_id,
@@ -1180,6 +1906,13 @@ function buildCommandPayload(mode, job) {
1180
1906
  next_action: resolveNextAction(job),
1181
1907
  artifacts: { ...job.artifacts }
1182
1908
  };
1909
+ return {
1910
+ ...base,
1911
+ ...buildTaskEnvelope(mode, job, {
1912
+ ...options,
1913
+ taskRef
1914
+ })
1915
+ };
1183
1916
  }
1184
1917
 
1185
1918
  function buildJobDomainChainMetadata(job = {}) {
@@ -1645,7 +2378,7 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
1645
2378
  };
1646
2379
 
1647
2380
  await saveJob(paths, job, fileSystem);
1648
- await appendStudioEvent(paths, job, 'stage.plan.completed', {
2381
+ const planEvent = await appendStudioEvent(paths, job, 'stage.plan.completed', {
1649
2382
  from_chat: fromChat,
1650
2383
  scene_id: sceneId,
1651
2384
  spec_id: effectiveSpecId,
@@ -1671,7 +2404,12 @@ async function runStudioPlanCommand(options = {}, dependencies = {}) {
1671
2404
  }, fileSystem);
1672
2405
  await writeLatestJob(paths, jobId, fileSystem);
1673
2406
 
1674
- const payload = buildCommandPayload('studio-plan', job);
2407
+ const payload = await buildCommandPayload('studio-plan', job, {
2408
+ stageName: 'plan',
2409
+ event: planEvent,
2410
+ projectPath,
2411
+ fileSystem
2412
+ });
1675
2413
  payload.scene = {
1676
2414
  id: sceneId,
1677
2415
  spec_id: effectiveSpecId
@@ -1756,7 +2494,7 @@ async function runStudioGenerateCommand(options = {}, dependencies = {}) {
1756
2494
  });
1757
2495
 
1758
2496
  await saveJob(paths, job, fileSystem);
1759
- await appendStudioEvent(paths, job, 'stage.generate.completed', {
2497
+ const generateEvent = await appendStudioEvent(paths, job, 'stage.generate.completed', {
1760
2498
  scene_id: sceneId,
1761
2499
  target: job.target,
1762
2500
  patch_bundle_id: patchBundleId,
@@ -1766,7 +2504,12 @@ async function runStudioGenerateCommand(options = {}, dependencies = {}) {
1766
2504
  }, fileSystem);
1767
2505
  await writeLatestJob(paths, jobId, fileSystem);
1768
2506
 
1769
- const payload = buildCommandPayload('studio-generate', job);
2507
+ const payload = await buildCommandPayload('studio-generate', job, {
2508
+ stageName: 'generate',
2509
+ event: generateEvent,
2510
+ projectPath,
2511
+ fileSystem
2512
+ });
1770
2513
  printStudioPayload(payload, options);
1771
2514
  return payload;
1772
2515
  }
@@ -1826,14 +2569,19 @@ async function runStudioApplyCommand(options = {}, dependencies = {}) {
1826
2569
  });
1827
2570
 
1828
2571
  await saveJob(paths, job, fileSystem);
1829
- await appendStudioEvent(paths, job, 'stage.apply.completed', {
2572
+ const applyEvent = await appendStudioEvent(paths, job, 'stage.apply.completed', {
1830
2573
  patch_bundle_id: patchBundleId,
1831
2574
  auth_required: authResult.required,
1832
2575
  problem_evaluation: summarizeProblemEvaluation(applyProblemEvaluation)
1833
2576
  }, fileSystem);
1834
2577
  await writeLatestJob(paths, jobId, fileSystem);
1835
2578
 
1836
- const payload = buildCommandPayload('studio-apply', job);
2579
+ const payload = await buildCommandPayload('studio-apply', job, {
2580
+ stageName: 'apply',
2581
+ event: applyEvent,
2582
+ projectPath,
2583
+ fileSystem
2584
+ });
1837
2585
  printStudioPayload(payload, options);
1838
2586
  return payload;
1839
2587
  }
@@ -1940,6 +2688,7 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
1940
2688
  profile,
1941
2689
  passed: false,
1942
2690
  report: verifyReportPath,
2691
+ gate_steps: gateResult.steps,
1943
2692
  problem_evaluation: summarizeProblemEvaluation(verifyProblemEvaluation),
1944
2693
  domain_chain: domainChainMetadata,
1945
2694
  auto_errorbook_records: autoErrorbookRecords
@@ -1962,13 +2711,14 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
1962
2711
  profile,
1963
2712
  passed: true,
1964
2713
  report: verifyReportPath,
2714
+ gate_steps: gateResult.steps,
1965
2715
  problem_evaluation: summarizeProblemEvaluation(verifyProblemEvaluation),
1966
2716
  domain_chain: domainChainMetadata,
1967
2717
  auto_errorbook_records: autoErrorbookRecords
1968
2718
  });
1969
2719
 
1970
2720
  await saveJob(paths, job, fileSystem);
1971
- await appendStudioEvent(paths, job, 'stage.verify.completed', {
2721
+ const verifyEvent = await appendStudioEvent(paths, job, 'stage.verify.completed', {
1972
2722
  profile,
1973
2723
  passed: true,
1974
2724
  report: verifyReportPath,
@@ -1978,7 +2728,12 @@ async function runStudioVerifyCommand(options = {}, dependencies = {}) {
1978
2728
  }, fileSystem);
1979
2729
  await writeLatestJob(paths, jobId, fileSystem);
1980
2730
 
1981
- const payload = buildCommandPayload('studio-verify', job);
2731
+ const payload = await buildCommandPayload('studio-verify', job, {
2732
+ stageName: 'verify',
2733
+ event: verifyEvent,
2734
+ projectPath,
2735
+ fileSystem
2736
+ });
1982
2737
  printStudioPayload(payload, options);
1983
2738
  return payload;
1984
2739
  }
@@ -2118,6 +2873,7 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
2118
2873
  release_ref: releaseRef,
2119
2874
  passed: false,
2120
2875
  report: releaseReportPath,
2876
+ gate_steps: gateResult.steps,
2121
2877
  auth_required: authResult.required,
2122
2878
  problem_evaluation: summarizeProblemEvaluation(releaseProblemEvaluation),
2123
2879
  domain_chain: domainChainMetadata,
@@ -2143,6 +2899,7 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
2143
2899
  channel,
2144
2900
  release_ref: releaseRef,
2145
2901
  report: releaseReportPath,
2902
+ gate_steps: gateResult.steps,
2146
2903
  auth_required: authResult.required,
2147
2904
  problem_evaluation: summarizeProblemEvaluation(releaseProblemEvaluation),
2148
2905
  domain_chain: domainChainMetadata,
@@ -2171,7 +2928,7 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
2171
2928
  }
2172
2929
 
2173
2930
  await saveJob(paths, job, fileSystem);
2174
- await appendStudioEvent(paths, job, 'stage.release.completed', {
2931
+ const releaseEvent = await appendStudioEvent(paths, job, 'stage.release.completed', {
2175
2932
  channel,
2176
2933
  release_ref: releaseRef,
2177
2934
  report: releaseReportPath,
@@ -2182,7 +2939,12 @@ async function runStudioReleaseCommand(options = {}, dependencies = {}) {
2182
2939
  }, fileSystem);
2183
2940
  await writeLatestJob(paths, jobId, fileSystem);
2184
2941
 
2185
- const payload = buildCommandPayload('studio-release', job);
2942
+ const payload = await buildCommandPayload('studio-release', job, {
2943
+ stageName: 'release',
2944
+ event: releaseEvent,
2945
+ projectPath,
2946
+ fileSystem
2947
+ });
2186
2948
  printStudioPayload(payload, options);
2187
2949
  return payload;
2188
2950
  }
@@ -2200,7 +2962,12 @@ async function runStudioResumeCommand(options = {}, dependencies = {}) {
2200
2962
  }
2201
2963
 
2202
2964
  const job = await loadJob(paths, jobId, fileSystem);
2203
- const payload = buildCommandPayload('studio-resume', job);
2965
+ const events = await readStudioEvents(paths, jobId, { limit: 20 }, fileSystem);
2966
+ const payload = await buildCommandPayload('studio-resume', job, {
2967
+ events,
2968
+ projectPath,
2969
+ fileSystem
2970
+ });
2204
2971
  payload.success = true;
2205
2972
  printStudioPayload(payload, options);
2206
2973
  return payload;
@@ -2252,12 +3019,17 @@ async function runStudioRollbackCommand(options = {}, dependencies = {}) {
2252
3019
  }
2253
3020
 
2254
3021
  await saveJob(paths, job, fileSystem);
2255
- await appendStudioEvent(paths, job, 'job.rolled_back', {
3022
+ const rollbackEvent = await appendStudioEvent(paths, job, 'job.rolled_back', {
2256
3023
  reason
2257
3024
  }, fileSystem);
2258
3025
  await writeLatestJob(paths, jobId, fileSystem);
2259
3026
 
2260
- const payload = buildCommandPayload('studio-rollback', job);
3027
+ const payload = await buildCommandPayload('studio-rollback', job, {
3028
+ stageName: 'rollback',
3029
+ event: rollbackEvent,
3030
+ projectPath,
3031
+ fileSystem
3032
+ });
2261
3033
  payload.rollback = { ...job.rollback };
2262
3034
  printStudioPayload(payload, options);
2263
3035
  return payload;
@@ -2297,15 +3069,66 @@ async function runStudioEventsCommand(options = {}, dependencies = {}) {
2297
3069
  }
2298
3070
 
2299
3071
  const limit = normalizePositiveInteger(options.limit, 50);
2300
- const events = await readStudioEvents(paths, jobId, { limit }, fileSystem);
3072
+ const job = await loadJob(paths, jobId, fileSystem);
3073
+ const openhandsEventsPath = normalizeString(options.openhandsEvents);
3074
+ let sourceStream = 'studio';
3075
+ let events = await readStudioEvents(paths, jobId, { limit }, fileSystem);
3076
+ let openhandsSignals = null;
3077
+ if (openhandsEventsPath) {
3078
+ const absoluteOpenhandsPath = path.isAbsolute(openhandsEventsPath)
3079
+ ? openhandsEventsPath
3080
+ : path.join(projectPath, openhandsEventsPath);
3081
+ const rawOpenhandsEvents = await readOpenHandsEventsFile(absoluteOpenhandsPath, fileSystem);
3082
+ openhandsSignals = mapOpenHandsEventsToTaskSignals(rawOpenhandsEvents, {
3083
+ jobId: job.job_id
3084
+ });
3085
+ events = openhandsSignals.event;
3086
+ sourceStream = 'openhands';
3087
+ }
2301
3088
 
2302
- const payload = {
2303
- mode: 'studio-events',
2304
- success: true,
2305
- job_id: jobId,
2306
- limit,
2307
- events
2308
- };
3089
+ const payload = await buildCommandPayload('studio-events', job, {
3090
+ events,
3091
+ projectPath,
3092
+ fileSystem
3093
+ });
3094
+ payload.limit = limit;
3095
+ payload.source_stream = sourceStream;
3096
+ if (sourceStream === 'openhands') {
3097
+ payload.openhands_events_file = path.relative(projectPath, path.isAbsolute(openhandsEventsPath)
3098
+ ? openhandsEventsPath
3099
+ : path.join(projectPath, openhandsEventsPath)).replace(/\\/g, '/');
3100
+ payload.task = {
3101
+ ...payload.task,
3102
+ summary: [
3103
+ `OpenHands events: ${events.length} | commands: ${openhandsSignals.commands.length} | errors: ${openhandsSignals.errors.length}`,
3104
+ `File changes: ${openhandsSignals.file_changes.length} | source: ${payload.openhands_events_file}`,
3105
+ `Next: ${payload.task.next_action}`
3106
+ ],
3107
+ handoff: {
3108
+ ...(payload.task.handoff || {}),
3109
+ source_stream: 'openhands',
3110
+ openhands_event_count: events.length,
3111
+ openhands_command_count: openhandsSignals.commands.length,
3112
+ openhands_error_count: openhandsSignals.errors.length
3113
+ },
3114
+ commands: openhandsSignals.commands,
3115
+ file_changes: openhandsSignals.file_changes,
3116
+ errors: openhandsSignals.errors,
3117
+ evidence: normalizeTaskEvidence([
3118
+ ...(Array.isArray(payload.task.evidence) ? payload.task.evidence : []),
3119
+ ...openhandsSignals.evidence,
3120
+ {
3121
+ type: 'openhands-event-file',
3122
+ ref: payload.openhands_events_file,
3123
+ detail: 'mapped'
3124
+ }
3125
+ ])
3126
+ };
3127
+ payload.eventId = events.length > 0
3128
+ ? events[events.length - 1].event_id
3129
+ : null;
3130
+ }
3131
+ payload.events = events;
2309
3132
  printStudioEventsPayload(payload, options);
2310
3133
  return payload;
2311
3134
  }
@@ -2591,6 +3414,7 @@ function registerStudioCommands(program) {
2591
3414
  .description('Show studio job event stream')
2592
3415
  .option('--job <job-id>', 'Studio job id (defaults to latest)')
2593
3416
  .option('--limit <number>', 'Maximum number of recent events to return', '50')
3417
+ .option('--openhands-events <path>', 'Optional OpenHands raw events file (.json/.jsonl) mapped to task stream')
2594
3418
  .option('--json', 'Print machine-readable JSON output')
2595
3419
  .action(async (options) => runStudioCommand(runStudioEventsCommand, options, 'events'));
2596
3420