@planu/cli 4.11.2 → 4.11.4

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/config/elicitation-questions.json +5 -17
  3. package/dist/engine/elicitation/question-generator.js +10 -3
  4. package/dist/engine/implementation-contract/evaluator.js +4 -1
  5. package/dist/engine/implementation-contract/renderer.js +17 -39
  6. package/dist/engine/lifecycle-hints.d.ts +1 -1
  7. package/dist/engine/lifecycle-hints.js +13 -29
  8. package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
  9. package/dist/engine/spec-format/lean-spec-generator.js +9 -10
  10. package/dist/engine/spec-generator/fallback-generator.js +13 -20
  11. package/dist/engine/spec-grounding/contract.d.ts +5 -0
  12. package/dist/engine/spec-grounding/contract.js +10 -6
  13. package/dist/engine/spec-migrator/lean-migration.js +9 -16
  14. package/dist/engine/web-fetcher/stack-advisor.d.ts +2 -2
  15. package/dist/engine/web-fetcher/stack-advisor.js +78 -17
  16. package/dist/tools/challenge-spec/challenge-report.d.ts +16 -0
  17. package/dist/tools/challenge-spec/challenge-report.js +120 -0
  18. package/dist/tools/challenge-spec.js +13 -8
  19. package/dist/tools/clarify-requirements/multiple-choice.js +1 -1
  20. package/dist/tools/clarify-requirements/questions-context.js +11 -11
  21. package/dist/tools/clarify-requirements/questions.js +3 -23
  22. package/dist/tools/clarify-requirements.js +38 -0
  23. package/dist/tools/create-spec/autopilot-analyzer.d.ts +1 -1
  24. package/dist/tools/create-spec/autopilot-analyzer.js +29 -54
  25. package/dist/tools/create-spec/post-creation.js +23 -4
  26. package/dist/tools/create-spec.js +47 -144
  27. package/dist/tools/elicit-requirements-handler.js +20 -12
  28. package/dist/tools/register-spec-tools/core-spec-tools.js +11 -2
  29. package/dist/tools/suggest-stack.js +49 -13
  30. package/dist/tools/update-status/dod-gates.d.ts +8 -0
  31. package/dist/tools/update-status/dod-gates.js +68 -43
  32. package/dist/tools/update-status/index.js +109 -74
  33. package/dist/tools/update-status/qa-gate.js +26 -0
  34. package/dist/tools/update-status/transition-guard.d.ts +2 -2
  35. package/dist/tools/update-status/transition-guard.js +14 -7
  36. package/dist/tools/validate-lint.d.ts +1 -1
  37. package/dist/tools/validate-lint.js +39 -1
  38. package/dist/types/spec/core.d.ts +20 -0
  39. package/dist/types/spec/inputs.d.ts +1 -1
  40. package/dist/types/stack/index.d.ts +1 -1
  41. package/dist/types/stack/recommend.d.ts +10 -0
  42. package/package.json +9 -9
  43. package/planu-native.json +1 -1
  44. package/planu-plugin.json +1 -1
  45. package/dist/engine/elicitation/non-interactive-defaults.d.ts +0 -9
  46. package/dist/engine/elicitation/non-interactive-defaults.js +0 -28
  47. package/dist/tools/clarify-requirements/interview-mode.d.ts +0 -42
  48. package/dist/tools/clarify-requirements/interview-mode.js +0 -342
@@ -167,8 +167,17 @@ export function registerCoreSpecTools(server) {
167
167
  .number()
168
168
  .optional()
169
169
  .describe('Maximum number of questions to generate (default: 5)'),
170
+ sessionId: z
171
+ .string()
172
+ .max(500)
173
+ .optional()
174
+ .describe('Existing clarification session to resume with user-confirmed answers.'),
175
+ answers: z
176
+ .record(z.string(), z.string())
177
+ .optional()
178
+ .describe('Explicit user-confirmed answers keyed by clarification question ID.'),
170
179
  },
171
- }, safeTracked('clarify_requirements', async (args) => handleClarifyRequirements(args)));
180
+ }, safeTracked('clarify_requirements', async (args) => handleClarifyRequirements(args, server)));
172
181
  // 5. create_spec
173
182
  server.registerTool('create_spec', {
174
183
  description: t('tools.create_spec.description'),
@@ -210,7 +219,7 @@ export function registerCoreSpecTools(server) {
210
219
  outOfScope: z
211
220
  .array(z.string())
212
221
  .optional()
213
- .describe('SPEC-612: Explicit out-of-scope items for this spec. If omitted, Planu auto-suggests based on the description.'),
222
+ .describe('Explicit user-confirmed out-of-scope items for this spec.'),
214
223
  specId: z
215
224
  .string()
216
225
  .optional()
@@ -7,20 +7,26 @@ import { adjustRecommendationRanking } from '../engine/self-improver.js';
7
7
  export async function handleSuggestStack(args) {
8
8
  const { projectType, requirements, constraints = [], projectId } = args;
9
9
  // If projectId provided, enrich with project knowledge
10
- let contextInfo = '';
10
+ const projectEvidence = [];
11
11
  if (projectId) {
12
12
  const knowledge = await knowledgeStore.getKnowledge(projectId);
13
13
  if (knowledge) {
14
- contextInfo =
15
- `\n\n**Project context:** ${knowledge.language}` +
16
- (knowledge.framework ? ` / ${knowledge.framework}` : '') +
17
- ` — architecture: ${knowledge.architecture.primary}` +
18
- ` — database: ${knowledge.database}`;
14
+ if (knowledge.language !== 'unknown') {
15
+ projectEvidence.push(`language=${knowledge.language}`);
16
+ }
17
+ if (knowledge.framework) {
18
+ projectEvidence.push(`framework=${knowledge.framework}`);
19
+ }
20
+ projectEvidence.push(`architecture=${knowledge.architecture.primary}`);
21
+ if (knowledge.database !== 'unknown') {
22
+ projectEvidence.push(`database=${knowledge.database}`);
23
+ }
19
24
  }
20
25
  }
21
26
  // SPEC-012: Load developer preferences to adjust recommendation ranking
22
27
  const developerPrefs = await globalStore.getDeveloperPreferences();
23
28
  const result = await suggestStack(projectType, requirements, constraints);
29
+ const grounding = result;
24
30
  // Apply developer preference rankings to ecosystem items
25
31
  const ecosystemItems = [...result.primary.ecosystem, ...result.alternative.ecosystem];
26
32
  const rankings = adjustRecommendationRanking(ecosystemItems, developerPrefs);
@@ -28,11 +34,14 @@ export async function handleSuggestStack(args) {
28
34
  const rejectedItems = [...rankings.entries()]
29
35
  .filter(([, score]) => score < -1)
30
36
  .map(([tool]) => tool);
31
- // Format primary recommendation
37
+ // Recommendations remain candidates until the user confirms a choice.
32
38
  const primaryLines = [
33
39
  `## ${t('tools.suggest_stack.description')}`,
34
40
  '',
35
- '### Primary Recommendation',
41
+ grounding.candidateNotice ??
42
+ 'Recommendations are optional candidates. This tool does not select or persist a stack.',
43
+ '',
44
+ '### Candidate A (unconfirmed)',
36
45
  '',
37
46
  `| Aspect | Value |`,
38
47
  `|--------|-------|`,
@@ -48,10 +57,10 @@ export async function handleSuggestStack(args) {
48
57
  '',
49
58
  `**Ecosystem:** ${result.primary.ecosystem.join(', ')}`,
50
59
  ].filter(Boolean);
51
- // Format alternative
60
+ // Format alternative candidate
52
61
  const altLines = [
53
62
  '',
54
- '### Alternative Recommendation',
63
+ '### Candidate B (unconfirmed)',
55
64
  '',
56
65
  `| Aspect | Value |`,
57
66
  `|--------|-------|`,
@@ -67,10 +76,10 @@ export async function handleSuggestStack(args) {
67
76
  '',
68
77
  `**Ecosystem:** ${result.alternative.ecosystem.join(', ')}`,
69
78
  ].filter(Boolean);
70
- // Why not others
79
+ // Other optional candidates
71
80
  const whyNotLines = [
72
81
  '',
73
- '### Why Not Others',
82
+ '### Other Candidates Considered',
74
83
  '',
75
84
  ...result.whyNotOthers.map((w) => `- **${w.name}:** ${w.reason}`),
76
85
  ];
@@ -86,18 +95,45 @@ export async function handleSuggestStack(args) {
86
95
  const prefsNote = rejectedItems.length > 0
87
96
  ? `\n\n> **Note:** Based on your preferences, these were previously rejected: ${rejectedItems.join(', ')}`
88
97
  : '';
98
+ const detectedEvidence = [
99
+ ...projectEvidence.map((item) => `project:${item}`),
100
+ ...(grounding.detectedEvidence ?? []),
101
+ ];
102
+ const evidenceLines = [
103
+ '',
104
+ '### Detected Evidence',
105
+ '',
106
+ ...(detectedEvidence.length > 0
107
+ ? detectedEvidence.map((item) => `- ${item}`)
108
+ : ['- No stack evidence was detected.']),
109
+ '',
110
+ '### User-Confirmed Choices',
111
+ '',
112
+ ...((grounding.confirmedChoices ?? []).length > 0
113
+ ? (grounding.confirmedChoices ?? []).map((choice) => `- ${choice.field}: ${choice.value}`)
114
+ : ['- None.']),
115
+ ];
89
116
  const text = [
90
117
  ...primaryLines,
91
118
  ...altLines,
92
119
  ...whyNotLines,
93
120
  ...mcpLines,
94
- contextInfo,
121
+ ...evidenceLines,
95
122
  prefsNote,
96
123
  '',
97
124
  `_Last checked: ${result.lastChecked}_`,
98
125
  ].join('\n');
99
126
  return {
100
127
  content: [{ type: 'text', text }],
128
+ structuredContent: {
129
+ selectionStatus: grounding.selectionStatus ?? 'unconfirmed',
130
+ detectedEvidence,
131
+ confirmedChoices: grounding.confirmedChoices ?? [],
132
+ candidates: [
133
+ { id: 'candidate-a', status: 'candidate', stack: result.primary },
134
+ { id: 'candidate-b', status: 'candidate', stack: result.alternative },
135
+ ],
136
+ },
101
137
  };
102
138
  }
103
139
  //# sourceMappingURL=suggest-stack.js.map
@@ -30,6 +30,13 @@ export type ValidateGateResult = {
30
30
  forcedReason: string;
31
31
  scoreSource: 'forced-best-effort-validateSpec';
32
32
  };
33
+ export type ValidationReportGateResult = {
34
+ ok: true;
35
+ score: number | null;
36
+ } | {
37
+ ok: false;
38
+ error: ToolResult;
39
+ };
33
40
  /**
34
41
  * SPEC-721 / SPEC-222 Trigger 1: Run validate engine before marking done.
35
42
  *
@@ -79,6 +86,7 @@ export interface DoneGateResult {
79
86
  * with reviewer evidence and all validation gates passing.
80
87
  */
81
88
  export declare function checkValidationReportGate(specId: string, projectId: string, force: boolean | undefined): Promise<ToolResult | null>;
89
+ export declare function readApprovedValidationReportGate(specId: string, projectId: string, force: boolean | undefined): Promise<ValidationReportGateResult>;
82
90
  /** SPEC-1051: Write spec-review evidence when a spec enters review. */
83
91
  export declare function writeSpecReviewArtifact(spec: Spec, specId: string, projectId: string): Promise<ToolResult | null>;
84
92
  /** SPEC-1051: Approval requires a dedicated spec reviewer artifact. */
@@ -280,8 +280,12 @@ async function recordForceDoneBypass(specId, projectId, reason, failingItems, wa
280
280
  * with reviewer evidence and all validation gates passing.
281
281
  */
282
282
  export async function checkValidationReportGate(specId, projectId, force) {
283
+ const result = await readApprovedValidationReportGate(specId, projectId, force);
284
+ return result.ok ? null : result.error;
285
+ }
286
+ export async function readApprovedValidationReportGate(specId, projectId, force) {
283
287
  if (force) {
284
- return null;
288
+ return { ok: true, score: null };
285
289
  }
286
290
  try {
287
291
  const { readArtifact } = await import('../../engine/handoff-artifacts/io.js');
@@ -289,60 +293,81 @@ export async function checkValidationReportGate(specId, projectId, force) {
289
293
  if (!result.ok) {
290
294
  const firstErr = result.errors[0];
291
295
  if (firstErr?.code === 'ARTIFACT_NOT_FOUND') {
292
- return validationReportGateError({
296
+ return {
297
+ ok: false,
298
+ error: validationReportGateError({
299
+ specId,
300
+ error: 'validation_report_missing',
301
+ message: 'No validation-report artifact exists for this spec. Run validate to generate implementation-review evidence before marking done.',
302
+ gates: [],
303
+ fixHint: 'Run validate for this spec, fix any failing gates, then retry update_status(done). Use force:true only with an audited reason.',
304
+ }),
305
+ };
306
+ }
307
+ return {
308
+ ok: false,
309
+ error: validationReportGateError({
293
310
  specId,
294
- error: 'validation_report_missing',
295
- message: 'No validation-report artifact exists for this spec. Run validate to generate implementation-review evidence before marking done.',
311
+ error: 'validation_report_invalid',
312
+ message: 'The validation-report artifact is malformed or uses an obsolete schema. Re-run validate so Planu can generate reviewer evidence.',
296
313
  gates: [],
297
- fixHint: 'Run validate for this spec, fix any failing gates, then retry update_status(done). Use force:true only with an audited reason.',
298
- });
299
- }
300
- return validationReportGateError({
301
- specId,
302
- error: 'validation_report_invalid',
303
- message: 'The validation-report artifact is malformed or uses an obsolete schema. Re-run validate so Planu can generate reviewer evidence.',
304
- gates: [],
305
- fixHint: 'Re-run validate for this spec. The report must include reviewer evidence and passing gates.',
306
- });
314
+ fixHint: 'Re-run validate for this spec. The report must include reviewer evidence and passing gates.',
315
+ }),
316
+ };
307
317
  }
308
318
  if (!result.payload.passed) {
309
- return validationReportGateError({
310
- specId,
311
- error: 'validation_report_failed',
312
- message: 'Validation report has passed:false — fix all failing gates before marking done.',
313
- gates: result.payload.gates,
314
- fixHint: 'Fix failing gates and re-run validate before marking done.',
315
- });
319
+ return {
320
+ ok: false,
321
+ error: validationReportGateError({
322
+ specId,
323
+ error: 'validation_report_failed',
324
+ message: 'Validation report has passed:false fix all failing gates before marking done.',
325
+ gates: result.payload.gates,
326
+ fixHint: 'Fix failing gates and re-run validate before marking done.',
327
+ }),
328
+ };
316
329
  }
317
330
  if (result.payload.minimalityReport?.blocked) {
318
- return validationReportGateError({
319
- specId,
320
- error: 'minimality_findings_block_done',
321
- message: 'Validation report contains blocking minimal implementation findings. Remove avoidable complexity or provide an audited forceStatusReason.',
322
- gates: result.payload.gates,
323
- fixHint: 'Fix blocking minimality findings and re-run validate before marking done. Use forceStatus only with an audited reason for accepted complexity.',
324
- });
331
+ return {
332
+ ok: false,
333
+ error: validationReportGateError({
334
+ specId,
335
+ error: 'minimality_findings_block_done',
336
+ message: 'Validation report contains blocking minimal implementation findings. Remove avoidable complexity or provide an audited forceStatusReason.',
337
+ gates: result.payload.gates,
338
+ fixHint: 'Fix blocking minimality findings and re-run validate before marking done. Use forceStatus only with an audited reason for accepted complexity.',
339
+ }),
340
+ };
325
341
  }
326
342
  if (result.payload.reviewer.verdict !== 'approved' ||
327
343
  result.payload.reviewer.agent.trim().length === 0) {
328
- return validationReportGateError({
329
- specId,
330
- error: 'validation_report_reviewer_not_approved',
331
- message: 'The implementation reviewer did not approve this spec. Fix the requested changes and re-run validate.',
332
- gates: result.payload.gates,
333
- fixHint: 'Fix reviewer findings and re-run validate before marking done.',
334
- });
344
+ return {
345
+ ok: false,
346
+ error: validationReportGateError({
347
+ specId,
348
+ error: 'validation_report_reviewer_not_approved',
349
+ message: 'The implementation reviewer did not approve this spec. Fix the requested changes and re-run validate.',
350
+ gates: result.payload.gates,
351
+ fixHint: 'Fix reviewer findings and re-run validate before marking done.',
352
+ }),
353
+ };
335
354
  }
336
- return null;
355
+ return {
356
+ ok: true,
357
+ score: typeof result.payload.score === 'number' ? result.payload.score : null,
358
+ };
337
359
  }
338
360
  catch (err) {
339
- return validationReportGateError({
340
- specId,
341
- error: 'validation_report_unreadable',
342
- message: `Could not read validation-report artifact: ${err instanceof Error ? err.message : String(err)}`,
343
- gates: [],
344
- fixHint: 'Re-run validate for this spec, then retry update_status(done).',
345
- });
361
+ return {
362
+ ok: false,
363
+ error: validationReportGateError({
364
+ specId,
365
+ error: 'validation_report_unreadable',
366
+ message: `Could not read validation-report artifact: ${err instanceof Error ? err.message : String(err)}`,
367
+ gates: [],
368
+ fixHint: 'Re-run validate for this spec, then retry update_status(done).',
369
+ }),
370
+ };
346
371
  }
347
372
  }
348
373
  /** SPEC-1051: Write spec-review evidence when a spec enters review. */
@@ -13,7 +13,7 @@ import { checkApprovedDepGate } from '../../engine/dep-guard/index.js';
13
13
  import { checkApprovalGate } from '../../engine/approval-workflow.js';
14
14
  import * as approvalStore from '../../storage/approval-store.js';
15
15
  import { isLocked, getLock } from '../../storage/spec-lock-store.js';
16
- import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, checkValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
16
+ import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, checkValidationReportGate, readApprovedValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
17
  import { checkLifecycleEvidenceTransitionGate } from './evidence-gate.js';
18
18
  import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse, } from './response-builder.js';
19
19
  import { recordDoneMetrics, syncSpecFiles, tryReconcile, recordTerminalTransitionEvent, } from './file-sync.js';
@@ -43,23 +43,12 @@ import { validateStrictLayoutOrError } from '../validate-helpers.js';
43
43
  * (e.g. draft → approved) without receiving an error.
44
44
  * Returns the list of states executed, or an empty array if none were needed.
45
45
  */
46
- async function runAutoAdvance(projectId, specId, currentStatus, targetStatus) {
47
- const steps = resolveAutoAdvanceSteps(currentStatus, targetStatus);
48
- if (!steps || steps.length === 0) {
49
- return { updatedCurrentStatus: currentStatus, stepsExecuted: [] };
50
- }
51
- let updatedCurrentStatus = currentStatus;
52
- const stepsExecuted = [];
53
- for (const intermediateStatus of steps) {
54
- // SPEC-720: route through transitionSpec — single entry point for status writes
55
- await transitionSpec(projectId, specId, intermediateStatus, {
56
- trigger: 'auto-advance',
57
- actor: 'system',
58
- });
59
- stepsExecuted.push(intermediateStatus);
60
- updatedCurrentStatus = intermediateStatus;
61
- }
62
- return { updatedCurrentStatus, stepsExecuted };
46
+ function buildAutoAdvancePlan(currentStatus, targetStatus) {
47
+ const stepsExecuted = resolveAutoAdvanceSteps(currentStatus, targetStatus) ?? [];
48
+ return {
49
+ plannedStatuses: [...stepsExecuted, targetStatus],
50
+ stepsExecuted,
51
+ };
63
52
  }
64
53
  /**
65
54
  * SPEC-301: Check if spec is locked by another agent.
@@ -335,19 +324,28 @@ export async function handleUpdateStatus(params, server) {
335
324
  }
336
325
  }
337
326
  try {
338
- // SPEC-280: auto-advance silently through intermediate states (e.g. draft approved)
339
- const { updatedCurrentStatus, stepsExecuted } = await runAutoAdvance(projectId, specId, spec.status, newStatus);
340
- const currentStatus = updatedCurrentStatus;
341
- // Idempotent or invalid transition check — returns early if no change needed or blocked
342
- const transitionError = checkIdempotentOrTransition(specId, currentStatus, newStatus);
343
- if (transitionError) {
344
- return transitionError;
327
+ if (spec.status === newStatus) {
328
+ const idempotentResult = checkIdempotentOrTransition(specId, spec.status, newStatus);
329
+ if (idempotentResult) {
330
+ return idempotentResult;
331
+ }
332
+ }
333
+ // SPEC-280/SPEC-1122: plan every intermediate state in memory. No status is
334
+ // persisted until all gates for the complete path have passed.
335
+ const { plannedStatuses, stepsExecuted } = buildAutoAdvancePlan(spec.status, newStatus);
336
+ let plannedFromStatus = spec.status;
337
+ for (const plannedStatus of plannedStatuses) {
338
+ const transitionError = checkTransition(plannedFromStatus, plannedStatus);
339
+ if (transitionError) {
340
+ return transitionError;
341
+ }
342
+ plannedFromStatus = plannedStatus;
345
343
  }
346
344
  // SPEC-733: Detect reverse transition and validate mandatory reason
347
- const reverseTransition = isReverseTransition(currentStatus, newStatus);
345
+ const reverseTransition = isReverseTransition(spec.status, newStatus);
348
346
  if (reverseTransition) {
349
347
  const reverseValidation = validateReverseTransition({
350
- from: currentStatus,
348
+ from: spec.status,
351
349
  to: newStatus,
352
350
  reason: params.reason,
353
351
  });
@@ -373,33 +371,35 @@ export async function handleUpdateStatus(params, server) {
373
371
  }
374
372
  }
375
373
  // Gate: approval policy must be satisfied before transitioning to 'approved'
376
- const approvalError = await checkApprovalPolicyGate(projectId, specId, newStatus);
374
+ const approvalGateStatus = plannedStatuses.includes('approved') ? 'approved' : newStatus;
375
+ const approvalError = await checkApprovalPolicyGate(projectId, specId, approvalGateStatus);
377
376
  if (approvalError) {
378
377
  return approvalError;
379
378
  }
380
379
  // Gate: DoR must pass before transitioning to 'implementing'
381
- const dorError = checkDorGate(spec, specId, projectId, newStatus);
380
+ const dorGateStatus = plannedStatuses.includes('implementing') ? 'implementing' : newStatus;
381
+ const dorError = checkDorGate(spec, specId, projectId, dorGateStatus);
382
382
  if (dorError) {
383
383
  return dorError;
384
384
  }
385
385
  // SPEC-716/SPEC-780: Format gate — block 'approved' unless forceApprove bypasses with warnings
386
- const formatGate = await checkApprovedFormatGate(spec, newStatus, params.forceApprove);
386
+ const formatGate = await checkApprovedFormatGate(spec, approvalGateStatus, params.forceApprove);
387
387
  if (formatGate.blockResult) {
388
388
  return formatGate.blockResult;
389
389
  }
390
390
  // SPEC-632: Ambiguity gate — block 'approved' if score < 70
391
- const ambiguityError = await checkAmbiguityGate(spec, newStatus);
391
+ const ambiguityError = await checkAmbiguityGate(spec, approvalGateStatus);
392
392
  if (ambiguityError) {
393
393
  return ambiguityError;
394
394
  }
395
395
  // SPEC-769: Readiness gate — block 'approved' if spec has 0 criteria or score < 70
396
- const readinessGate = await checkReadinessGate(spec, newStatus, params.forceApprove);
396
+ const readinessGate = await checkReadinessGate(spec, approvalGateStatus, params.forceApprove);
397
397
  if (readinessGate.blockResult) {
398
398
  return readinessGate.blockResult;
399
399
  }
400
400
  // SPEC-728: DepGuard — block 'approved' if spec participates in a dependency cycle
401
401
  let depGuardResult = null;
402
- if (newStatus === 'approved') {
402
+ if (plannedStatuses.includes('approved')) {
403
403
  const allSpecsForDepGuard = await specStore.listSpecs(projectId);
404
404
  depGuardResult = checkApprovedDepGate(spec, allSpecsForDepGuard);
405
405
  if (depGuardResult.blocked) {
@@ -417,17 +417,12 @@ export async function handleUpdateStatus(params, server) {
417
417
  }
418
418
  }
419
419
  // SPEC-964: Challenge gate — block 'review' if challenge_spec was never run
420
- const challengeGate = checkChallengeGate(spec, newStatus);
420
+ const challengeGateStatus = plannedStatuses.includes('review') ? 'review' : newStatus;
421
+ const challengeGate = checkChallengeGate(spec, challengeGateStatus);
421
422
  if (challengeGate) {
422
423
  return challengeGate;
423
424
  }
424
- if (newStatus === 'review') {
425
- const specReviewWriteError = await writeSpecReviewArtifact(spec, specId, projectId);
426
- if (specReviewWriteError) {
427
- return specReviewWriteError;
428
- }
429
- }
430
- if (newStatus === 'approved') {
425
+ if (plannedStatuses.includes('approved')) {
431
426
  const specReviewError = await checkSpecReviewGate(specId, projectId, params.forceApprove);
432
427
  if (specReviewError) {
433
428
  return specReviewError;
@@ -443,34 +438,45 @@ export async function handleUpdateStatus(params, server) {
443
438
  // Resolve effective project path once — used across all gates below
444
439
  const effectiveGatePath = knowledge?.projectPath ?? params.projectPath;
445
440
  // SPEC-1044: SDD model-routing + context continuity hard gate.
446
- const sddRoutingGate = shouldSkipSddRoutingGateForLegacyTestHarness()
447
- ? {
448
- blockResult: null,
449
- gateResults: { sddModelRouting: 'skip' },
450
- forcedReasons: [],
441
+ const sddRoutingGate = {
442
+ blockResult: null,
443
+ gateResults: { sddModelRouting: 'skip' },
444
+ forcedReasons: [],
445
+ };
446
+ if (!shouldSkipSddRoutingGateForLegacyTestHarness()) {
447
+ const routedStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
448
+ for (const status of routedStatuses) {
449
+ const gate = await checkSddModelRoutingGate({
450
+ params,
451
+ status,
452
+ projectPath: effectiveGatePath,
453
+ });
454
+ if (gate.blockResult) {
455
+ return gate.blockResult;
456
+ }
457
+ Object.assign(sddRoutingGate.gateResults, gate.gateResults);
458
+ sddRoutingGate.forcedReasons.push(...gate.forcedReasons);
459
+ }
460
+ if (sddRoutingGate.forcedReasons.length > 0) {
461
+ sddRoutingGate.gateResults.sddModelRouting = 'forced';
451
462
  }
452
- : await checkSddModelRoutingGate({
453
- params,
454
- status: newStatus,
455
- projectPath: effectiveGatePath,
456
- });
457
- if (sddRoutingGate.blockResult) {
458
- return sddRoutingGate.blockResult;
459
463
  }
460
464
  // SPEC-1054: BDD/SDD evidence gates. Non-trivial specs must carry
461
465
  // Discovery before approval, task-plan before implementation, and
462
466
  // traceability/contract evidence before done.
463
- if (!shouldSkipEvidenceGateForLegacyTestHarness() &&
464
- (newStatus === 'approved' || newStatus === 'implementing' || newStatus === 'done')) {
465
- const evidenceGate = await checkLifecycleEvidenceTransitionGate({
466
- spec,
467
- specId,
468
- projectId,
469
- projectPath: effectiveGatePath,
470
- transition: newStatus,
471
- });
472
- if (evidenceGate !== null) {
473
- return evidenceGate;
467
+ if (!shouldSkipEvidenceGateForLegacyTestHarness()) {
468
+ const evidenceStatuses = plannedStatuses.filter((status) => status === 'approved' || status === 'implementing' || status === 'done');
469
+ for (const transition of evidenceStatuses) {
470
+ const evidenceGate = await checkLifecycleEvidenceTransitionGate({
471
+ spec,
472
+ specId,
473
+ projectId,
474
+ projectPath: effectiveGatePath,
475
+ transition,
476
+ });
477
+ if (evidenceGate !== null) {
478
+ return evidenceGate;
479
+ }
474
480
  }
475
481
  }
476
482
  // ---------------------------------------------------------------------------
@@ -522,21 +528,37 @@ export async function handleUpdateStatus(params, server) {
522
528
  let crashShieldWarning = null;
523
529
  let crashShieldSkipReason = null;
524
530
  let complianceGateResult = null;
531
+ let validationReportGate = null;
532
+ if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
533
+ validationReportGate = await readApprovedValidationReportGate(specId, projectId, false);
534
+ if (!validationReportGate.ok) {
535
+ return validationReportGate.error;
536
+ }
537
+ }
525
538
  // SPEC-628: Rate-limit crash scan — check before entering the parallel batch
526
539
  crashShieldSkipReason = await checkCrashScanRateLimit(newStatus, effectiveGatePath ?? null);
540
+ const approvedValidationReportScore = validationReportGate?.ok === true && validationReportGate.score === 100
541
+ ? validationReportGate.score
542
+ : null;
527
543
  const [validateGateResult, crashRisksReport, complianceResult] = await Promise.all([
528
544
  // Validate: only on 'done'.
529
545
  // SPEC-721: timeout lives inside runValidateGate (Promise.race) — do NOT wrap with
530
546
  // withToolTimeout here, which would silently convert timeout into blocked:false (fail-open).
531
547
  newStatus === 'done'
532
- ? runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
548
+ ? approvedValidationReportScore !== null
549
+ ? Promise.resolve({
550
+ blocked: false,
551
+ score: approvedValidationReportScore,
552
+ forced: false,
553
+ })
554
+ : runValidateGate(spec, effectiveGatePath ?? '', params.forceStatus ?? false, params.forceStatusReason, 9_000, { projectId, specId })
533
555
  : Promise.resolve(null),
534
556
  // Crash shield: only on 'done', skipped if rate-limited (SPEC-628)
535
557
  newStatus === 'done' && effectiveGatePath && !crashShieldSkipReason
536
558
  ? withToolTimeout(scanCrashRisks(effectiveGatePath).catch(() => null), 9_000, null)
537
559
  : Promise.resolve(null),
538
560
  // The heuristic scorer is a review aid. Done relies on the authoritative validate report.
539
- newStatus === 'review'
561
+ plannedStatuses.includes('review')
540
562
  ? withToolTimeout(checkComplianceGate(specId, projectId, effectiveGatePath), 9_000, {
541
563
  skipped: true,
542
564
  blocked: false,
@@ -560,10 +582,13 @@ export async function handleUpdateStatus(params, server) {
560
582
  };
561
583
  }
562
584
  else {
563
- validateScoreSource = 'validateSpec';
585
+ validateScoreSource =
586
+ approvedValidationReportScore !== null ? 'validation-report' : 'validateSpec';
564
587
  }
565
588
  }
566
- if (newStatus === 'done' && !(params.force ?? params.forceStatus ?? false)) {
589
+ if (newStatus === 'done' &&
590
+ !(params.force ?? params.forceStatus ?? false) &&
591
+ validationReportGate === null) {
567
592
  const validationReportError = await checkValidationReportGate(specId, projectId, false);
568
593
  if (validationReportError) {
569
594
  return validationReportError;
@@ -600,14 +625,14 @@ export async function handleUpdateStatus(params, server) {
600
625
  // Process compliance gate result — may block on 'review'
601
626
  if (complianceResult !== null) {
602
627
  complianceGateResult = complianceResult;
603
- if (newStatus === 'review' && complianceResult.blocked) {
628
+ if (plannedStatuses.includes('review') && complianceResult.blocked) {
604
629
  return buildBlockedByComplianceResponse(specId, complianceResult);
605
630
  }
606
631
  }
607
632
  // SPEC-190: Run all compliance gates in parallel (convention + constitution + compile + lint + test, non-blocking)
608
633
  // runComplianceGates internally skips heavy commands (compile/lint/test) for non-done transitions,
609
634
  // but 'approved' still needs constitutionWarnings — so run for 'done' and 'approved' only.
610
- const { conventionWarnings, constitutionWarnings, compileWarnings, lintWarnings, testWarnings, } = newStatus === 'done' || newStatus === 'approved'
635
+ const { conventionWarnings, constitutionWarnings, compileWarnings, lintWarnings, testWarnings, } = newStatus === 'done' || plannedStatuses.includes('approved')
611
636
  ? await runComplianceGates(projectId, spec.title, spec.tags, newStatus)
612
637
  : {
613
638
  conventionWarnings: [],
@@ -645,6 +670,14 @@ export async function handleUpdateStatus(params, server) {
645
670
  blockingReasons: [],
646
671
  });
647
672
  }
673
+ // Entering review creates reviewer evidence only after every lifecycle gate has passed.
674
+ // Auto-advance to approval must consume pre-existing approved reviewer evidence.
675
+ if (newStatus === 'review') {
676
+ const specReviewWriteError = await writeSpecReviewArtifact(spec, specId, projectId);
677
+ if (specReviewWriteError) {
678
+ return specReviewWriteError;
679
+ }
680
+ }
648
681
  // Explicit actuals are preserved verbatim. Provider usage and cost are never inferred.
649
682
  const resolvedActuals = actuals;
650
683
  // Run transition-specific actions
@@ -653,7 +686,9 @@ export async function handleUpdateStatus(params, server) {
653
686
  // SPEC-720: Step 1 — Transition the status through the single authorised path
654
687
  const trigger = reverseTransition
655
688
  ? 'reopen'
656
- : (params.trigger ?? 'user');
689
+ : stepsExecuted.length > 0
690
+ ? 'auto-advance'
691
+ : (params.trigger ?? 'user');
657
692
  const actor = params.actor ?? 'system';
658
693
  const viaSync = params.viaSync ?? false;
659
694
  await transitionSpec(projectId, specId, newStatus, {
@@ -674,7 +709,7 @@ export async function handleUpdateStatus(params, server) {
674
709
  projectId,
675
710
  specId,
676
711
  eventType: 'transition',
677
- from: currentStatus,
712
+ from: originalStatus,
678
713
  to: newStatus,
679
714
  actor,
680
715
  reason: params.reason,
@@ -691,7 +726,7 @@ export async function handleUpdateStatus(params, server) {
691
726
  projectId,
692
727
  specId,
693
728
  eventType: 'reopen',
694
- from: currentStatus,
729
+ from: originalStatus,
695
730
  to: newStatus,
696
731
  actor,
697
732
  reason: params.reason,
@@ -1106,7 +1141,7 @@ export async function handleUpdateStatus(params, server) {
1106
1141
  const reconciliationMarkdown = await tryReconcile(newStatus, specId, projectId);
1107
1142
  // SPEC-754: Append shell-hygiene reminder when transitioning to done on claude-code host
1108
1143
  const shellHygieneHint = newStatus === 'done' && detectHost() === 'claude-code' ? shellHygieneReminder() : null;
1109
- return buildStatusResponse(result, specId, currentStatus, newStatus, reconciliationMarkdown, spec.title, shellHygieneHint);
1144
+ return buildStatusResponse(result, specId, originalStatus, newStatus, reconciliationMarkdown, spec.title, shellHygieneHint);
1110
1145
  }
1111
1146
  finally {
1112
1147
  // SPEC-719: Release cross-process lock in all exit paths (return, throw)