@@ -39,6 +39,9 @@ pub enum PipelinePhaseCmd {
3939 /// Self-review score (required for plan_review and impl_review).
4040 #[ arg( long) ]
4141 score : Option < u32 > ,
42+ /// Review evidence text (required with --score). Must contain actual Q&A content.
43+ #[ arg( long) ]
44+ evidence : Option < String > ,
4245 /// Confirms guard was executed (required for work and close).
4346 #[ arg( long) ]
4447 guard_ran : bool ,
@@ -52,8 +55,8 @@ pub enum PipelinePhaseCmd {
5255pub fn dispatch_pipeline_phase ( cmd : & PipelinePhaseCmd , json : bool ) {
5356 match cmd {
5457 PipelinePhaseCmd :: Next { epic } => cmd_phase_next ( json, epic) ,
55- PipelinePhaseCmd :: Done { epic, phase, score, guard_ran, no_gate } => {
56- cmd_phase_done ( json, epic, phase, * score, * guard_ran, * no_gate)
58+ PipelinePhaseCmd :: Done { epic, phase, score, evidence , guard_ran, no_gate } => {
59+ cmd_phase_done ( json, epic, phase, * score, evidence . as_deref ( ) , * guard_ran, * no_gate)
5760 }
5861 }
5962}
@@ -70,6 +73,43 @@ fn get_or_init_phase(flow_dir: &std::path::Path, epic_id: &str) -> PipelinePhase
7073 }
7174}
7275
76+ /// Validate that score evidence contains per-question scores (Q1:N Q2:N ...).
77+ fn validate_score_evidence ( evidence : & str , expected_questions : usize , phase : & str ) {
78+ // Count Q-score entries like "Q1:3" or "Q1: 3"
79+ let q_count = evidence
80+ . split_whitespace ( )
81+ . filter ( |token| {
82+ token. starts_with ( 'Q' ) && token. contains ( ':' )
83+ && token. len ( ) >= 3
84+ } )
85+ . count ( ) ;
86+
87+ if q_count < expected_questions {
88+ error_exit ( & format ! (
89+ "{phase} evidence must contain scores for all {expected_questions} questions.\n \
90+ Found {q_count} question scores. Expected format: \" Q1:3 Q2:2 Q3:3 ...\" \n \
91+ You must actually run each forcing question before scoring it."
92+ ) ) ;
93+ }
94+
95+ // Verify the total matches --score
96+ let total: u32 = evidence
97+ . split_whitespace ( )
98+ . filter_map ( |token| {
99+ if token. starts_with ( 'Q' ) && token. contains ( ':' ) {
100+ token. split ( ':' ) . nth ( 1 ) ?. trim ( ) . parse :: < u32 > ( ) . ok ( )
101+ } else {
102+ None
103+ }
104+ } )
105+ . sum ( ) ;
106+
107+ // Just warn if total doesn't match (don't block — allow rounding)
108+ if total > 0 {
109+ eprintln ! ( "Evidence total: {total}/{} (from {q_count} questions)" , expected_questions * 3 ) ;
110+ }
111+ }
112+
73113/// Update pipeline phase in file store.
74114fn update_phase ( flow_dir : & std:: path:: Path , epic_id : & str , new_phase : & PipelinePhase ) {
75115 if let Err ( e) = json_store:: pipeline_write ( flow_dir, epic_id, new_phase. as_str ( ) ) {
@@ -99,7 +139,7 @@ fn cmd_phase_next(json: bool, epic_id: &str) {
99139}
100140
101141/// `flowctl phase done --epic <id> --phase <name> [--score N] [--guard-ran] [--no-gate] --json`
102- fn cmd_phase_done ( json : bool , epic_id : & str , phase_name : & str , score : Option < u32 > , guard_ran : bool , no_gate : bool ) {
142+ fn cmd_phase_done ( json : bool , epic_id : & str , phase_name : & str , score : Option < u32 > , evidence : Option < & str > , guard_ran : bool , no_gate : bool ) {
103143 let flow_dir = ensure_flow_exists ( ) ;
104144
105145 let requested = match PipelinePhase :: parse ( phase_name) {
@@ -134,9 +174,17 @@ fn cmd_phase_done(json: bool, epic_id: &str, phase_name: &str, score: Option<u32
134174 PipelinePhase :: PlanReview => {
135175 if score. is_none ( ) {
136176 error_exit (
137- "plan_review requires --score N (self-review score out of 30).\n \
138- Run the 10 forcing questions, compute the total, then:\n \
139- $FLOWCTL phase done --epic ID --phase plan_review --score 25 --json"
177+ "plan_review requires --score N AND --evidence \" Q1:3 Q2:2 ...\" (scores per question).\n \
178+ Run the 10 forcing questions, score each 1-3, then:\n \
179+ $FLOWCTL phase done --epic ID --phase plan_review --score 25 --evidence \" Q1:3 Q2:2 Q3:3 Q4:2 Q5:3 Q6:2 Q7:3 Q8:2 Q9:3 Q10:2\" --json"
180+ ) ;
181+ }
182+ if let Some ( ev) = evidence {
183+ validate_score_evidence ( ev, 10 , "plan_review" ) ;
184+ } else if score. is_some ( ) {
185+ error_exit (
186+ "plan_review requires --evidence with per-question scores.\n \
187+ Example: --evidence \" Q1:3 Q2:2 Q3:3 Q4:2 Q5:3 Q6:2 Q7:3 Q8:2 Q9:3 Q10:2\" "
140188 ) ;
141189 }
142190 }
@@ -152,9 +200,17 @@ fn cmd_phase_done(json: bool, epic_id: &str, phase_name: &str, score: Option<u32
152200 PipelinePhase :: ImplReview => {
153201 if score. is_none ( ) {
154202 error_exit (
155- "impl_review requires --score N (self-review score out of 30).\n \
156- Run the 10 forcing questions, compute the total, then:\n \
157- $FLOWCTL phase done --epic ID --phase impl_review --score 25 --json"
203+ "impl_review requires --score N AND --evidence \" Q1:3 Q2:2 ...\" (scores per question).\n \
204+ Run the 10 forcing questions, score each 1-3, then:\n \
205+ $FLOWCTL phase done --epic ID --phase impl_review --score 25 --evidence \" Q1:3 Q2:2 Q3:3 Q4:2 Q5:3 Q6:2 Q7:3 Q8:2 Q9:3 Q10:2\" --json"
206+ ) ;
207+ }
208+ if let Some ( ev) = evidence {
209+ validate_score_evidence ( ev, 10 , "impl_review" ) ;
210+ } else if score. is_some ( ) {
211+ error_exit (
212+ "impl_review requires --evidence with per-question scores.\n \
213+ Example: --evidence \" Q1:3 Q2:2 Q3:3 Q4:2 Q5:3 Q6:2 Q7:3 Q8:2 Q9:3 Q10:2\" "
158214 ) ;
159215 }
160216 }
0 commit comments