orchestrix-yuri 4.6.3 โ 4.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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
|
/**
|
|
@@ -763,6 +819,29 @@ class PhaseOrchestrator {
|
|
|
763
819
|
}
|
|
764
820
|
|
|
765
821
|
this._phase = null;
|
|
822
|
+
|
|
823
|
+
// Update state files so auto-reporter stops
|
|
824
|
+
try {
|
|
825
|
+
const phase3Path = path.join(this._projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
826
|
+
if (fs.existsSync(phase3Path)) {
|
|
827
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
828
|
+
phase3.status = 'complete';
|
|
829
|
+
phase3.completed_at = new Date().toISOString();
|
|
830
|
+
fs.writeFileSync(phase3Path, yaml.dump(phase3, { lineWidth: -1 }));
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
834
|
+
if (fs.existsSync(focusPath)) {
|
|
835
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
836
|
+
focus.step = 'phase3.complete';
|
|
837
|
+
focus.pulse = 'Phase 3 complete, all stories done';
|
|
838
|
+
focus.updated_at = new Date().toISOString();
|
|
839
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
840
|
+
}
|
|
841
|
+
} catch (err) {
|
|
842
|
+
log.warn(`Failed to update state files on dev complete: ${err.message}`);
|
|
843
|
+
}
|
|
844
|
+
|
|
766
845
|
log.engine('Dev phase complete');
|
|
767
846
|
this.onComplete('develop', '๐ Development complete! All stories finished.\n\nRun *test to start smoke testing.');
|
|
768
847
|
}
|
|
@@ -772,6 +851,8 @@ class PhaseOrchestrator {
|
|
|
772
851
|
/**
|
|
773
852
|
* Start test phase: QA smoke test per epic, auto fix-retest loop.
|
|
774
853
|
* Uses existing dev session's QA window (3) and Dev window (2).
|
|
854
|
+
*
|
|
855
|
+
* State machine subPhases: qa-loading โ qa-testing โ (on fail) dev-loading โ dev-fixing โ qa-loading ...
|
|
775
856
|
*/
|
|
776
857
|
startTest(projectRoot) {
|
|
777
858
|
if (this._phase) {
|
|
@@ -780,11 +861,11 @@ class PhaseOrchestrator {
|
|
|
780
861
|
|
|
781
862
|
this._projectRoot = projectRoot;
|
|
782
863
|
this._phase = 'test';
|
|
783
|
-
this._step = 0;
|
|
784
864
|
this._lastHash = '';
|
|
785
865
|
this._stableCount = 0;
|
|
866
|
+
this._testBusy = false;
|
|
786
867
|
|
|
787
|
-
// Ensure dev session exists (QA is window 3)
|
|
868
|
+
// Ensure dev session exists (QA is window 3, Dev is window 2)
|
|
788
869
|
try {
|
|
789
870
|
const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
|
|
790
871
|
const result = execSync(`bash "${scriptPath}" dev "${projectRoot}"`, {
|
|
@@ -797,44 +878,350 @@ class PhaseOrchestrator {
|
|
|
797
878
|
return `โ Failed to ensure dev session: ${err.message}`;
|
|
798
879
|
}
|
|
799
880
|
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
881
|
+
// Collect epic list from PRD files
|
|
882
|
+
const prdDir = path.join(projectRoot, 'docs', 'prd');
|
|
883
|
+
let epicIds = [];
|
|
884
|
+
try {
|
|
885
|
+
if (fs.existsSync(prdDir)) {
|
|
886
|
+
epicIds = fs.readdirSync(prdDir)
|
|
887
|
+
.filter((f) => /^epic-\d+/.test(f) && f.endsWith('.yaml'))
|
|
888
|
+
.map((f) => f.replace(/^epic-/, '').replace(/\.yaml$/, ''))
|
|
889
|
+
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
|
|
890
|
+
}
|
|
891
|
+
} catch { /* ignore */ }
|
|
892
|
+
|
|
893
|
+
if (epicIds.length === 0) {
|
|
894
|
+
this._phase = null;
|
|
895
|
+
return 'โ No epic files found in docs/prd/. Nothing to test.';
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Initialize or resume phase4.yaml state
|
|
899
|
+
const phase4Path = path.join(projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
900
|
+
let startIdx = 0;
|
|
901
|
+
try {
|
|
902
|
+
if (fs.existsSync(phase4Path)) {
|
|
903
|
+
const existing = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
904
|
+
if (existing.status === 'in_progress' && Array.isArray(existing.epics)) {
|
|
905
|
+
// Resume: find first non-passed epic
|
|
906
|
+
const idx = epicIds.findIndex((id) => {
|
|
907
|
+
const e = existing.epics.find((ep) => String(ep.id) === String(id));
|
|
908
|
+
return !e || e.status !== 'passed';
|
|
909
|
+
});
|
|
910
|
+
if (idx >= 0) startIdx = idx;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Write initial state
|
|
915
|
+
const epicsState = epicIds.map((id, i) => ({
|
|
916
|
+
id, status: i < startIdx ? 'passed' : 'pending', rounds: 0, last_tested_at: '',
|
|
917
|
+
}));
|
|
918
|
+
const state = { status: 'in_progress', started_at: new Date().toISOString(), completed_at: '', epics: epicsState, regression_rounds: 0 };
|
|
919
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
920
|
+
} catch (err) {
|
|
921
|
+
log.warn(`Failed to init phase4.yaml: ${err.message}`);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Update focus.yaml
|
|
925
|
+
try {
|
|
926
|
+
const focusPath = path.join(projectRoot, '.yuri', 'focus.yaml');
|
|
927
|
+
if (fs.existsSync(focusPath)) {
|
|
928
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
929
|
+
focus.phase = 4;
|
|
930
|
+
focus.step = 'testing';
|
|
931
|
+
focus.action = 'starting smoke tests';
|
|
932
|
+
focus.pulse = `Phase 4: testing 0/${epicIds.length} epics`;
|
|
933
|
+
focus.updated_at = new Date().toISOString();
|
|
934
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
935
|
+
}
|
|
936
|
+
} catch { /* ignore */ }
|
|
937
|
+
|
|
938
|
+
// Initialize test context
|
|
939
|
+
this._testContext = {
|
|
940
|
+
epicIds,
|
|
941
|
+
epicIdx: startIdx,
|
|
942
|
+
round: 0,
|
|
943
|
+
maxRounds: 3,
|
|
944
|
+
subPhase: null, // set by _loadAndTestEpic
|
|
945
|
+
results: {},
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
// Start first epic test
|
|
949
|
+
this._loadAndTestEpic();
|
|
806
950
|
|
|
807
951
|
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
808
952
|
this._timer = setInterval(() => this._pollTest(), pollInterval);
|
|
809
953
|
|
|
810
|
-
log.engine(`Test phase started: session=${this._session}`);
|
|
811
|
-
return
|
|
954
|
+
log.engine(`Test phase started: session=${this._session}, epics=${epicIds.join(',')}, startIdx=${startIdx}`);
|
|
955
|
+
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
956
|
}
|
|
813
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Reload QA agent and send *smoke-test for current epic.
|
|
960
|
+
*/
|
|
961
|
+
_loadAndTestEpic() {
|
|
962
|
+
const ctx = this._testContext;
|
|
963
|
+
const epicId = ctx.epicIds[ctx.epicIdx];
|
|
964
|
+
|
|
965
|
+
this._testBusy = true;
|
|
966
|
+
this._lastHash = '';
|
|
967
|
+
this._stableCount = 0;
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
tmx.sendKeysWithEnter(this._session, 3, '/clear');
|
|
971
|
+
execSync('sleep 2');
|
|
972
|
+
tmx.sendKeysWithEnter(this._session, 3, '/o qa');
|
|
973
|
+
execSync('sleep 12');
|
|
974
|
+
tmx.sendKeysWithEnter(this._session, 3, `*smoke-test ${epicId}`);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
this._testBusy = false;
|
|
977
|
+
this._handleError('test', `Failed to start QA for epic ${epicId}: ${err.message}`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
ctx.subPhase = 'qa-testing';
|
|
982
|
+
this._testBusy = false;
|
|
983
|
+
log.engine(`QA testing epic ${epicId} (round ${ctx.round + 1}/${ctx.maxRounds})`);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Reload Dev agent and send *quick-fix for the detected bug.
|
|
988
|
+
*/
|
|
989
|
+
_loadAndFixEpic(bugDesc) {
|
|
990
|
+
this._testBusy = true;
|
|
991
|
+
this._lastHash = '';
|
|
992
|
+
this._stableCount = 0;
|
|
993
|
+
|
|
994
|
+
try {
|
|
995
|
+
tmx.sendKeysWithEnter(this._session, 2, '/clear');
|
|
996
|
+
execSync('sleep 2');
|
|
997
|
+
tmx.sendKeysWithEnter(this._session, 2, '/o dev');
|
|
998
|
+
execSync('sleep 12');
|
|
999
|
+
|
|
1000
|
+
// Escape quotes in bug description
|
|
1001
|
+
const safeBug = (bugDesc || 'smoke test failure').replace(/"/g, '\\"');
|
|
1002
|
+
tmx.sendKeysWithEnter(this._session, 2, `*quick-fix "${safeBug}"`);
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
this._testBusy = false;
|
|
1005
|
+
this._handleError('test', `Failed to start Dev fix: ${err.message}`);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
this._testContext.subPhase = 'dev-fixing';
|
|
1010
|
+
this._testBusy = false;
|
|
1011
|
+
log.engine(`Dev fixing: ${bugDesc}`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Capture QA pane output and determine PASS/FAIL.
|
|
1016
|
+
*/
|
|
1017
|
+
_evaluateTestResult() {
|
|
1018
|
+
const output = tmx.capturePane(this._session, 3, 200);
|
|
1019
|
+
const lines = output.split('\n');
|
|
1020
|
+
const tail = lines.slice(-50).join('\n');
|
|
1021
|
+
|
|
1022
|
+
// PASS indicators
|
|
1023
|
+
if (/all\s+tests?\s+passed/i.test(tail) || /โ
.*pass/i.test(tail) || /smoke.*pass/i.test(tail) || /PASS/i.test(tail)) {
|
|
1024
|
+
return { passed: true, bugDesc: null };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// FAIL: extract bug description near failure markers
|
|
1028
|
+
let bugDesc = 'smoke test failure';
|
|
1029
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 50); i--) {
|
|
1030
|
+
if (/fail|โ|error|FAIL/i.test(lines[i])) {
|
|
1031
|
+
// Take up to 3 lines around the failure for context
|
|
1032
|
+
const start = Math.max(0, i - 1);
|
|
1033
|
+
const end = Math.min(lines.length, i + 2);
|
|
1034
|
+
bugDesc = lines.slice(start, end).join(' ').trim().substring(0, 200);
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return { passed: false, bugDesc };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* State machine poll for test phase.
|
|
1044
|
+
*/
|
|
814
1045
|
_pollTest() {
|
|
815
1046
|
if (this._phase !== 'test') return;
|
|
1047
|
+
if (this._testBusy) return;
|
|
1048
|
+
|
|
1049
|
+
const ctx = this._testContext;
|
|
1050
|
+
if (!ctx) return;
|
|
816
1051
|
|
|
817
1052
|
if (!tmx.hasSession(this._session)) {
|
|
818
1053
|
this._handleError('test', 'tmux session died');
|
|
819
1054
|
return;
|
|
820
1055
|
}
|
|
821
1056
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1057
|
+
// Determine which window to watch
|
|
1058
|
+
const window = ctx.subPhase === 'dev-fixing' ? 2 : 3;
|
|
1059
|
+
const result = tmx.checkCompletion(this._session, window, this._lastHash);
|
|
1060
|
+
|
|
1061
|
+
// Not done yet
|
|
1062
|
+
if (result.status !== 'complete' && !(result.status === 'stable' && ++this._stableCount >= 3)) {
|
|
1063
|
+
if (result.status !== 'stable') { this._stableCount = 0; this._lastHash = result.hash || ''; }
|
|
1064
|
+
else { this._lastHash = result.hash; }
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Agent finished โ reset counters
|
|
1069
|
+
this._stableCount = 0;
|
|
1070
|
+
this._lastHash = '';
|
|
1071
|
+
|
|
1072
|
+
const epicId = ctx.epicIds[ctx.epicIdx];
|
|
1073
|
+
|
|
1074
|
+
if (ctx.subPhase === 'qa-testing') {
|
|
1075
|
+
const testResult = this._evaluateTestResult();
|
|
1076
|
+
|
|
1077
|
+
if (testResult.passed) {
|
|
1078
|
+
ctx.results[epicId] = { status: 'passed', rounds: ctx.round + 1 };
|
|
1079
|
+
this._updateTestState(epicId, 'passed', ctx.round + 1);
|
|
1080
|
+
this.onProgress(`โ
Epic ${epicId} passed (${ctx.round + 1} round(s))`);
|
|
1081
|
+
log.engine(`Epic ${epicId} PASSED after ${ctx.round + 1} round(s)`);
|
|
1082
|
+
this._advanceToNextEpic();
|
|
1083
|
+
} else if (ctx.round + 1 >= ctx.maxRounds) {
|
|
1084
|
+
ctx.results[epicId] = { status: 'failed', rounds: ctx.round + 1 };
|
|
1085
|
+
this._updateTestState(epicId, 'failed', ctx.round + 1);
|
|
1086
|
+
this.onProgress(`โ Epic ${epicId} failed after ${ctx.round + 1} rounds.\nLast error: ${testResult.bugDesc}`);
|
|
1087
|
+
log.engine(`Epic ${epicId} FAILED after ${ctx.round + 1} rounds`);
|
|
1088
|
+
this._advanceToNextEpic();
|
|
1089
|
+
} else {
|
|
1090
|
+
ctx.round++;
|
|
1091
|
+
this.onProgress(`๏ฟฝ๏ฟฝ๏ฟฝ๏ธ Epic ${epicId} failed (round ${ctx.round}/${ctx.maxRounds}). Sending to Dev for fix...`);
|
|
1092
|
+
log.engine(`Epic ${epicId} failed round ${ctx.round}, sending to Dev`);
|
|
1093
|
+
this._loadAndFixEpic(testResult.bugDesc);
|
|
1094
|
+
}
|
|
1095
|
+
} else if (ctx.subPhase === 'dev-fixing') {
|
|
1096
|
+
this.onProgress(`๐ง Dev fix done for epic ${epicId}. Retesting...`);
|
|
1097
|
+
log.engine(`Dev fix done for epic ${epicId}, retesting`);
|
|
1098
|
+
this._loadAndTestEpic();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Advance to next epic or complete test phase.
|
|
1104
|
+
*/
|
|
1105
|
+
_advanceToNextEpic() {
|
|
1106
|
+
const ctx = this._testContext;
|
|
1107
|
+
ctx.epicIdx++;
|
|
1108
|
+
ctx.round = 0;
|
|
1109
|
+
|
|
1110
|
+
const tested = ctx.epicIdx;
|
|
1111
|
+
const total = ctx.epicIds.length;
|
|
1112
|
+
|
|
1113
|
+
// Update focus pulse
|
|
1114
|
+
try {
|
|
1115
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
1116
|
+
if (fs.existsSync(focusPath)) {
|
|
1117
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
1118
|
+
focus.pulse = `Phase 4: ${tested}/${total} epics tested`;
|
|
1119
|
+
focus.updated_at = new Date().toISOString();
|
|
1120
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
1121
|
+
}
|
|
1122
|
+
} catch { /* ignore */ }
|
|
1123
|
+
|
|
1124
|
+
if (ctx.epicIdx >= ctx.epicIds.length) {
|
|
826
1125
|
this._completeTest();
|
|
827
1126
|
return;
|
|
828
1127
|
}
|
|
829
|
-
|
|
830
|
-
|
|
1128
|
+
|
|
1129
|
+
// Start next epic
|
|
1130
|
+
this._loadAndTestEpic();
|
|
831
1131
|
}
|
|
832
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* Update a single epic's status in phase4.yaml.
|
|
1135
|
+
*/
|
|
1136
|
+
_updateTestState(epicId, status, rounds) {
|
|
1137
|
+
try {
|
|
1138
|
+
const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
1139
|
+
if (!fs.existsSync(phase4Path)) return;
|
|
1140
|
+
|
|
1141
|
+
const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
1142
|
+
if (!Array.isArray(state.epics)) return;
|
|
1143
|
+
|
|
1144
|
+
const epic = state.epics.find((e) => String(e.id) === String(epicId));
|
|
1145
|
+
if (epic) {
|
|
1146
|
+
epic.status = status;
|
|
1147
|
+
epic.rounds = rounds;
|
|
1148
|
+
epic.last_tested_at = new Date().toISOString();
|
|
1149
|
+
}
|
|
1150
|
+
state.regression_rounds = (state.regression_rounds || 0) + rounds;
|
|
1151
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
log.warn(`Failed to update phase4.yaml for epic ${epicId}: ${err.message}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Complete test phase: aggregate results, update state files, send summary.
|
|
1159
|
+
*/
|
|
833
1160
|
_completeTest() {
|
|
834
1161
|
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
|
1162
|
+
|
|
1163
|
+
const ctx = this._testContext;
|
|
1164
|
+
const passed = Object.values(ctx.results).filter((r) => r.status === 'passed').length;
|
|
1165
|
+
const failed = Object.values(ctx.results).filter((r) => r.status === 'failed').length;
|
|
1166
|
+
const total = ctx.epicIds.length;
|
|
1167
|
+
|
|
1168
|
+
// Update phase4.yaml
|
|
1169
|
+
try {
|
|
1170
|
+
const phase4Path = path.join(this._projectRoot, '.yuri', 'state', 'phase4.yaml');
|
|
1171
|
+
if (fs.existsSync(phase4Path)) {
|
|
1172
|
+
const state = yaml.load(fs.readFileSync(phase4Path, 'utf8')) || {};
|
|
1173
|
+
state.status = failed > 0 ? 'complete_with_failures' : 'complete';
|
|
1174
|
+
state.completed_at = new Date().toISOString();
|
|
1175
|
+
fs.writeFileSync(phase4Path, yaml.dump(state, { lineWidth: -1 }));
|
|
1176
|
+
}
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
log.warn(`Failed to finalize phase4.yaml: ${err.message}`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Update focus.yaml
|
|
1182
|
+
try {
|
|
1183
|
+
const focusPath = path.join(this._projectRoot, '.yuri', 'focus.yaml');
|
|
1184
|
+
if (fs.existsSync(focusPath)) {
|
|
1185
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
1186
|
+
focus.step = 'phase4.complete';
|
|
1187
|
+
focus.pulse = `Phase 4 complete: ${passed}/${total} passed`;
|
|
1188
|
+
focus.updated_at = new Date().toISOString();
|
|
1189
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
1190
|
+
}
|
|
1191
|
+
} catch { /* ignore */ }
|
|
1192
|
+
|
|
1193
|
+
// Append timeline event
|
|
1194
|
+
try {
|
|
1195
|
+
const timelinePath = path.join(this._projectRoot, '.yuri', 'timeline', 'events.jsonl');
|
|
1196
|
+
const dir = path.dirname(timelinePath);
|
|
1197
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1198
|
+
const event = JSON.stringify({ ts: new Date().toISOString(), type: 'phase_completed', phase: 4, passed, failed, total });
|
|
1199
|
+
fs.appendFileSync(timelinePath, event + '\n');
|
|
1200
|
+
} catch { /* ignore */ }
|
|
1201
|
+
|
|
1202
|
+
// Build summary
|
|
1203
|
+
const lines = [`๐งช Testing complete! ${passed}/${total} epics passed.\n`];
|
|
1204
|
+
for (const epicId of ctx.epicIds) {
|
|
1205
|
+
const r = ctx.results[epicId];
|
|
1206
|
+
if (r && r.status === 'passed') {
|
|
1207
|
+
lines.push(` โ
Epic ${epicId} โ passed (${r.rounds} round(s))`);
|
|
1208
|
+
} else if (r && r.status === 'failed') {
|
|
1209
|
+
lines.push(` โ Epic ${epicId} โ failed after ${r.rounds} rounds`);
|
|
1210
|
+
} else {
|
|
1211
|
+
lines.push(` โญ๏ธ Epic ${epicId} โ skipped`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (failed === 0) {
|
|
1216
|
+
lines.push('\n๐ All tests passed! Run *deploy when ready.');
|
|
1217
|
+
} else {
|
|
1218
|
+
lines.push(`\nโ ๏ธ ${failed} epic(s) failed. Review and fix manually, or run *test again.`);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
835
1221
|
this._phase = null;
|
|
836
|
-
|
|
837
|
-
|
|
1222
|
+
this._testContext = null;
|
|
1223
|
+
log.engine(`Test phase complete: ${passed}/${total} passed, ${failed} failed`);
|
|
1224
|
+
this.onComplete('test', lines.join('\n'));
|
|
838
1225
|
}
|
|
839
1226
|
|
|
840
1227
|
// โโ Iterate (New Iteration) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
package/lib/gateway/router.js
CHANGED
|
@@ -101,6 +101,13 @@ class Router {
|
|
|
101
101
|
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
102
102
|
if (parseInt(focus.phase, 10) !== 3) return;
|
|
103
103
|
|
|
104
|
+
// Skip if dev phase already complete
|
|
105
|
+
const phase3Path = path.join(projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
106
|
+
if (fs.existsSync(phase3Path)) {
|
|
107
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
108
|
+
if (phase3.status === 'complete') return;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
// Check tmux session is alive
|
|
105
112
|
const { execSync } = require('child_process');
|
|
106
113
|
const sessions = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf8' }).trim();
|
package/package.json
CHANGED
|
@@ -55,9 +55,9 @@ for f in "$STORIES_DIR"/*.md; do
|
|
|
55
55
|
# Try heading format: "## Status" then next non-empty line
|
|
56
56
|
status=$(awk '/^## Status/{found=1; next} found && /^[[:space:]]*$/{next} found{print; exit}' "$f" 2>/dev/null)
|
|
57
57
|
|
|
58
|
-
# Fallback: inline "Status:
|
|
58
|
+
# Fallback: inline "status: Done" or "Status: Done" (case-insensitive)
|
|
59
59
|
if [ -z "$status" ]; then
|
|
60
|
-
status=$(grep -m1 -
|
|
60
|
+
status=$(grep -m1 -oiE 'status:\s*\S+' "$f" 2>/dev/null | sed 's/[Ss]tatus:\s*//')
|
|
61
61
|
fi
|
|
62
62
|
|
|
63
63
|
# Normalize to lowercase, strip whitespace
|