gsd-lite 0.3.1 → 0.3.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.
package/src/schema.js CHANGED
@@ -36,10 +36,11 @@ export const PHASE_LIFECYCLE = {
36
36
  failed: [],
37
37
  };
38
38
 
39
- const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
39
+ export const PHASE_REVIEW_STATUS = ['pending', 'reviewing', 'accepted', 'rework_required'];
40
40
 
41
41
  export const CANONICAL_FIELDS = [
42
42
  'project',
43
+ 'schema_version',
43
44
  'workflow_mode',
44
45
  'plan_version',
45
46
  'git_head',
@@ -133,6 +134,9 @@ export function validateResearchArtifacts(artifacts, { decisionIds = [], volatil
133
134
  }
134
135
 
135
136
  export function validateTransition(entity, from, to) {
137
+ if (entity !== 'task' && entity !== 'phase') {
138
+ return { valid: false, error: `Unknown entity type: ${entity}` };
139
+ }
136
140
  const transitions = entity === 'task' ? TASK_LIFECYCLE : PHASE_LIFECYCLE;
137
141
  if (!transitions[from]) {
138
142
  return { valid: false, error: `Unknown ${entity} state: ${from}` };
@@ -143,25 +147,119 @@ export function validateTransition(entity, from, to) {
143
147
  return { valid: true };
144
148
  }
145
149
 
150
+ /**
151
+ * Incremental validation: only validate changed fields + their relationships.
152
+ * Falls back to full validateState() for complex updates (phases).
153
+ */
154
+ export function validateStateUpdate(state, updates) {
155
+ // For phases updates, fall back to full validation
156
+ if ('phases' in updates) {
157
+ return validateState({ ...state, ...updates });
158
+ }
159
+
160
+ const errors = [];
161
+
162
+ for (const key of Object.keys(updates)) {
163
+ switch (key) {
164
+ case 'workflow_mode':
165
+ if (!WORKFLOW_MODES.includes(updates.workflow_mode)) {
166
+ errors.push(`Invalid workflow_mode: ${updates.workflow_mode}`);
167
+ }
168
+ break;
169
+ case 'current_phase':
170
+ if (!Number.isFinite(updates.current_phase)) {
171
+ errors.push('current_phase must be a finite number');
172
+ }
173
+ break;
174
+ case 'current_task':
175
+ if (updates.current_task !== null && typeof updates.current_task !== 'string') {
176
+ errors.push('current_task must be a string or null');
177
+ }
178
+ break;
179
+ case 'current_review':
180
+ if (updates.current_review !== null && !isPlainObject(updates.current_review)) {
181
+ errors.push('current_review must be an object or null');
182
+ }
183
+ break;
184
+ case 'git_head':
185
+ if (updates.git_head !== null && typeof updates.git_head !== 'string') {
186
+ errors.push('git_head must be a string or null');
187
+ }
188
+ break;
189
+ case 'plan_version':
190
+ if (!Number.isFinite(updates.plan_version)) {
191
+ errors.push('plan_version must be a finite number');
192
+ }
193
+ break;
194
+ case 'schema_version':
195
+ if (!Number.isFinite(updates.schema_version)) {
196
+ errors.push('schema_version must be a finite number');
197
+ }
198
+ break;
199
+ case 'total_phases':
200
+ if (!Number.isFinite(updates.total_phases)) {
201
+ errors.push('total_phases must be a finite number');
202
+ }
203
+ break;
204
+ case 'project':
205
+ if (!updates.project || typeof updates.project !== 'string') {
206
+ errors.push('project must be a non-empty string');
207
+ }
208
+ break;
209
+ case 'decisions':
210
+ if (!Array.isArray(updates.decisions)) {
211
+ errors.push('decisions must be an array');
212
+ }
213
+ break;
214
+ case 'context':
215
+ if (!isPlainObject(updates.context)) {
216
+ errors.push('context must be an object');
217
+ } else {
218
+ const ctx = { ...state.context, ...updates.context };
219
+ if (typeof ctx.last_session !== 'string') errors.push('context.last_session must be a string');
220
+ if (!Number.isFinite(ctx.remaining_percentage)) errors.push('context.remaining_percentage must be a finite number');
221
+ }
222
+ break;
223
+ case 'evidence':
224
+ if (!isPlainObject(updates.evidence)) {
225
+ errors.push('evidence must be an object');
226
+ }
227
+ break;
228
+ case 'research':
229
+ if (updates.research !== null && !isPlainObject(updates.research)) {
230
+ errors.push('research must be null or an object');
231
+ }
232
+ break;
233
+ default:
234
+ errors.push(`Unknown canonical field: ${key}`);
235
+ }
236
+ }
237
+
238
+ return { valid: errors.length === 0, errors };
239
+ }
240
+
146
241
  export function validateState(state) {
147
242
  const errors = [];
148
243
  if (!state.project || typeof state.project !== 'string') {
149
244
  errors.push('project must be a non-empty string');
150
245
  }
246
+ if (!Number.isFinite(state.schema_version)) {
247
+ errors.push('schema_version must be a finite number');
248
+ }
151
249
  if (!WORKFLOW_MODES.includes(state.workflow_mode)) {
152
250
  errors.push(`Invalid workflow_mode: ${state.workflow_mode}`);
153
251
  }
154
- if (typeof state.plan_version !== 'number') {
155
- errors.push('plan_version must be a number');
252
+ if (!Number.isFinite(state.plan_version)) {
253
+ errors.push('plan_version must be a finite number');
156
254
  }
157
- if (typeof state.current_phase !== 'number') {
158
- errors.push('current_phase must be a number');
255
+ if (!Number.isFinite(state.current_phase)) {
256
+ errors.push('current_phase must be a finite number');
159
257
  }
160
258
  if (state.git_head !== null && typeof state.git_head !== 'string') {
161
259
  errors.push('git_head must be a string or null');
162
260
  }
163
- if (typeof state.total_phases !== 'number') {
164
- errors.push('total_phases must be a number');
261
+ if (!Number.isFinite(state.total_phases)) {
262
+ errors.push('total_phases must be a finite number');
165
263
  }
166
264
  if (!Array.isArray(state.phases)) {
167
265
  errors.push('phases must be an array');
@@ -175,8 +273,8 @@ export function validateState(state) {
175
273
  if (typeof state.context.last_session !== 'string') {
176
274
  errors.push('context.last_session must be a string');
177
275
  }
178
- if (typeof state.context.remaining_percentage !== 'number') {
179
- errors.push('context.remaining_percentage must be a number');
276
+ if (!Number.isFinite(state.context.remaining_percentage)) {
277
+ errors.push('context.remaining_percentage must be a finite number');
180
278
  }
181
279
  }
182
280
  if (state.research !== null && !isPlainObject(state.research)) {
@@ -212,6 +310,12 @@ export function validateState(state) {
212
310
  errors.push(...decisionIndexValidation.errors.map((error) => `research.${error}`));
213
311
  }
214
312
  }
313
+ if (state.current_task !== null && typeof state.current_task !== 'string') {
314
+ errors.push('current_task must be a string or null');
315
+ }
316
+ if (state.current_review !== null && !isPlainObject(state.current_review)) {
317
+ errors.push('current_review must be an object or null');
318
+ }
215
319
  if (!isPlainObject(state.evidence)) {
216
320
  errors.push('evidence must be an object');
217
321
  }
@@ -224,8 +328,8 @@ export function validateState(state) {
224
328
  errors.push('phase must be an object');
225
329
  continue;
226
330
  }
227
- if (typeof phase.id !== 'number') {
228
- errors.push('phase.id must be a number');
331
+ if (!Number.isFinite(phase.id)) {
332
+ errors.push('phase.id must be a finite number');
229
333
  }
230
334
  if (!phase.name || typeof phase.name !== 'string') {
231
335
  errors.push(`Phase ${phase.id}: name must be a non-empty string`);
@@ -239,15 +343,15 @@ export function validateState(state) {
239
343
  if (!PHASE_REVIEW_STATUS.includes(phase.phase_review.status)) {
240
344
  errors.push(`Phase ${phase.id}: invalid phase_review.status ${phase.phase_review.status}`);
241
345
  }
242
- if (typeof phase.phase_review.retry_count !== 'number') {
243
- errors.push(`Phase ${phase.id}: phase_review.retry_count must be a number`);
346
+ if (!Number.isFinite(phase.phase_review.retry_count)) {
347
+ errors.push(`Phase ${phase.id}: phase_review.retry_count must be a finite number`);
244
348
  }
245
349
  }
246
- if (typeof phase.tasks !== 'number') {
247
- errors.push(`Phase ${phase.id}: tasks must be a number`);
350
+ if (!Number.isFinite(phase.tasks)) {
351
+ errors.push(`Phase ${phase.id}: tasks must be a finite number`);
248
352
  }
249
- if (typeof phase.done !== 'number') {
250
- errors.push(`Phase ${phase.id}: done must be a number`);
353
+ if (!Number.isFinite(phase.done)) {
354
+ errors.push(`Phase ${phase.id}: done must be a finite number`);
251
355
  }
252
356
  if (!Array.isArray(phase.todo)) {
253
357
  errors.push(`Phase ${phase.id}: todo must be an array`);
@@ -262,8 +366,8 @@ export function validateState(state) {
262
366
  if (typeof phase.phase_handoff.tests_passed !== 'boolean') {
263
367
  errors.push(`Phase ${phase.id}: phase_handoff.tests_passed must be boolean`);
264
368
  }
265
- if (typeof phase.phase_handoff.critical_issues_open !== 'number') {
266
- errors.push(`Phase ${phase.id}: phase_handoff.critical_issues_open must be a number`);
369
+ if (!Number.isFinite(phase.phase_handoff.critical_issues_open)) {
370
+ errors.push(`Phase ${phase.id}: phase_handoff.critical_issues_open must be a finite number`);
267
371
  }
268
372
  if ('direction_ok' in phase.phase_handoff && typeof phase.phase_handoff.direction_ok !== 'boolean') {
269
373
  errors.push(`Phase ${phase.id}: phase_handoff.direction_ok must be boolean when present`);
@@ -289,8 +393,8 @@ export function validateState(state) {
289
393
  if (!Array.isArray(task.requires)) {
290
394
  errors.push(`Task ${task.id}: requires must be an array`);
291
395
  }
292
- if (typeof task.retry_count !== 'number') {
293
- errors.push(`Task ${task.id}: retry_count must be a number`);
396
+ if (!Number.isFinite(task.retry_count)) {
397
+ errors.push(`Task ${task.id}: retry_count must be a finite number`);
294
398
  }
295
399
  if (typeof task.review_required !== 'boolean') {
296
400
  errors.push(`Task ${task.id}: review_required must be a boolean`);
@@ -318,7 +422,7 @@ export function validateState(state) {
318
422
  */
319
423
  export function validateExecutorResult(r) {
320
424
  const errors = [];
321
- if (!r.task_id) errors.push('missing task_id');
425
+ if (typeof r.task_id !== 'string' || r.task_id.length === 0) errors.push('missing task_id');
322
426
  if (!['checkpointed', 'blocked', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
323
427
  if (typeof r.summary !== 'string' || r.summary.length === 0) errors.push('summary must be non-empty string');
324
428
  if ('checkpoint_commit' in r && r.checkpoint_commit !== null && typeof r.checkpoint_commit !== 'string') {
@@ -354,6 +458,12 @@ export function validateReviewerResult(r) {
354
458
  if (!Array.isArray(r.rework_tasks)) errors.push('rework_tasks must be array');
355
459
  if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
356
460
 
461
+ if (Array.isArray(r.accepted_tasks) && Array.isArray(r.rework_tasks)) {
462
+ const overlap = r.accepted_tasks.filter(id => r.rework_tasks.includes(id));
463
+ if (overlap.length > 0) {
464
+ errors.push(`accepted_tasks and rework_tasks must be disjoint; overlap: ${overlap.join(', ')}`);
465
+ }
466
+ }
357
467
  for (const issue of r.critical_issues || []) {
358
468
  if (!isPlainObject(issue)) {
359
469
  errors.push('critical_issues entries must be objects');
@@ -390,7 +500,7 @@ export function validateResearcherResult(r) {
390
500
  */
391
501
  export function validateDebuggerResult(r) {
392
502
  const errors = [];
393
- if (!r.task_id) errors.push('missing task_id');
503
+ if (typeof r.task_id !== 'string' || r.task_id.length === 0) errors.push('missing task_id');
394
504
  if (!['root_cause_found', 'fix_suggested', 'failed'].includes(r.outcome)) errors.push('invalid outcome');
395
505
  if (typeof r.root_cause !== 'string' || r.root_cause.length === 0) errors.push('root_cause must be non-empty string');
396
506
  if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
@@ -421,16 +531,26 @@ export function validateDebuggerResult(r) {
421
531
  }
422
532
 
423
533
  export function createInitialState({ project, phases }) {
424
- // Validate task names before creating state
425
- for (const [pi, p] of (phases || []).entries()) {
534
+ if (!Array.isArray(phases)) {
535
+ return { error: true, message: 'phases must be an array' };
536
+ }
537
+ // Validate task names and uniqueness before creating state
538
+ const seenIds = new Set();
539
+ for (const [pi, p] of phases.entries()) {
426
540
  for (const [ti, t] of (p.tasks || []).entries()) {
427
541
  if (!t.name || typeof t.name !== 'string') {
428
542
  return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
429
543
  }
544
+ const id = `${pi + 1}.${t.index ?? (ti + 1)}`;
545
+ if (seenIds.has(id)) {
546
+ return { error: true, message: `Duplicate task ID: ${id} in phase ${pi + 1}` };
547
+ }
548
+ seenIds.add(id);
430
549
  }
431
550
  }
432
551
  return {
433
552
  project,
553
+ schema_version: 1,
434
554
  workflow_mode: 'executing_task',
435
555
  plan_version: 1,
436
556
  git_head: null,
@@ -446,7 +566,7 @@ export function createInitialState({ project, phases }) {
446
566
  tasks: p.tasks ? p.tasks.length : 0,
447
567
  done: 0,
448
568
  todo: (p.tasks || []).map((t, ti) => ({
449
- id: `${i + 1}.${t.index || ti + 1}`,
569
+ id: `${i + 1}.${t.index ?? (ti + 1)}`,
450
570
  name: t.name,
451
571
  lifecycle: 'pending',
452
572
  level: t.level || 'L1',
package/src/server.js CHANGED
@@ -247,6 +247,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
247
247
 
248
248
  return {
249
249
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
250
+ ...(result.error ? { isError: true } : {}),
250
251
  };
251
252
  });
252
253
 
@@ -255,6 +256,12 @@ export async function main() {
255
256
  await server.connect(transport);
256
257
  }
257
258
 
259
+ process.on('SIGINT', () => process.exit(0));
260
+ process.on('SIGTERM', () => process.exit(0));
261
+ process.on('unhandledRejection', (err) => {
262
+ if (process.env.GSD_DEBUG) console.error('[gsd] unhandledRejection', err);
263
+ });
264
+
258
265
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
259
266
  main().catch(console.error);
260
267
  }
@@ -27,7 +27,7 @@ function parseTimestamp(value) {
27
27
  }
28
28
 
29
29
  async function readContextHealth(basePath) {
30
- const gsdDir = getGsdDir(basePath);
30
+ const gsdDir = await getGsdDir(basePath);
31
31
  if (!gsdDir) return null;
32
32
  try {
33
33
  const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
@@ -75,7 +75,7 @@ async function detectPlanDrift(basePath, lastSession) {
75
75
  const lastSessionTs = parseTimestamp(lastSession);
76
76
  if (lastSessionTs === null) return [];
77
77
 
78
- const gsdDir = getGsdDir(basePath);
78
+ const gsdDir = await getGsdDir(basePath);
79
79
  if (!gsdDir) return [];
80
80
 
81
81
  const candidates = [join(gsdDir, 'plan.md')];
@@ -106,41 +106,37 @@ async function evaluatePreflight(state, basePath) {
106
106
  return { override: null };
107
107
  }
108
108
 
109
+ const hints = [];
110
+
109
111
  const currentGitHead = await getGitHead(basePath);
110
112
  if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
111
- return {
112
- override: {
113
- workflow_mode: 'reconcile_workspace',
114
- action: 'await_manual_intervention',
115
- updates: { workflow_mode: 'reconcile_workspace' },
116
- saved_git_head: state.git_head,
117
- current_git_head: currentGitHead,
118
- message: 'Saved git_head does not match the current workspace HEAD',
119
- },
120
- };
113
+ hints.push({
114
+ workflow_mode: 'reconcile_workspace',
115
+ action: 'await_manual_intervention',
116
+ updates: { workflow_mode: 'reconcile_workspace' },
117
+ saved_git_head: state.git_head,
118
+ current_git_head: currentGitHead,
119
+ message: 'Saved git_head does not match the current workspace HEAD',
120
+ });
121
121
  }
122
122
 
123
123
  const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
124
124
  if (changed_files.length > 0) {
125
- return {
126
- override: {
127
- workflow_mode: 'replan_required',
128
- action: 'await_manual_intervention',
129
- updates: { workflow_mode: 'replan_required' },
130
- changed_files,
131
- message: 'Plan artifacts changed after the last recorded session',
132
- },
133
- };
134
- }
135
-
136
- if (state.workflow_mode === 'awaiting_user' && state.current_review?.stage === 'direction_drift') {
137
- return { override: null };
125
+ hints.push({
126
+ workflow_mode: 'replan_required',
127
+ action: 'await_manual_intervention',
128
+ updates: { workflow_mode: 'replan_required' },
129
+ changed_files,
130
+ message: 'Plan artifacts changed after the last recorded session',
131
+ });
138
132
  }
139
133
 
140
- const driftPhase = getDirectionDriftPhase(state);
141
- if (driftPhase) {
142
- return {
143
- override: {
134
+ const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
135
+ && state.current_review?.stage === 'direction_drift';
136
+ if (!skipDirectionDrift) {
137
+ const driftPhase = getDirectionDriftPhase(state);
138
+ if (driftPhase) {
139
+ hints.push({
144
140
  workflow_mode: 'awaiting_user',
145
141
  action: 'awaiting_user',
146
142
  updates: {
@@ -155,24 +151,28 @@ async function evaluatePreflight(state, basePath) {
155
151
  },
156
152
  drift_phase: { id: driftPhase.id, name: driftPhase.name },
157
153
  message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
158
- },
159
- };
154
+ });
155
+ }
160
156
  }
161
157
 
162
158
  const expired_research = collectExpiredResearch(state);
163
159
  if (expired_research.length > 0) {
164
- return {
165
- override: {
166
- workflow_mode: 'research_refresh_needed',
167
- action: 'dispatch_researcher',
168
- updates: { workflow_mode: 'research_refresh_needed' },
169
- expired_research,
170
- message: 'Research cache expired and must be refreshed before execution resumes',
171
- },
172
- };
160
+ hints.push({
161
+ workflow_mode: 'research_refresh_needed',
162
+ action: 'dispatch_researcher',
163
+ updates: { workflow_mode: 'research_refresh_needed' },
164
+ expired_research,
165
+ message: 'Research cache expired and must be refreshed before execution resumes',
166
+ });
173
167
  }
174
168
 
175
- return { override: null };
169
+ if (hints.length === 0) return { override: null };
170
+
171
+ return {
172
+ override: hints[0],
173
+ // Always report all hint messages so caller can surface pending issues
174
+ hints: hints.map(h => h.message),
175
+ };
176
176
  }
177
177
 
178
178
  function getCurrentPhase(state) {
@@ -503,6 +503,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
503
503
  ...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
504
504
  ...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
505
505
  ...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
506
+ ...(preflight.hints && preflight.hints.length > 1 ? { pending_issues: preflight.hints.slice(1) } : {}),
506
507
  };
507
508
  }
508
509
 
@@ -700,8 +701,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
700
701
  }
701
702
  }
702
703
 
703
- // L0 auto-accept: promote checkpointed accepted in a second persist
704
- if (isL0) {
704
+ // Auto-accept: L0 tasks or tasks with review_required: false
705
+ const autoAccept = isL0 || task.review_required === false;
706
+ if (autoAccept) {
705
707
  const acceptError = await persist(basePath, {
706
708
  phases: [{
707
709
  id: phase.id,
@@ -719,7 +721,7 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
719
721
  task_id: task.id,
720
722
  review_level: reviewLevel,
721
723
  current_review,
722
- auto_accepted: isL0,
724
+ auto_accepted: autoAccept,
723
725
  };
724
726
  }
725
727
 
@@ -825,6 +827,18 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
825
827
 
826
828
  if (result.outcome === 'failed' || result.architecture_concern === true) {
827
829
  const phaseFailed = result.architecture_concern === true;
830
+
831
+ // Determine effective workflow mode: if no tasks can make progress, escalate
832
+ let effectiveWorkflowMode;
833
+ if (phaseFailed) {
834
+ effectiveWorkflowMode = 'failed';
835
+ } else {
836
+ const hasProgressable = (phase.todo || []).some(t =>
837
+ t.id !== task.id && !['accepted', 'failed'].includes(t.lifecycle),
838
+ );
839
+ effectiveWorkflowMode = hasProgressable ? 'executing_task' : 'awaiting_user';
840
+ }
841
+
828
842
  const phasePatch = { id: phase.id };
829
843
  if (phaseFailed) {
830
844
  phasePatch.lifecycle = 'failed';
@@ -832,7 +846,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
832
846
  phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
833
847
 
834
848
  const persistError = await persist(basePath, {
835
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
849
+ workflow_mode: effectiveWorkflowMode,
836
850
  current_task: null,
837
851
  current_review: null,
838
852
  phases: [phasePatch],
@@ -842,7 +856,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
842
856
  return {
843
857
  success: true,
844
858
  action: phaseFailed ? 'phase_failed' : 'task_failed',
845
- workflow_mode: phaseFailed ? 'failed' : 'executing_task',
859
+ workflow_mode: effectiveWorkflowMode,
846
860
  phase_id: phase.id,
847
861
  task_id: task.id,
848
862
  };
@@ -914,6 +928,11 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
914
928
  }
915
929
  }
916
930
 
931
+ // Snapshot accepted task IDs before propagation (for done counter adjustment)
932
+ const acceptedBeforePropagation = new Set(
933
+ (phase.todo || []).filter(t => t.lifecycle === 'accepted').map(t => t.id),
934
+ );
935
+
917
936
  // Propagation for critical issues with invalidates_downstream
918
937
  for (const issue of (result.critical_issues || [])) {
919
938
  if (issue.invalidates_downstream && issue.task_id) {
@@ -925,6 +944,9 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
925
944
  for (const task of (phase.todo || [])) {
926
945
  if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
927
946
  taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
947
+ if (acceptedBeforePropagation.has(task.id)) {
948
+ doneDecrement += 1;
949
+ }
928
950
  }
929
951
  }
930
952
 
@@ -936,11 +958,18 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
936
958
  done: Math.max(0, (phase.done || 0) + doneIncrement - doneDecrement),
937
959
  phase_review: {
938
960
  status: reviewStatus,
939
- ...(hasCritical ? { retry_count: (phase.phase_review?.retry_count || 0) + 1 } : {}),
961
+ ...(hasCritical
962
+ ? { retry_count: (phase.phase_review?.retry_count || 0) + 1 }
963
+ : { retry_count: 0 }),
940
964
  },
941
965
  todo: taskPatches,
942
966
  };
943
967
 
968
+ // Transition phase back to active when rework is needed
969
+ if (hasCritical && phase.lifecycle === 'reviewing') {
970
+ phaseUpdates.lifecycle = 'active';
971
+ }
972
+
944
973
  if (!hasCritical && result.scope === 'phase') {
945
974
  phaseUpdates.phase_handoff = { required_reviews_passed: true };
946
975
  }