@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.
- package/CHANGELOG.md +14 -0
- package/dist/config/elicitation-questions.json +5 -17
- package/dist/engine/elicitation/question-generator.js +10 -3
- package/dist/engine/implementation-contract/evaluator.js +4 -1
- package/dist/engine/implementation-contract/renderer.js +17 -39
- package/dist/engine/lifecycle-hints.d.ts +1 -1
- package/dist/engine/lifecycle-hints.js +13 -29
- package/dist/engine/spec-format/lean-spec-generator.d.ts +2 -2
- package/dist/engine/spec-format/lean-spec-generator.js +9 -10
- package/dist/engine/spec-generator/fallback-generator.js +13 -20
- package/dist/engine/spec-grounding/contract.d.ts +5 -0
- package/dist/engine/spec-grounding/contract.js +10 -6
- package/dist/engine/spec-migrator/lean-migration.js +9 -16
- package/dist/engine/web-fetcher/stack-advisor.d.ts +2 -2
- package/dist/engine/web-fetcher/stack-advisor.js +78 -17
- package/dist/tools/challenge-spec/challenge-report.d.ts +16 -0
- package/dist/tools/challenge-spec/challenge-report.js +120 -0
- package/dist/tools/challenge-spec.js +13 -8
- package/dist/tools/clarify-requirements/multiple-choice.js +1 -1
- package/dist/tools/clarify-requirements/questions-context.js +11 -11
- package/dist/tools/clarify-requirements/questions.js +3 -23
- package/dist/tools/clarify-requirements.js +38 -0
- package/dist/tools/create-spec/autopilot-analyzer.d.ts +1 -1
- package/dist/tools/create-spec/autopilot-analyzer.js +29 -54
- package/dist/tools/create-spec/post-creation.js +23 -4
- package/dist/tools/create-spec.js +47 -144
- package/dist/tools/elicit-requirements-handler.js +20 -12
- package/dist/tools/register-spec-tools/core-spec-tools.js +11 -2
- package/dist/tools/suggest-stack.js +49 -13
- package/dist/tools/update-status/dod-gates.d.ts +8 -0
- package/dist/tools/update-status/dod-gates.js +68 -43
- package/dist/tools/update-status/index.js +109 -74
- package/dist/tools/update-status/qa-gate.js +26 -0
- package/dist/tools/update-status/transition-guard.d.ts +2 -2
- package/dist/tools/update-status/transition-guard.js +14 -7
- package/dist/tools/validate-lint.d.ts +1 -1
- package/dist/tools/validate-lint.js +39 -1
- package/dist/types/spec/core.d.ts +20 -0
- package/dist/types/spec/inputs.d.ts +1 -1
- package/dist/types/stack/index.d.ts +1 -1
- package/dist/types/stack/recommend.d.ts +10 -0
- package/package.json +9 -9
- package/planu-native.json +1 -1
- package/planu-plugin.json +1 -1
- package/dist/engine/elicitation/non-interactive-defaults.d.ts +0 -9
- package/dist/engine/elicitation/non-interactive-defaults.js +0 -28
- package/dist/tools/clarify-requirements/interview-mode.d.ts +0 -42
- 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('
|
|
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
|
-
|
|
10
|
+
const projectEvidence = [];
|
|
11
11
|
if (projectId) {
|
|
12
12
|
const knowledge = await knowledgeStore.getKnowledge(projectId);
|
|
13
13
|
if (knowledge) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
37
|
+
// Recommendations remain candidates until the user confirms a choice.
|
|
32
38
|
const primaryLines = [
|
|
33
39
|
`## ${t('tools.suggest_stack.description')}`,
|
|
34
40
|
'',
|
|
35
|
-
|
|
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
|
-
'###
|
|
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
|
-
//
|
|
79
|
+
// Other optional candidates
|
|
71
80
|
const whyNotLines = [
|
|
72
81
|
'',
|
|
73
|
-
'###
|
|
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
|
-
|
|
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
|
|
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: '
|
|
295
|
-
message: '
|
|
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: '
|
|
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
|
|
310
|
-
|
|
311
|
-
error:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
error:
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
329
|
-
|
|
330
|
-
error:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
|
340
|
-
|
|
341
|
-
error:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
345
|
+
const reverseTransition = isReverseTransition(spec.status, newStatus);
|
|
348
346
|
if (reverseTransition) {
|
|
349
347
|
const reverseValidation = validateReverseTransition({
|
|
350
|
-
from:
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
(
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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 =
|
|
585
|
+
validateScoreSource =
|
|
586
|
+
approvedValidationReportScore !== null ? 'validation-report' : 'validateSpec';
|
|
564
587
|
}
|
|
565
588
|
}
|
|
566
|
-
if (newStatus === 'done' &&
|
|
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 (
|
|
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' ||
|
|
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
|
-
:
|
|
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:
|
|
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:
|
|
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,
|
|
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)
|