orchestrix-yuri 4.6.4 โ†’ 4.6.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.
@@ -38,7 +38,7 @@ class PhaseOrchestrator {
38
38
  this.onError = opts.onError || (() => {});
39
39
  this.config = opts.config || {};
40
40
 
41
- this._phase = null; // 'plan' | 'develop' | null
41
+ this._phase = null; // 'plan' | 'develop' | 'test' | 'iterate' | 'change' | null
42
42
  this._step = 0; // current agent index
43
43
  this._session = null; // tmux session name
44
44
  this._projectRoot = null;
@@ -117,6 +117,62 @@ class PhaseOrchestrator {
117
117
  }
118
118
  }
119
119
  }
120
+
121
+ // Check phase4 (test)
122
+ const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
123
+ if (fs.existsSync(phase4Path)) {
124
+ const phase4 = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
125
+ if (phase4.status === 'in_progress' && Array.isArray(phase4.epics)) {
126
+ // Find a live orchestrix tmux session
127
+ let session = null;
128
+ try {
129
+ const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
130
+ session = sessions.split('\n').find((s) => s.startsWith('orchestrix-'));
131
+ } catch { /* no sessions */ }
132
+
133
+ if (session && tmx.hasSession(session)) {
134
+ const epicIds = phase4.epics.map((e) => String(e.id));
135
+ const firstPending = epicIds.findIndex((id) => {
136
+ const e = phase4.epics.find((ep) => String(ep.id) === id);
137
+ return !e || (e.status !== 'passed' && e.status !== 'failed');
138
+ });
139
+
140
+ if (firstPending >= 0) {
141
+ this._phase = 'test';
142
+ this._projectRoot = projectRoot;
143
+ this._session = session;
144
+ this._lastHash = '';
145
+ this._stableCount = 0;
146
+ this._testBusy = false;
147
+
148
+ // Rebuild results from already-tested epics
149
+ const results = {};
150
+ for (const e of phase4.epics) {
151
+ if (e.status === 'passed' || e.status === 'failed') {
152
+ results[e.id] = { status: e.status, rounds: e.rounds || 0 };
153
+ }
154
+ }
155
+
156
+ this._testContext = {
157
+ epicIds,
158
+ epicIdx: firstPending,
159
+ round: 0,
160
+ maxRounds: 3,
161
+ subPhase: null,
162
+ results,
163
+ };
164
+
165
+ this._loadAndTestEpic();
166
+
167
+ const pollInterval = this.config.phase_poll_interval || 30000;
168
+ this._timer = setInterval(() => this._pollTest(), pollInterval);
169
+
170
+ log.engine(`Recovered test phase: session=${session}, resuming at epic idx ${firstPending}`);
171
+ return;
172
+ }
173
+ }
174
+ }
175
+ }
120
176
  }
121
177
 
122
178
  /**
@@ -182,7 +238,8 @@ class PhaseOrchestrator {
182
238
  }
183
239
 
184
240
  if (this._phase === 'test') {
185
- return { phase: 'test', message: '๐Ÿงช Testing in progress. QA running smoke tests.' };
241
+ const card = this._buildTestProgressCard();
242
+ return { phase: 'test', message: card };
186
243
  }
187
244
 
188
245
  if (this._phase === 'iterate') {
@@ -763,6 +820,29 @@ class PhaseOrchestrator {
763
820
  }
764
821
 
765
822
  this._phase = null;
823
+
824
+ // Update state files so auto-reporter stops
825
+ try {
826
+ const phase3Path = path.join(this._projectRoot, '.yuri', 'state', 'phase3.yaml');
827
+ if (fs.existsSync(phase3Path)) {
828
+ const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
829
+ phase3.status = 'complete';
830
+ phase3.completed_at = new Date().toISOString();
831
+ fs.writeFileSync(phase3Path, yaml.dump(phase3, { lineWidth: -1 }));
832
+ }
833
+
834
+ const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
835
+ if (fs.existsSync(focusPath)) {
836
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
837
+ focus.step = 'phase3.complete';
838
+ focus.pulse = 'Phase 3 complete, all stories done';
839
+ focus.updated_at = new Date().toISOString();
840
+ fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
841
+ }
842
+ } catch (err) {
843
+ log.warn(`Failed to update state files on dev complete: ${err.message}`);
844
+ }
845
+
766
846
  log.engine('Dev phase complete');
767
847
  this.onComplete('develop', '๐ŸŽ‰ Development complete! All stories finished.\n\nRun *test to start smoke testing.');
768
848
  }
@@ -772,6 +852,8 @@ class PhaseOrchestrator {
772
852
  /**
773
853
  * Start test phase: QA smoke test per epic, auto fix-retest loop.
774
854
  * Uses existing dev session's QA window (3) and Dev window (2).
855
+ *
856
+ * State machine subPhases: qa-loading โ†’ qa-testing โ†’ (on fail) dev-loading โ†’ dev-fixing โ†’ qa-loading ...
775
857
  */
776
858
  startTest(projectRoot) {
777
859
  if (this._phase) {
@@ -780,11 +862,11 @@ class PhaseOrchestrator {
780
862
 
781
863
  this._projectRoot = projectRoot;
782
864
  this._phase = 'test';
783
- this._step = 0;
784
865
  this._lastHash = '';
785
866
  this._stableCount = 0;
867
+ this._testBusy = false;
786
868
 
787
- // Ensure dev session exists (QA is window 3)
869
+ // Ensure dev session exists (QA is window 3, Dev is window 2)
788
870
  try {
789
871
  const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
790
872
  const result = execSync(`bash "${scriptPath}" dev "${projectRoot}"`, {
@@ -797,44 +879,418 @@ class PhaseOrchestrator {
797
879
  return `โŒ Failed to ensure dev session: ${err.message}`;
798
880
  }
799
881
 
800
- // Start QA smoke test on first epic
801
- tmx.sendKeysWithEnter(this._session, 3, '/clear');
802
- execSync('sleep 2');
803
- tmx.sendKeysWithEnter(this._session, 3, '/o qa');
804
- execSync('sleep 12');
805
- tmx.sendKeysWithEnter(this._session, 3, '*smoke-test');
882
+ // Collect epic list from PRD files
883
+ const prdDir = path.join(projectRoot, 'docs', 'prd');
884
+ let epicIds = [];
885
+ try {
886
+ if (fs.existsSync(prdDir)) {
887
+ epicIds = fs.readdirSync(prdDir)
888
+ .filter((f) => /^epic-\d+/.test(f) && f.endsWith('.yaml'))
889
+ .map((f) => f.replace(/^epic-/, '').replace(/\.yaml$/, ''))
890
+ .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
891
+ }
892
+ } catch { /* ignore */ }
893
+
894
+ if (epicIds.length === 0) {
895
+ this._phase = null;
896
+ return 'โŒ No epic files found in docs/prd/. Nothing to test.';
897
+ }
898
+
899
+ // Initialize or resume phase4.yaml state
900
+ const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
901
+ let startIdx = 0;
902
+ try {
903
+ if (fs.existsSync(phase4Path)) {
904
+ const existing = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
905
+ if (existing.status === 'in_progress' && Array.isArray(existing.epics)) {
906
+ // Resume: find first non-passed epic
907
+ const idx = epicIds.findIndex((id) => {
908
+ const e = existing.epics.find((ep) => String(ep.id) === String(id));
909
+ return !e || e.status !== 'passed';
910
+ });
911
+ if (idx >= 0) startIdx = idx;
912
+ }
913
+ }
914
+
915
+ // Write initial state
916
+ const epicsState = epicIds.map((id, i) => ({
917
+ id, status: i < startIdx ? 'passed' : 'pending', rounds: 0, last_tested_at: '',
918
+ }));
919
+ const state = { status: 'in_progress', started_at: new Date().toISOString(), completed_at: '', epics: epicsState, regression_rounds: 0 };
920
+ fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
921
+ } catch (err) {
922
+ log.warn(`Failed to init phase4.yaml: ${err.message}`);
923
+ }
924
+
925
+ // Update focus.yaml
926
+ try {
927
+ const focusPath = path.join(projectRoot, '.yuri', 'focus.yaml');
928
+ if (fs.existsSync(focusPath)) {
929
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
930
+ focus.phase = 4;
931
+ focus.step = 'testing';
932
+ focus.action = 'starting smoke tests';
933
+ focus.pulse = `Phase 4: testing 0/${epicIds.length} epics`;
934
+ focus.updated_at = new Date().toISOString();
935
+ fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
936
+ }
937
+ } catch { /* ignore */ }
938
+
939
+ // Initialize test context
940
+ this._testContext = {
941
+ epicIds,
942
+ epicIdx: startIdx,
943
+ round: 0,
944
+ maxRounds: 3,
945
+ subPhase: null, // set by _loadAndTestEpic
946
+ results: {},
947
+ };
948
+
949
+ // Start first epic test
950
+ this._loadAndTestEpic();
951
+ this._testStartedAt = Date.now();
952
+ this._lastReportTime = Date.now();
806
953
 
807
954
  const pollInterval = this.config.phase_poll_interval || 30000;
808
955
  this._timer = setInterval(() => this._pollTest(), pollInterval);
809
956
 
810
- log.engine(`Test phase started: session=${this._session}`);
811
- return '๐Ÿงช Testing started! QA is running smoke tests.\n\nI\'ll notify you of results. Failed tests will auto-trigger Dev fixes.';
957
+ const reportMin = Math.round(this._reportInterval / 60000);
958
+ log.engine(`Test phase started: session=${this._session}, epics=${epicIds.join(',')}, startIdx=${startIdx}, report every ${reportMin}min`);
959
+ return `๐Ÿงช Testing started! ${epicIds.length} epic(s) to test.\n\nQA will smoke-test each epic. Failed tests auto-trigger Dev fixes (max 3 rounds).\nI'll notify you of results.`;
812
960
  }
813
961
 
962
+ /**
963
+ * Reload QA agent and send *smoke-test for current epic.
964
+ */
965
+ _loadAndTestEpic() {
966
+ const ctx = this._testContext;
967
+ const epicId = ctx.epicIds[ctx.epicIdx];
968
+
969
+ this._testBusy = true;
970
+ this._lastHash = '';
971
+ this._stableCount = 0;
972
+
973
+ try {
974
+ tmx.sendKeysWithEnter(this._session, 3, '/clear');
975
+ execSync('sleep 2');
976
+ tmx.sendKeysWithEnter(this._session, 3, '/o qa');
977
+ execSync('sleep 12');
978
+ tmx.sendKeysWithEnter(this._session, 3, `*smoke-test ${epicId}`);
979
+ } catch (err) {
980
+ this._testBusy = false;
981
+ this._handleError('test', `Failed to start QA for epic ${epicId}: ${err.message}`);
982
+ return;
983
+ }
984
+
985
+ ctx.subPhase = 'qa-testing';
986
+ this._testBusy = false;
987
+ log.engine(`QA testing epic ${epicId} (round ${ctx.round + 1}/${ctx.maxRounds})`);
988
+ }
989
+
990
+ /**
991
+ * Reload Dev agent and send *quick-fix for the detected bug.
992
+ */
993
+ _loadAndFixEpic(bugDesc) {
994
+ this._testBusy = true;
995
+ this._lastHash = '';
996
+ this._stableCount = 0;
997
+
998
+ try {
999
+ tmx.sendKeysWithEnter(this._session, 2, '/clear');
1000
+ execSync('sleep 2');
1001
+ tmx.sendKeysWithEnter(this._session, 2, '/o dev');
1002
+ execSync('sleep 12');
1003
+
1004
+ // Escape quotes in bug description
1005
+ const safeBug = (bugDesc || 'smoke test failure').replace(/"/g, '\\"');
1006
+ tmx.sendKeysWithEnter(this._session, 2, `*quick-fix "${safeBug}"`);
1007
+ } catch (err) {
1008
+ this._testBusy = false;
1009
+ this._handleError('test', `Failed to start Dev fix: ${err.message}`);
1010
+ return;
1011
+ }
1012
+
1013
+ this._testContext.subPhase = 'dev-fixing';
1014
+ this._testBusy = false;
1015
+ log.engine(`Dev fixing: ${bugDesc}`);
1016
+ }
1017
+
1018
+ /**
1019
+ * Capture QA pane output and determine PASS/FAIL.
1020
+ */
1021
+ _evaluateTestResult() {
1022
+ const output = tmx.capturePane(this._session, 3, 200);
1023
+ const lines = output.split('\n');
1024
+ const tail = lines.slice(-50).join('\n');
1025
+
1026
+ // PASS indicators
1027
+ if (/all\s+tests?\s+passed/i.test(tail) || /โœ….*pass/i.test(tail) || /smoke.*pass/i.test(tail) || /PASS/i.test(tail)) {
1028
+ return { passed: true, bugDesc: null };
1029
+ }
1030
+
1031
+ // FAIL: extract bug description near failure markers
1032
+ let bugDesc = 'smoke test failure';
1033
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 50); i--) {
1034
+ if (/fail|โŒ|error|FAIL/i.test(lines[i])) {
1035
+ // Take up to 3 lines around the failure for context
1036
+ const start = Math.max(0, i - 1);
1037
+ const end = Math.min(lines.length, i + 2);
1038
+ bugDesc = lines.slice(start, end).join(' ').trim().substring(0, 200);
1039
+ break;
1040
+ }
1041
+ }
1042
+
1043
+ return { passed: false, bugDesc };
1044
+ }
1045
+
1046
+ /**
1047
+ * State machine poll for test phase.
1048
+ */
814
1049
  _pollTest() {
815
1050
  if (this._phase !== 'test') return;
1051
+ if (this._testBusy) return;
1052
+
1053
+ const ctx = this._testContext;
1054
+ if (!ctx) return;
816
1055
 
817
1056
  if (!tmx.hasSession(this._session)) {
818
1057
  this._handleError('test', 'tmux session died');
819
1058
  return;
820
1059
  }
821
1060
 
822
- const result = tmx.checkCompletion(this._session, 3, this._lastHash);
823
- if (result.status === 'complete' || (result.status === 'stable' && ++this._stableCount >= 3)) {
824
- this._stableCount = 0;
825
- this._lastHash = '';
1061
+ // Periodic progress report (reuses dev phase's _reportInterval)
1062
+ const now = Date.now();
1063
+ if (now - this._lastReportTime >= this._reportInterval) {
1064
+ this._lastReportTime = now;
1065
+ const card = this._buildTestProgressCard();
1066
+ this.onProgress(card);
1067
+ }
1068
+
1069
+ // Determine which window to watch
1070
+ const window = ctx.subPhase === 'dev-fixing' ? 2 : 3;
1071
+ const result = tmx.checkCompletion(this._session, window, this._lastHash);
1072
+
1073
+ // Not done yet
1074
+ if (result.status !== 'complete' && !(result.status === 'stable' && ++this._stableCount >= 3)) {
1075
+ if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
1076
+ else { this._lastHash = result.hash; }
1077
+ return;
1078
+ }
1079
+
1080
+ // Agent finished โ€” reset counters
1081
+ this._stableCount = 0;
1082
+ this._lastHash = '';
1083
+
1084
+ const epicId = ctx.epicIds[ctx.epicIdx];
1085
+
1086
+ if (ctx.subPhase === 'qa-testing') {
1087
+ const testResult = this._evaluateTestResult();
1088
+
1089
+ if (testResult.passed) {
1090
+ ctx.results[epicId] = { status: 'passed', rounds: ctx.round + 1 };
1091
+ this._updateTestState(epicId, 'passed', ctx.round + 1);
1092
+ this.onProgress(`โœ… Epic ${epicId} passed (${ctx.round + 1} round(s))`);
1093
+ log.engine(`Epic ${epicId} PASSED after ${ctx.round + 1} round(s)`);
1094
+ this._advanceToNextEpic();
1095
+ } else if (ctx.round + 1 >= ctx.maxRounds) {
1096
+ ctx.results[epicId] = { status: 'failed', rounds: ctx.round + 1 };
1097
+ this._updateTestState(epicId, 'failed', ctx.round + 1);
1098
+ this.onProgress(`โŒ Epic ${epicId} failed after ${ctx.round + 1} rounds.\nLast error: ${testResult.bugDesc}`);
1099
+ log.engine(`Epic ${epicId} FAILED after ${ctx.round + 1} rounds`);
1100
+ this._advanceToNextEpic();
1101
+ } else {
1102
+ ctx.round++;
1103
+ this.onProgress(`๏ฟฝ๏ฟฝ๏ฟฝ๏ธ Epic ${epicId} failed (round ${ctx.round}/${ctx.maxRounds}). Sending to Dev for fix...`);
1104
+ log.engine(`Epic ${epicId} failed round ${ctx.round}, sending to Dev`);
1105
+ this._loadAndFixEpic(testResult.bugDesc);
1106
+ }
1107
+ } else if (ctx.subPhase === 'dev-fixing') {
1108
+ this.onProgress(`๐Ÿ”ง Dev fix done for epic ${epicId}. Retesting...`);
1109
+ log.engine(`Dev fix done for epic ${epicId}, retesting`);
1110
+ this._loadAndTestEpic();
1111
+ }
1112
+ }
1113
+
1114
+ /**
1115
+ * Advance to next epic or complete test phase.
1116
+ */
1117
+ _advanceToNextEpic() {
1118
+ const ctx = this._testContext;
1119
+ ctx.epicIdx++;
1120
+ ctx.round = 0;
1121
+
1122
+ const tested = ctx.epicIdx;
1123
+ const total = ctx.epicIds.length;
1124
+
1125
+ // Update focus pulse
1126
+ try {
1127
+ const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
1128
+ if (fs.existsSync(focusPath)) {
1129
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
1130
+ focus.pulse = `Phase 4: ${tested}/${total} epics tested`;
1131
+ focus.updated_at = new Date().toISOString();
1132
+ fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
1133
+ }
1134
+ } catch { /* ignore */ }
1135
+
1136
+ if (ctx.epicIdx >= ctx.epicIds.length) {
826
1137
  this._completeTest();
827
1138
  return;
828
1139
  }
829
- if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
830
- else { this._lastHash = result.hash; }
1140
+
1141
+ // Start next epic
1142
+ this._loadAndTestEpic();
1143
+ }
1144
+
1145
+ /**
1146
+ * Update a single epic's status in phase4.yaml.
1147
+ */
1148
+ _updateTestState(epicId, status, rounds) {
1149
+ try {
1150
+ const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
1151
+ if (!fs.existsSync(phase4Path)) return;
1152
+
1153
+ const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
1154
+ if (!Array.isArray(state.epics)) return;
1155
+
1156
+ const epic = state.epics.find((e) => String(e.id) === String(epicId));
1157
+ if (epic) {
1158
+ epic.status = status;
1159
+ epic.rounds = rounds;
1160
+ epic.last_tested_at = new Date().toISOString();
1161
+ }
1162
+ state.regression_rounds = (state.regression_rounds || 0) + rounds;
1163
+ fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
1164
+ } catch (err) {
1165
+ log.warn(`Failed to update phase4.yaml for epic ${epicId}: ${err.message}`);
1166
+ }
831
1167
  }
832
1168
 
1169
+ /**
1170
+ * Build a progress card for the test phase (used by getStatus + periodic reports).
1171
+ */
1172
+ _buildTestProgressCard() {
1173
+ const ctx = this._testContext;
1174
+ if (!ctx) return '๐Ÿงช Testing in progress.';
1175
+
1176
+ const total = ctx.epicIds.length;
1177
+ const passed = Object.values(ctx.results).filter((r) => r.status === 'passed').length;
1178
+ const failed = Object.values(ctx.results).filter((r) => r.status === 'failed').length;
1179
+ const tested = passed + failed;
1180
+ const pct = total > 0 ? Math.round((tested / total) * 100) : 0;
1181
+
1182
+ const currentEpic = ctx.epicIds[ctx.epicIdx];
1183
+ const subPhaseLabel = {
1184
+ 'qa-testing': '๐Ÿ” QA smoke-testing',
1185
+ 'dev-fixing': '๐Ÿ”ง Dev fixing bug',
1186
+ }[ctx.subPhase] || 'โณ Loading agent';
1187
+
1188
+ const elapsed = this._devStartedAt ? this._formatDuration(Date.now() - this._devStartedAt) : '';
1189
+ const startedAt = this._testStartedAt ? this._formatDuration(Date.now() - this._testStartedAt) : '';
1190
+
1191
+ const lines = [
1192
+ `๐Ÿงช **Test Phase Progress**`,
1193
+ ``,
1194
+ this._progressBar(pct),
1195
+ ``,
1196
+ `๐Ÿ“Š Epics: ${tested}/${total} tested (โœ… ${passed} passed, โŒ ${failed} failed)`,
1197
+ ];
1198
+
1199
+ if (ctx.epicIdx < total) {
1200
+ lines.push(`๐ŸŽฏ Current: Epic ${currentEpic} โ€” ${subPhaseLabel} (round ${ctx.round + 1}/${ctx.maxRounds})`);
1201
+ }
1202
+
1203
+ // Per-epic summary
1204
+ if (tested > 0) {
1205
+ lines.push('', '๐Ÿ“‹ Results:');
1206
+ for (const epicId of ctx.epicIds) {
1207
+ const r = ctx.results[epicId];
1208
+ if (r && r.status === 'passed') {
1209
+ lines.push(` โœ… Epic ${epicId} โ€” passed (${r.rounds} round(s))`);
1210
+ } else if (r && r.status === 'failed') {
1211
+ lines.push(` โŒ Epic ${epicId} โ€” failed (${r.rounds} rounds)`);
1212
+ } else if (epicId === currentEpic) {
1213
+ lines.push(` โณ Epic ${epicId} โ€” in progress`);
1214
+ } else {
1215
+ lines.push(` โฌœ Epic ${epicId} โ€” pending`);
1216
+ }
1217
+ }
1218
+ }
1219
+
1220
+ if (startedAt) lines.push(`\nโฑ๏ธ Elapsed: ${startedAt}`);
1221
+
1222
+ return lines.join('\n');
1223
+ }
1224
+
1225
+ /**
1226
+ * Complete test phase: aggregate results, update state files, send summary.
1227
+ */
833
1228
  _completeTest() {
834
1229
  if (this._timer) { clearInterval(this._timer); this._timer = null; }
1230
+
1231
+ const ctx = this._testContext;
1232
+ const passed = Object.values(ctx.results).filter((r) => r.status === 'passed').length;
1233
+ const failed = Object.values(ctx.results).filter((r) => r.status === 'failed').length;
1234
+ const total = ctx.epicIds.length;
1235
+
1236
+ // Update phase4.yaml
1237
+ try {
1238
+ const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
1239
+ if (fs.existsSync(phase4Path)) {
1240
+ const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
1241
+ state.status = failed > 0 ? 'complete_with_failures' : 'complete';
1242
+ state.completed_at = new Date().toISOString();
1243
+ fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
1244
+ }
1245
+ } catch (err) {
1246
+ log.warn(`Failed to finalize phase4.yaml: ${err.message}`);
1247
+ }
1248
+
1249
+ // Update focus.yaml
1250
+ try {
1251
+ const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
1252
+ if (fs.existsSync(focusPath)) {
1253
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
1254
+ focus.step = 'phase4.complete';
1255
+ focus.pulse = `Phase 4 complete: ${passed}/${total} passed`;
1256
+ focus.updated_at = new Date().toISOString();
1257
+ fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
1258
+ }
1259
+ } catch { /* ignore */ }
1260
+
1261
+ // Append timeline event
1262
+ try {
1263
+ const timelinePath = path.join(this._projectRoot, '.yuri', 'timeline', 'events.jsonl');
1264
+ const dir = path.dirname(timelinePath);
1265
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1266
+ const event = JSON.stringify({ ts: new Date().toISOString(), type: 'phase_completed', phase: 4, passed, failed, total });
1267
+ fs.appendFileSync(timelinePath, event + '\n');
1268
+ } catch { /* ignore */ }
1269
+
1270
+ // Build summary
1271
+ const lines = [`๐Ÿงช Testing complete! ${passed}/${total} epics passed.\n`];
1272
+ for (const epicId of ctx.epicIds) {
1273
+ const r = ctx.results[epicId];
1274
+ if (r && r.status === 'passed') {
1275
+ lines.push(` โœ… Epic ${epicId} โ€” passed (${r.rounds} round(s))`);
1276
+ } else if (r && r.status === 'failed') {
1277
+ lines.push(` โŒ Epic ${epicId} โ€” failed after ${r.rounds} rounds`);
1278
+ } else {
1279
+ lines.push(` โญ๏ธ Epic ${epicId} โ€” skipped`);
1280
+ }
1281
+ }
1282
+
1283
+ if (failed === 0) {
1284
+ lines.push('\n๐Ÿš€ All tests passed! Run *deploy when ready.');
1285
+ } else {
1286
+ lines.push(`\nโš ๏ธ ${failed} epic(s) failed. Review and fix manually, or run *test again.`);
1287
+ }
1288
+
835
1289
  this._phase = null;
836
- log.engine('Test phase complete');
837
- this.onComplete('test', '๐Ÿงช Testing complete! Check QA results.\n\nRun *deploy when ready.');
1290
+ this._testContext = null;
1291
+ this._testStartedAt = null;
1292
+ log.engine(`Test phase complete: ${passed}/${total} passed, ${failed} failed`);
1293
+ this.onComplete('test', lines.join('\n'));
838
1294
  }
839
1295
 
840
1296
  // โ”€โ”€ Iterate (New Iteration) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -99,18 +99,38 @@ class Router {
99
99
 
100
100
  try {
101
101
  const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
102
- if (parseInt(focus.phase, 10) !== 3) return;
103
-
104
- // Check tmux session is alive
105
- const { execSync } = require('child_process');
106
- const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
107
- if (!sessions.split('\n').some((s) => s.startsWith('orchestrix-'))) return;
108
-
109
- // Generate and send progress card
110
- const card = this._buildStatusCard(projectRoot, focus);
111
- if (card) {
112
- log.router('Auto-reporting dev progress');
113
- this._sendProactive(card);
102
+ const phaseNum = parseInt(focus.phase, 10);
103
+
104
+ if (phaseNum === 3) {
105
+ // Skip if dev phase already complete
106
+ const phase3Path = path.join(projectRoot, '.yuri', 'state', 'phase3.yaml');
107
+ if (fs.existsSync(phase3Path)) {
108
+ const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
109
+ if (phase3.status === 'complete') return;
110
+ }
111
+
112
+ // Check tmux session is alive
113
+ const { execSync } = require('child_process');
114
+ const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
115
+ if (!sessions.split('\n').some((s) => s.startsWith('orchestrix-'))) return;
116
+
117
+ const card = this._buildStatusCard(projectRoot, focus);
118
+ if (card) {
119
+ log.router('Auto-reporting dev progress');
120
+ this._sendProactive(card);
121
+ }
122
+ } else if (phaseNum === 4) {
123
+ // Skip if test phase already complete
124
+ const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
125
+ if (!fs.existsSync(phase4Path)) return;
126
+ const phase4 = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
127
+ if (phase4.status === 'complete' || phase4.status === 'complete_with_failures') return;
128
+
129
+ const card = this._buildTestStatusCard(projectRoot);
130
+ if (card) {
131
+ log.router('Auto-reporting test progress');
132
+ this._sendProactive(card);
133
+ }
114
134
  }
115
135
  } catch { /* silent */ }
116
136
  }, interval);
@@ -400,6 +420,16 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
400
420
  }
401
421
  }
402
422
 
423
+ // Test phase: generate test progress card from phase4.yaml
424
+ if (phaseNum === 4 && parts.length === 0) {
425
+ try {
426
+ const card = this._buildTestStatusCard(projectRoot);
427
+ if (card) parts.push(card);
428
+ } catch (err) {
429
+ log.warn(`Test progress card failed: ${err.message}`);
430
+ }
431
+ }
432
+
403
433
  // Fallback or non-dev phase
404
434
  if (parts.length === 0) {
405
435
  if (focus.pulse) parts.push(`Pulse: ${focus.pulse}`);
@@ -817,6 +847,52 @@ Reply with ONLY one word: small, medium, or large. Nothing else.`;
817
847
  return lines.join('\n');
818
848
  }
819
849
 
850
+ // โ”€โ”€ Test Progress Card (from phase4.yaml, for *status when orchestrator is not tracking) โ”€โ”€
851
+
852
+ _buildTestStatusCard(projectRoot) {
853
+ const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
854
+ if (!fs.existsSync(phase4Path)) return null;
855
+
856
+ const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
857
+ if (!Array.isArray(state.epics) || state.epics.length === 0) return null;
858
+
859
+ const total = state.epics.length;
860
+ const passed = state.epics.filter((e) => e.status === 'passed').length;
861
+ const failed = state.epics.filter((e) => e.status === 'failed').length;
862
+ const tested = passed + failed;
863
+ const pct = total > 0 ? Math.round((tested / total) * 100) : 0;
864
+
865
+ const barTotal = 20;
866
+ const filled = Math.round(pct / 100 * barTotal);
867
+ const bar = 'โ–“'.repeat(filled) + 'โ–‘'.repeat(barTotal - filled) + ` ${pct}%`;
868
+
869
+ const lines = [
870
+ '๐Ÿงช **Test Phase Progress**',
871
+ '',
872
+ bar,
873
+ '',
874
+ `๐Ÿ“Š Epics: ${tested}/${total} tested (โœ… ${passed} passed, โŒ ${failed} failed)`,
875
+ '',
876
+ '๐Ÿ“‹ Results:',
877
+ ];
878
+
879
+ for (const epic of state.epics) {
880
+ if (epic.status === 'passed') {
881
+ lines.push(` โœ… Epic ${epic.id} โ€” passed (${epic.rounds || 0} round(s))`);
882
+ } else if (epic.status === 'failed') {
883
+ lines.push(` โŒ Epic ${epic.id} โ€” failed (${epic.rounds || 0} rounds)`);
884
+ } else {
885
+ lines.push(` โฌœ Epic ${epic.id} โ€” ${epic.status || 'pending'}`);
886
+ }
887
+ }
888
+
889
+ if (state.status === 'complete' || state.status === 'complete_with_failures') {
890
+ lines.push(`\nโœ… Testing finished at ${state.completed_at || 'unknown'}`);
891
+ }
892
+
893
+ return lines.join('\n');
894
+ }
895
+
820
896
  // โ”€โ”€ Help โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
821
897
 
822
898
  _buildHelpText() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "4.6.4",
3
+ "version": "4.6.6",
4
4
  "description": "Yuri โ€” Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {