@planu/cli 5.3.63 → 5.3.65
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 +35 -0
- package/dist/.planu-build.json +1 -1
- package/dist/cli/commands/create.js +3 -16
- package/dist/cli/commands/dashboard.js +2 -16
- package/dist/cli/commands/init.js +2 -16
- package/dist/cli/commands/spec.js +5 -36
- package/dist/cli/commands/status.js +5 -28
- package/dist/cli/formatter.d.ts +7 -1
- package/dist/cli/formatter.js +49 -6
- package/dist/engine/cascade-hooks/hooks/implementing-kickoff.hook.js +38 -6
- package/dist/engine/dor-dod/index.d.ts +1 -1
- package/dist/engine/dor-dod/index.js +1 -1
- package/dist/engine/dor-dod/plan.d.ts +1 -0
- package/dist/engine/dor-dod/plan.js +2 -0
- package/dist/engine/evidence-gates/artifact-reader.js +1 -0
- package/dist/engine/evidence-gates/evidence-skeletons.js +1 -0
- package/dist/engine/execution-plan/plan-utils.d.ts +2 -6
- package/dist/engine/execution-plan/plan-utils.js +71 -37
- package/dist/engine/handoff-artifacts/implementation-review-reader.d.ts +4 -0
- package/dist/engine/handoff-artifacts/implementation-review-reader.js +72 -0
- package/dist/engine/validator/dor-dod.d.ts +2 -2
- package/dist/engine/validator/dor-dod.js +5 -4
- package/dist/storage/knowledge-store/knowledge.js +31 -1
- package/dist/tools/challenge-spec/agent-challenge-scenarios.js +30 -9
- package/dist/tools/challenge-spec/resilience-challenge-scenarios-a.d.ts +2 -4
- package/dist/tools/challenge-spec/resilience-challenge-scenarios-a.js +24 -24
- package/dist/tools/challenge-spec/resilience-challenge-scenarios.js +1 -1
- package/dist/tools/challenge-spec/scenarios-failure.js +1 -2
- package/dist/tools/challenge-spec/scenarios-utils.d.ts +3 -0
- package/dist/tools/challenge-spec/scenarios-utils.js +16 -0
- package/dist/tools/challenge-spec-helpers.d.ts +1 -1
- package/dist/tools/challenge-spec-helpers.js +5 -2
- package/dist/tools/challenge-spec.js +5 -2
- package/dist/tools/create-spec.js +18 -0
- package/dist/tools/generate-execution-plan.js +160 -54
- package/dist/tools/update-status/dod-gates.js +46 -64
- package/dist/tools/update-status/side-effects.d.ts +4 -0
- package/dist/tools/update-status/side-effects.js +45 -4
- package/dist/tools/validate.js +8 -4
- package/dist/transports/transport-factory.js +13 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/evidence-gates.d.ts +2 -0
- package/dist/types/execution.d.ts +5 -0
- package/dist/types/handoff-artifacts.d.ts +19 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
- package/scripts/lib/portable-paths.mjs +1 -0
- package/dist/engine/execution-plan/phases-b.d.ts +0 -4
- package/dist/engine/execution-plan/phases-b.js +0 -88
- package/dist/engine/execution-plan/phases.d.ts +0 -7
- package/dist/engine/execution-plan/phases.js +0 -216
|
@@ -1,18 +1,113 @@
|
|
|
1
|
-
|
|
2
|
-
// SRP: this file owns only tool I/O and phase orchestration.
|
|
3
|
-
// Phase logic lives in src/engine/execution-plan/phases.ts
|
|
4
|
-
// Mobile distribution lives in src/engine/execution-plan/mobile-distribution.ts
|
|
5
|
-
// Utilities live in src/engine/execution-plan/plan-utils.ts
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
6
2
|
import { join } from 'node:path';
|
|
7
3
|
import { specStore, knowledgeStore, patternStore } from '../storage/index.js';
|
|
8
|
-
import {
|
|
9
|
-
import { generatePlanMarkdown } from '../engine/dor-dod.js';
|
|
10
|
-
import {
|
|
4
|
+
import { ti } from '../i18n/index.js';
|
|
5
|
+
import { generatePlanMarkdown, EXECUTION_PLAN_MARKER } from '../engine/dor-dod.js';
|
|
6
|
+
import { extractCanonicalFileOwnership } from '../engine/handoff-packager.js';
|
|
7
|
+
import { readEvidenceArtifacts } from '../engine/evidence-gates/artifact-reader.js';
|
|
11
8
|
import { generateMobileDistributionPhase, isMobileDistributionSpec, } from '../engine/execution-plan/mobile-distribution.js';
|
|
12
9
|
import { isDesktopReleaseSpec, generateDesktopDistributionPhase, } from '../engine/execution-plan/desktop-distribution.js';
|
|
13
|
-
import { determineCriticalPath, findParallelizable,
|
|
10
|
+
import { determineCriticalPath, findParallelizable, validateExecutionPlanGraph, } from '../engine/execution-plan/plan-utils.js';
|
|
14
11
|
import { projectDataDir } from '../storage/base-store.js';
|
|
15
12
|
import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
|
|
13
|
+
async function readSpecBody(spec) {
|
|
14
|
+
try {
|
|
15
|
+
return await readFile(spec.specPath, 'utf-8');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function invalidTaskPlanEntry(taskPlan) {
|
|
22
|
+
const task = taskPlan.tasks.find((candidate) => candidate.id.trim().length === 0 ||
|
|
23
|
+
candidate.title.trim().length === 0 ||
|
|
24
|
+
candidate.acceptanceCriteria.length === 0 ||
|
|
25
|
+
candidate.acceptanceCriteria.some((criterion) => criterion.trim().length === 0));
|
|
26
|
+
if (!task) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const trimmedId = task.id.trim();
|
|
30
|
+
return trimmedId.length > 0 ? trimmedId : '(missing id)';
|
|
31
|
+
}
|
|
32
|
+
async function loadGroundedContract(spec, projectId, specId) {
|
|
33
|
+
const body = await readSpecBody(spec);
|
|
34
|
+
const ownership = body
|
|
35
|
+
? extractCanonicalFileOwnership(body)
|
|
36
|
+
: { present: false, toCreate: [], toModify: [], toTest: [], toTestDeclared: [], blockers: [] };
|
|
37
|
+
const ownedFiles = [...ownership.toCreate, ...ownership.toModify];
|
|
38
|
+
if (!ownership.present || ownedFiles.length === 0) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
issues: [
|
|
42
|
+
'No approved file ownership found under the spec ## Files section; cannot ground execution steps.',
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (ownership.blockers.length > 0) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
issues: [`Approved file ownership is malformed: ${ownership.blockers.join('; ')}`],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const artifacts = await readEvidenceArtifacts({ spec, projectId, specId });
|
|
53
|
+
if (artifacts.invalidArtifacts.some((entry) => entry.startsWith('Task plan evidence'))) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
issues: [
|
|
57
|
+
'Task plan evidence is invalid; regenerate task-plan.json before generating an execution plan.',
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const taskPlan = artifacts.taskPlan;
|
|
62
|
+
if (!taskPlan) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
issues: ['No task-plan.json evidence found; cannot ground execution steps in approved work.'],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (taskPlan.generatedBy === 'planu-evidence-skeleton') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
issues: [
|
|
72
|
+
'task-plan.json is still the auto-generated skeleton; replace it with the real task plan before generating an execution plan.',
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (taskPlan.tasks.length === 0) {
|
|
77
|
+
return { ok: false, issues: ['task-plan.json has no tasks; cannot ground execution steps.'] };
|
|
78
|
+
}
|
|
79
|
+
const invalidTaskId = invalidTaskPlanEntry(taskPlan);
|
|
80
|
+
if (invalidTaskId !== undefined) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
issues: [
|
|
84
|
+
`Task plan entry "${invalidTaskId}" has an empty id, title, or acceptance-criteria list.`,
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const steps = taskPlan.tasks.map((task, index) => ({
|
|
89
|
+
order: index + 1,
|
|
90
|
+
title: task.title,
|
|
91
|
+
description: `Satisfy ${task.acceptanceCriteria.join(', ')} per task ${task.id}.`,
|
|
92
|
+
files: ownedFiles,
|
|
93
|
+
dependsOn: index === 0 ? [] : [index],
|
|
94
|
+
verification: ownership.toTest.length > 0
|
|
95
|
+
? `Tests pass: ${ownership.toTest.join(', ')}`
|
|
96
|
+
: `Task ${task.id} satisfies ${task.acceptanceCriteria.join(', ')}.`,
|
|
97
|
+
estimatedMinutes: 30,
|
|
98
|
+
canRollback: true,
|
|
99
|
+
}));
|
|
100
|
+
return { ok: true, steps };
|
|
101
|
+
}
|
|
102
|
+
async function isPreservedForeignPlan(planPath) {
|
|
103
|
+
try {
|
|
104
|
+
const existing = await readFile(planPath, 'utf-8');
|
|
105
|
+
return !existing.startsWith(EXECUTION_PLAN_MARKER);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return error.code !== 'ENOENT';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
16
111
|
export async function handleGenerateExecutionPlan(args) {
|
|
17
112
|
const { specId, projectId } = args;
|
|
18
113
|
const spec = await specStore.getSpec(projectId, specId);
|
|
@@ -25,33 +120,76 @@ export async function handleGenerateExecutionPlan(args) {
|
|
|
25
120
|
const knowledge = await knowledgeStore.getKnowledge(projectId);
|
|
26
121
|
if (!knowledge) {
|
|
27
122
|
return {
|
|
28
|
-
content: [{ type: 'text', text:
|
|
123
|
+
content: [{ type: 'text', text: ti('project.notFound', {}) }],
|
|
29
124
|
isError: true,
|
|
30
125
|
};
|
|
31
126
|
}
|
|
32
127
|
await patternStore.listPatterns(projectId);
|
|
33
|
-
const
|
|
34
|
-
|
|
128
|
+
const planPath = join(projectDataDir(projectId), 'handoffs', specId, 'execution-plan.md');
|
|
129
|
+
if (await isPreservedForeignPlan(planPath)) {
|
|
130
|
+
await specStore.updateSpec(projectId, specId, { planPath }).catch(() => undefined);
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: 'text',
|
|
135
|
+
text: `Execution plan preserved: an existing execution-plan.md at ${planPath} was not generated by Planu and was left unmodified.`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
type: 'text',
|
|
139
|
+
text: JSON.stringify({ planPath, planPreserved: true }, null, 2),
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const contract = await loadGroundedContract(spec, projectId, specId);
|
|
145
|
+
if (!contract.ok) {
|
|
146
|
+
return {
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: 'text',
|
|
150
|
+
text: `Execution plan generation failed closed: ${contract.issues.join(' | ')}`,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
isError: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const phases = generatePhases(spec, knowledge, contract.steps);
|
|
157
|
+
const graphViolations = validateExecutionPlanGraph(phases);
|
|
158
|
+
if (graphViolations.length > 0) {
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: `Execution plan generation failed closed: ${graphViolations.map((v) => v.message).join(' | ')}`,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
type: 'text',
|
|
167
|
+
text: JSON.stringify({ violations: graphViolations }, null, 2),
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
isError: true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
35
173
|
const criticalPath = determineCriticalPath(phases);
|
|
36
174
|
const parallelizable = findParallelizable(phases);
|
|
37
175
|
const totalSteps = phases.reduce((sum, phase) => sum + phase.steps.length, 0);
|
|
38
176
|
const totalMinutes = phases.reduce((sum, phase) => sum + phase.steps.reduce((s, step) => s + step.estimatedMinutes, 0), 0);
|
|
39
177
|
const plan = { phases, totalSteps, criticalPath, parallelizable };
|
|
40
|
-
let
|
|
178
|
+
let writtenPlanPath;
|
|
41
179
|
try {
|
|
42
|
-
planPath = join(projectDataDir(projectId), 'handoffs', specId, 'execution-plan.md');
|
|
43
180
|
const planContent = generatePlanMarkdown(plan, spec);
|
|
44
181
|
await atomicWriteFile(planPath, planContent, { encoding: 'utf-8' });
|
|
45
|
-
|
|
182
|
+
writtenPlanPath = planPath;
|
|
183
|
+
await specStore.updateSpec(projectId, specId, { planPath: writtenPlanPath });
|
|
46
184
|
/* v8 ignore next 3 -- defensive: filesystem write failure during plan generation */
|
|
47
185
|
}
|
|
48
186
|
catch {
|
|
49
|
-
|
|
187
|
+
writtenPlanPath = undefined;
|
|
50
188
|
}
|
|
51
|
-
// Expose plan data as structured JSON for downstream tools that parse content[1]
|
|
52
189
|
const planJson = JSON.stringify({
|
|
53
190
|
...plan,
|
|
54
|
-
planPath:
|
|
191
|
+
planPath: writtenPlanPath ?? null,
|
|
192
|
+
planPreserved: false,
|
|
55
193
|
summary: {
|
|
56
194
|
phases: phases.length,
|
|
57
195
|
totalSteps,
|
|
@@ -77,40 +215,12 @@ export async function handleGenerateExecutionPlan(args) {
|
|
|
77
215
|
],
|
|
78
216
|
};
|
|
79
217
|
}
|
|
80
|
-
|
|
81
|
-
function generatePhases(spec, knowledge, specContent) {
|
|
218
|
+
function generatePhases(spec, knowledge, groundedSteps) {
|
|
82
219
|
const phases = [];
|
|
83
|
-
let stepOrder = 1;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
phases.push({ name: 'Setup & Scaffolding', steps: setupSteps });
|
|
87
|
-
stepOrder += setupSteps.length;
|
|
88
|
-
}
|
|
89
|
-
if (spec.target === 'backend' || spec.target === 'fullstack' || spec.target === 'database') {
|
|
90
|
-
const dataSteps = generateDataLayerPhase(spec, knowledge, specContent, stepOrder);
|
|
91
|
-
if (dataSteps.length > 0) {
|
|
92
|
-
phases.push({ name: 'Data Layer', steps: dataSteps });
|
|
93
|
-
stepOrder += dataSteps.length;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (spec.target !== 'frontend') {
|
|
97
|
-
const logicSteps = generateBusinessLogicPhase(spec, knowledge, specContent, stepOrder);
|
|
98
|
-
if (logicSteps.length > 0) {
|
|
99
|
-
phases.push({ name: 'Business Logic & API', steps: logicSteps });
|
|
100
|
-
stepOrder += logicSteps.length;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
if (spec.target === 'frontend' || spec.target === 'fullstack') {
|
|
104
|
-
const uiSteps = generateUIPhase(spec, knowledge, specContent, stepOrder);
|
|
105
|
-
if (uiSteps.length > 0) {
|
|
106
|
-
phases.push({ name: 'UI Implementation', steps: uiSteps });
|
|
107
|
-
stepOrder += uiSteps.length;
|
|
108
|
-
}
|
|
220
|
+
let stepOrder = groundedSteps.length + 1;
|
|
221
|
+
if (groundedSteps.length > 0) {
|
|
222
|
+
phases.push({ name: 'Grounded Implementation', steps: groundedSteps });
|
|
109
223
|
}
|
|
110
|
-
const testSteps = generateTestingPhase(spec, knowledge, stepOrder);
|
|
111
|
-
phases.push({ name: 'Testing & Verification', steps: testSteps });
|
|
112
|
-
stepOrder += testSteps.length;
|
|
113
|
-
// SPEC-013: Distribution phase for mobile release specs (iOS/Android/Flutter/RN/Expo)
|
|
114
224
|
if (isMobileDistributionSpec(spec)) {
|
|
115
225
|
const distSteps = generateMobileDistributionPhase(spec, stepOrder);
|
|
116
226
|
if (distSteps.length > 0) {
|
|
@@ -118,16 +228,12 @@ function generatePhases(spec, knowledge, specContent) {
|
|
|
118
228
|
stepOrder += distSteps.length;
|
|
119
229
|
}
|
|
120
230
|
}
|
|
121
|
-
// SPEC-014b: Distribution phase for desktop release specs
|
|
122
231
|
if (knowledge.projectCategory === 'desktop' && isDesktopReleaseSpec(spec)) {
|
|
123
232
|
const desktopDistSteps = generateDesktopDistributionPhase(spec, stepOrder);
|
|
124
233
|
if (desktopDistSteps.length > 0) {
|
|
125
234
|
phases.push({ name: 'Distribution', steps: desktopDistSteps });
|
|
126
|
-
stepOrder += desktopDistSteps.length;
|
|
127
235
|
}
|
|
128
236
|
}
|
|
129
|
-
const integrationSteps = generateIntegrationPhase(spec, knowledge, stepOrder);
|
|
130
|
-
phases.push({ name: 'Integration & Cleanup', steps: integrationSteps });
|
|
131
237
|
return phases;
|
|
132
238
|
}
|
|
133
239
|
//# sourceMappingURL=generate-execution-plan.js.map
|
|
@@ -17,9 +17,10 @@ import { formatKeyValue } from '../output-formatter.js';
|
|
|
17
17
|
import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
|
|
18
18
|
import { verifyCurrentDoneReceipt } from './done-receipt-verifier.js';
|
|
19
19
|
import { projectDataDir } from '../../storage/base-store.js';
|
|
20
|
-
import {
|
|
20
|
+
import { transitionLogPath } from '../../storage/transition-log.js';
|
|
21
21
|
import { ImplementationReviewV1Schema, ReviewFeedbackV1Schema, ValidationReportV1Schema, } from '../../engine/handoff-artifacts/schemas.js';
|
|
22
22
|
import { renderSchemaSkeleton } from '../../engine/handoff-artifacts/schema-skeleton.js';
|
|
23
|
+
import { isLifecycleStubBody, readVerifiedImplementationReview, } from '../../engine/handoff-artifacts/implementation-review-reader.js';
|
|
23
24
|
export { completedJobPublishesReceipt } from './done-receipt-verifier.js';
|
|
24
25
|
const HANDOFF_ARTIFACT_TEMPLATE_TIMESTAMP = '2026-01-01T00:00:00.000Z';
|
|
25
26
|
const HANDOFF_ARTIFACT_TEMPLATE_SCHEMA_VERSION = '1.0.0';
|
|
@@ -186,7 +187,8 @@ export async function runValidateGate(spec, projectPath, forceStatus, forceStatu
|
|
|
186
187
|
* Returns an error ToolResult if blocked (unless force=true), null if passed.
|
|
187
188
|
*/
|
|
188
189
|
export async function checkDodGate(spec, specId, projectId, projectPath, _force) {
|
|
189
|
-
const
|
|
190
|
+
const verifiedReview = await readVerifiedImplementationReview(projectId, specId);
|
|
191
|
+
const dod = await generateDoD(spec, undefined, undefined, projectPath, verifiedReview);
|
|
190
192
|
const requiredItems = dod.items.filter((i) => i.required);
|
|
191
193
|
const blockingItems = requiredItems
|
|
192
194
|
.filter((i) => i.status === 'failed')
|
|
@@ -591,10 +593,6 @@ export async function readApprovedValidationReportGate(specId, projectId, force)
|
|
|
591
593
|
};
|
|
592
594
|
}
|
|
593
595
|
}
|
|
594
|
-
function isLifecycleStubBody(specId, body) {
|
|
595
|
-
return (body === `Spec ${specId} reviewed by planu-spec-reviewer and approved for approval gates.` ||
|
|
596
|
-
body === `Spec ${specId} reviewed by planu-spec-reviewer with requested changes.`);
|
|
597
|
-
}
|
|
598
596
|
/** SPEC-1051: Approval requires a dedicated spec reviewer artifact. */
|
|
599
597
|
export async function checkSpecReviewGate(specId, projectId, _forceApprove) {
|
|
600
598
|
try {
|
|
@@ -665,28 +663,36 @@ export async function checkSpecReviewGate(specId, projectId, _forceApprove) {
|
|
|
665
663
|
}
|
|
666
664
|
}
|
|
667
665
|
export async function checkImplementationReviewGate(specId, projectId, _force) {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
666
|
+
const verified = await readVerifiedImplementationReview(projectId, specId);
|
|
667
|
+
if (verified.ok) {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
switch (verified.reason) {
|
|
671
|
+
case 'missing':
|
|
672
|
+
case 'schema_invalid':
|
|
673
673
|
return implementationReviewGateError({
|
|
674
674
|
specId,
|
|
675
|
-
error:
|
|
675
|
+
error: verified.reason === 'missing'
|
|
676
676
|
? 'implementation_review_missing'
|
|
677
677
|
: 'implementation_review_invalid',
|
|
678
|
-
message:
|
|
678
|
+
message: verified.reason === 'missing'
|
|
679
679
|
? 'No implementation review exists for this spec. An independent planu-implementation-reviewer must review the implementation and write its own evidence before done.'
|
|
680
680
|
: 'Implementation review evidence is malformed or uses an obsolete schema.',
|
|
681
|
-
blockers: schemaBlockers(
|
|
682
|
-
fixHint:
|
|
681
|
+
blockers: schemaBlockers(verified.errors ?? []),
|
|
682
|
+
fixHint: verified.reason === 'missing'
|
|
683
683
|
? 'Have planu-implementation-reviewer write implementation_review.json (ImplementationReviewV1) into the handoff store, then retry update_status(done).'
|
|
684
684
|
: 'Have planu-implementation-reviewer rewrite implementation_review.json as valid ImplementationReviewV1 evidence, then retry done.',
|
|
685
685
|
template: implementationReviewTemplate(),
|
|
686
686
|
});
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
687
|
+
case 'wrong_spec':
|
|
688
|
+
return implementationReviewGateError({
|
|
689
|
+
specId,
|
|
690
|
+
error: 'implementation_review_wrong_spec',
|
|
691
|
+
message: 'Implementation review evidence is bound to a different spec.',
|
|
692
|
+
blockers: [`Review specId was ${verified.review?.specId}. Expected ${specId}.`],
|
|
693
|
+
fixHint: 'Have planu-implementation-reviewer write review evidence bound to this spec, then retry done.',
|
|
694
|
+
});
|
|
695
|
+
case 'stub_body':
|
|
690
696
|
return implementationReviewGateError({
|
|
691
697
|
specId,
|
|
692
698
|
error: 'implementation_review_stub',
|
|
@@ -694,24 +700,22 @@ export async function checkImplementationReviewGate(specId, projectId, _force) {
|
|
|
694
700
|
blockers: [],
|
|
695
701
|
fixHint: 'Replace the stub with genuine planu-implementation-reviewer evidence in the handoff store, then retry done.',
|
|
696
702
|
});
|
|
697
|
-
|
|
698
|
-
if (review.verdict !== 'approved' || review.reviewer.verdict !== 'approved') {
|
|
703
|
+
case 'not_approved':
|
|
699
704
|
return implementationReviewGateError({
|
|
700
705
|
specId,
|
|
701
706
|
error: 'implementation_review_changes_requested',
|
|
702
707
|
message: 'Implementation reviewer requested changes. Done is blocked until the review passes.',
|
|
703
|
-
blockers: review
|
|
708
|
+
blockers: verified.review?.blockers ?? [],
|
|
704
709
|
fixHint: 'Resolve the implementation review blockers and have planu-implementation-reviewer write updated evidence, then retry done.',
|
|
705
710
|
});
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (implementationReviewerBlockers.length > 0) {
|
|
711
|
+
case 'wrong_agent_or_kind': {
|
|
712
|
+
const implementationReviewerBlockers = [];
|
|
713
|
+
if (verified.review?.reviewer.kind !== 'implementation-review-agent') {
|
|
714
|
+
implementationReviewerBlockers.push(`reviewer.kind was ${verified.review?.reviewer.kind}. Expected implementation-review-agent.`);
|
|
715
|
+
}
|
|
716
|
+
if (verified.review?.reviewer.agent !== 'planu-implementation-reviewer') {
|
|
717
|
+
implementationReviewerBlockers.push(`reviewer.agent was ${verified.review?.reviewer.agent}. Expected planu-implementation-reviewer.`);
|
|
718
|
+
}
|
|
715
719
|
return implementationReviewGateError({
|
|
716
720
|
specId,
|
|
717
721
|
error: 'implementation_review_wrong_reviewer',
|
|
@@ -720,54 +724,32 @@ export async function checkImplementationReviewGate(specId, projectId, _force) {
|
|
|
720
724
|
fixHint: 'Have planu-implementation-reviewer write the review artifact with reviewer.kind implementation-review-agent and reviewer.agent planu-implementation-reviewer, then retry done.',
|
|
721
725
|
});
|
|
722
726
|
}
|
|
723
|
-
|
|
724
|
-
try {
|
|
725
|
-
implementerActor = await findImplementingActor(specId, projectId);
|
|
726
|
-
}
|
|
727
|
-
catch (err) {
|
|
728
|
-
/* reliability-optional: TRANSITION_LOG_GATE_READ — typed gate error blocks done */
|
|
729
|
-
reportClassifiedDegradation('TRANSITION_LOG_GATE_READ', err);
|
|
727
|
+
case 'actor_unreadable':
|
|
730
728
|
return implementationReviewGateError({
|
|
731
729
|
specId,
|
|
732
730
|
error: 'implementing_actor_unreadable',
|
|
733
|
-
message: `Could not read the transition log to find the implementing actor: ${
|
|
731
|
+
message: `Could not read the transition log to find the implementing actor: ${verified.readError ?? ''}`,
|
|
734
732
|
blockers: [],
|
|
735
733
|
fixHint: 'Fix the transition log so it can be read, then retry update_status(done).',
|
|
736
734
|
artifactPath: transitionLogPath(projectId),
|
|
737
735
|
});
|
|
738
|
-
|
|
739
|
-
if (implementerActor !== undefined && implementerActor === review.reviewer.agent) {
|
|
736
|
+
case 'self_review':
|
|
740
737
|
return implementationReviewGateError({
|
|
741
738
|
specId,
|
|
742
739
|
error: 'implementation_review_self_review',
|
|
743
740
|
message: 'The implementation reviewer must be a different identity from the implementer. Self-review is blocked.',
|
|
744
|
-
blockers: [`Implementer and reviewer share the identity "${
|
|
741
|
+
blockers: [`Implementer and reviewer share the identity "${verified.implementingActor}".`],
|
|
745
742
|
fixHint: 'Have a different, independent agent write the implementation review evidence, then retry done.',
|
|
746
743
|
});
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
error: 'implementation_review_unreadable',
|
|
756
|
-
message: `Could not read implementation review evidence: ${err instanceof Error ? err.message : String(err)}`,
|
|
757
|
-
blockers: [],
|
|
758
|
-
fixHint: 'Fix the artifact store issue so implementation_review.json is readable, then retry update_status(done).',
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
async function findImplementingActor(specId, projectId) {
|
|
763
|
-
const entries = await readTransitionLog(projectId, specId);
|
|
764
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
765
|
-
const entry = entries[i];
|
|
766
|
-
if (entry?.to === 'implementing' && entry.actor) {
|
|
767
|
-
return entry.actor;
|
|
768
|
-
}
|
|
744
|
+
case 'unreadable':
|
|
745
|
+
return implementationReviewGateError({
|
|
746
|
+
specId,
|
|
747
|
+
error: 'implementation_review_unreadable',
|
|
748
|
+
message: `Could not read implementation review evidence: ${verified.readError ?? ''}`,
|
|
749
|
+
blockers: [],
|
|
750
|
+
fixHint: 'Fix the artifact store issue so implementation_review.json is readable, then retry update_status(done).',
|
|
751
|
+
});
|
|
769
752
|
}
|
|
770
|
-
return undefined;
|
|
771
753
|
}
|
|
772
754
|
function implementationReviewGateError(args) {
|
|
773
755
|
const artifactPath = args.artifactPath ??
|
|
@@ -2,6 +2,10 @@ import type { SpecStatus } from '../../types/index.js';
|
|
|
2
2
|
import { type knowledgeStore } from '../../storage/index.js';
|
|
3
3
|
import type { Spec } from '../../types/spec/core.js';
|
|
4
4
|
import type { RunCascadeResult } from '../../types/cascade-hooks.js';
|
|
5
|
+
export declare function drainPendingPostCommitWork(boundMs: number): Promise<{
|
|
6
|
+
pendingCount: number;
|
|
7
|
+
drained: boolean;
|
|
8
|
+
}>;
|
|
5
9
|
export interface PostCommitTask {
|
|
6
10
|
name: string;
|
|
7
11
|
run: (signal?: AbortSignal) => Promise<unknown>;
|
|
@@ -3,7 +3,48 @@ import { runCascade } from '../../engine/cascade-hooks/runner.js';
|
|
|
3
3
|
import { appendAutopilotLogEntry } from '../../storage/autopilot-log-store.js';
|
|
4
4
|
import { redactFailureMessage, reportClassifiedDegradation, } from '../../errors/classified-degradation.js';
|
|
5
5
|
import { getRuntimePolicy } from '../../engine/runtime-policy.js';
|
|
6
|
+
import { createExecutionScope } from '../../engine/execution/context.js';
|
|
6
7
|
const queuedTransitions = new Map();
|
|
8
|
+
const CASCADE_ISOLATED_SCOPE_MS = 30_000;
|
|
9
|
+
const pendingPostCommitWork = new Set();
|
|
10
|
+
function resolveTaskLeaseMs(task) {
|
|
11
|
+
const policy = getRuntimePolicy().postCommit;
|
|
12
|
+
const timeoutMs = task.timeoutMs ?? policy.defaultTaskTimeoutMs;
|
|
13
|
+
return Math.max(policy.minimumLeaseMs, timeoutMs * policy.leaseMultiplier);
|
|
14
|
+
}
|
|
15
|
+
function trackIsolatedWork(deadlineAt, fn) {
|
|
16
|
+
const scope = createExecutionScope({ deadlineAt });
|
|
17
|
+
const work = scope.run(fn).finally(() => {
|
|
18
|
+
scope.dispose();
|
|
19
|
+
});
|
|
20
|
+
pendingPostCommitWork.add(work);
|
|
21
|
+
const untrack = () => {
|
|
22
|
+
pendingPostCommitWork.delete(work);
|
|
23
|
+
};
|
|
24
|
+
work.then(untrack, untrack);
|
|
25
|
+
return work;
|
|
26
|
+
}
|
|
27
|
+
export async function drainPendingPostCommitWork(boundMs) {
|
|
28
|
+
const pending = [...pendingPostCommitWork];
|
|
29
|
+
if (pending.length === 0) {
|
|
30
|
+
return { pendingCount: 0, drained: true };
|
|
31
|
+
}
|
|
32
|
+
let timer;
|
|
33
|
+
const timeout = new Promise((resolve) => {
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
resolve('timeout');
|
|
36
|
+
}, boundMs);
|
|
37
|
+
timer.unref();
|
|
38
|
+
});
|
|
39
|
+
const outcome = await Promise.race([
|
|
40
|
+
Promise.allSettled(pending).then(() => 'settled'),
|
|
41
|
+
timeout,
|
|
42
|
+
]);
|
|
43
|
+
if (timer !== undefined) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
return { pendingCount: pending.length, drained: outcome === 'settled' };
|
|
47
|
+
}
|
|
7
48
|
function rememberTransition(transitionKey) {
|
|
8
49
|
if (queuedTransitions.has(transitionKey)) {
|
|
9
50
|
return false;
|
|
@@ -21,7 +62,7 @@ async function runBoundedPostCommitTask(args) {
|
|
|
21
62
|
const { projectId, specId, transitionId, task } = args;
|
|
22
63
|
const policy = getRuntimePolicy().postCommit;
|
|
23
64
|
const timeoutMs = task.timeoutMs ?? policy.defaultTaskTimeoutMs;
|
|
24
|
-
const leaseMs =
|
|
65
|
+
const leaseMs = resolveTaskLeaseMs(task);
|
|
25
66
|
const claim = await specStore.claimPostCommitTask(projectId, specId, transitionId, task.name, {
|
|
26
67
|
leaseMs,
|
|
27
68
|
});
|
|
@@ -120,7 +161,7 @@ export function queuePostCommitTasks(args) {
|
|
|
120
161
|
if (!rememberTransition(transitionKey)) {
|
|
121
162
|
return [];
|
|
122
163
|
}
|
|
123
|
-
void Promise.allSettled(args.tasks.map((task) => runBoundedPostCommitTask({ ...args, task }))).finally(() => {
|
|
164
|
+
void Promise.allSettled(args.tasks.map((task) => trackIsolatedWork(Date.now() + resolveTaskLeaseMs(task), () => runBoundedPostCommitTask({ ...args, task })))).finally(() => {
|
|
124
165
|
queuedTransitions.delete(transitionKey);
|
|
125
166
|
});
|
|
126
167
|
return args.tasks.map((task) => task.name);
|
|
@@ -205,13 +246,13 @@ export function fireAndForgetSideEffects(params) {
|
|
|
205
246
|
allSpecs,
|
|
206
247
|
};
|
|
207
248
|
// Launch cascade — fire-and-forget, never blocks the response
|
|
208
|
-
void (async () => {
|
|
249
|
+
void trackIsolatedWork(Date.now() + CASCADE_ISOLATED_SCOPE_MS, async () => {
|
|
209
250
|
const [disabledByConfig, disabledByEnv] = await Promise.all([
|
|
210
251
|
readDisabledByConfig(projectPath),
|
|
211
252
|
Promise.resolve(readDisabledByEnv()),
|
|
212
253
|
]);
|
|
213
254
|
await runCascade(ctx, { disabledByConfig, disabledByEnv });
|
|
214
|
-
})
|
|
255
|
+
}).catch((error) => {
|
|
215
256
|
/* reliability-optional: ASYNC_CASCADE_FAILURE — post-commit task ledger captures retry state */
|
|
216
257
|
reportClassifiedDegradation('ASYNC_CASCADE_FAILURE', error);
|
|
217
258
|
});
|
package/dist/tools/validate.js
CHANGED
|
@@ -59,6 +59,7 @@ import { resolveProjectId, missingProjectIdError } from './resolve-project-id.js
|
|
|
59
59
|
import { specStore, knowledgeStore } from '../storage/index.js';
|
|
60
60
|
import { validateSpec, generateDoR, generateDoD } from '../engine/validator.js';
|
|
61
61
|
import { isBlockingQualitySeverity } from '../engine/validator/dor-dod.js';
|
|
62
|
+
import { readVerifiedImplementationReview } from '../engine/handoff-artifacts/implementation-review-reader.js';
|
|
62
63
|
import { calcQualityScore } from '../engine/auditor-scoring.js';
|
|
63
64
|
import { dispatchFeedbackEvent } from './learn.js';
|
|
64
65
|
import { t, ti } from '../i18n/index.js';
|
|
@@ -350,10 +351,13 @@ export async function executeValidate(args, server, onProgress) {
|
|
|
350
351
|
}
|
|
351
352
|
const result = await runStage('spec-scan', 'validate spec implementation', () => validateSpec(executionSpec, projectPath));
|
|
352
353
|
const graphCoverage = await runStage('graph', 'validate graph coverage', () => buildGraphCoverageReport({ projectId, projectPath, specId }));
|
|
353
|
-
const { dor, dod } = await runStage('definitions', 'validate ready and done definitions', async () =>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
const { dor, dod } = await runStage('definitions', 'validate ready and done definitions', async () => {
|
|
355
|
+
const verifiedReview = await readVerifiedImplementationReview(projectId, specId);
|
|
356
|
+
return {
|
|
357
|
+
dor: generateDoR(executionSpec),
|
|
358
|
+
dod: await generateDoD(executionSpec, result, undefined, projectPath, verifiedReview),
|
|
359
|
+
};
|
|
360
|
+
});
|
|
357
361
|
const implementationQualityScore = calcQualityScore(result.qualityIssues);
|
|
358
362
|
const auditedFiles = [...new Set(result.qualityIssues.map((i) => i.file))];
|
|
359
363
|
const { conventionViolations, regressionDetected } = await runStage('conventions', 'validate project conventions', () => scanProjectConventions(projectId, projectPath));
|
|
@@ -3,10 +3,12 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import { createHttpTransport } from './http-transport.js';
|
|
5
5
|
import { compactToolsListMessage, isToolsListResponse, } from '../engine/compact/tool-list-compactor.js';
|
|
6
|
+
import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
|
|
6
7
|
const VALID_TRANSPORTS = ['stdio', 'http'];
|
|
7
8
|
const DEFAULT_PORT = 3100;
|
|
8
9
|
const DEFAULT_HOST = '127.0.0.1';
|
|
9
10
|
const SHUTDOWN_GRACE_MS = 100;
|
|
11
|
+
const POST_COMMIT_DRAIN_MS = 1_200;
|
|
10
12
|
function descendantPids(parentPid) {
|
|
11
13
|
if (process.platform === 'win32') {
|
|
12
14
|
return [];
|
|
@@ -114,6 +116,13 @@ function installToolsListCompaction(transport) {
|
|
|
114
116
|
const wrappedTransport = transport;
|
|
115
117
|
wrappedTransport.send = compactingSend;
|
|
116
118
|
}
|
|
119
|
+
async function drainPostCommitWorkOnShutdown() {
|
|
120
|
+
const { drainPendingPostCommitWork } = await import('../tools/update-status/side-effects.js');
|
|
121
|
+
const drainResult = await drainPendingPostCommitWork(POST_COMMIT_DRAIN_MS);
|
|
122
|
+
if (!drainResult.drained) {
|
|
123
|
+
reportClassifiedDegradation('POST_COMMIT_DRAIN_TIMEOUT', new Error(`Shutdown drain exceeded ${String(POST_COMMIT_DRAIN_MS)}ms with ${String(drainResult.pendingCount)} pending`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
117
126
|
function installStdioShutdownHandlers(server) {
|
|
118
127
|
let shuttingDown = false;
|
|
119
128
|
const handlers = new Map();
|
|
@@ -153,6 +162,10 @@ function installStdioShutdownHandlers(server) {
|
|
|
153
162
|
})
|
|
154
163
|
.catch(() => {
|
|
155
164
|
/* shutdown remains best-effort */
|
|
165
|
+
})
|
|
166
|
+
.then(() => drainPostCommitWorkOnShutdown())
|
|
167
|
+
.catch((err) => {
|
|
168
|
+
reportClassifiedDegradation('POST_COMMIT_DRAIN_UNAVAILABLE', err);
|
|
156
169
|
})
|
|
157
170
|
.then(() => Promise.all([
|
|
158
171
|
server.close(),
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface CliCommand {
|
|
|
17
17
|
/** Execute the command with parsed args */
|
|
18
18
|
run(args: string[], flags?: GlobalFlags): Promise<void>;
|
|
19
19
|
}
|
|
20
|
+
export interface CliJsonFallbackEnvelope {
|
|
21
|
+
content: unknown[];
|
|
22
|
+
}
|
|
20
23
|
export interface StatusCommandValues {
|
|
21
24
|
set?: string;
|
|
22
25
|
'project-id'?: string;
|
|
@@ -20,9 +20,11 @@ export interface DiscoveryEvidence {
|
|
|
20
20
|
meaning: string;
|
|
21
21
|
}[];
|
|
22
22
|
}
|
|
23
|
+
export type TaskPlanGeneratedBy = 'planu-evidence-skeleton';
|
|
23
24
|
export interface TaskPlanEvidence {
|
|
24
25
|
version: 1;
|
|
25
26
|
provenance?: ValidationArtifactProvenance;
|
|
27
|
+
generatedBy?: TaskPlanGeneratedBy;
|
|
26
28
|
tasks: {
|
|
27
29
|
id: string;
|
|
28
30
|
title: string;
|
|
@@ -26,6 +26,11 @@ export interface ExecutionStep {
|
|
|
26
26
|
parallelizable?: boolean;
|
|
27
27
|
cycle?: PlanCycle;
|
|
28
28
|
}
|
|
29
|
+
export type ExecutionPlanGraphViolationCode = 'SELF_DEPENDENT_STEP' | 'INVALID_STEP_DEPENDENCY' | 'CYCLIC_STEP_DEPENDENCIES';
|
|
30
|
+
export interface ExecutionPlanGraphViolation {
|
|
31
|
+
code: ExecutionPlanGraphViolationCode;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
29
34
|
export interface PhaseTracker {
|
|
30
35
|
currentPhase: SddPhase;
|
|
31
36
|
specId: string | null;
|