@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/.planu-build.json +1 -1
  3. package/dist/cli/commands/create.js +3 -16
  4. package/dist/cli/commands/dashboard.js +2 -16
  5. package/dist/cli/commands/init.js +2 -16
  6. package/dist/cli/commands/spec.js +5 -36
  7. package/dist/cli/commands/status.js +5 -28
  8. package/dist/cli/formatter.d.ts +7 -1
  9. package/dist/cli/formatter.js +49 -6
  10. package/dist/engine/cascade-hooks/hooks/implementing-kickoff.hook.js +38 -6
  11. package/dist/engine/dor-dod/index.d.ts +1 -1
  12. package/dist/engine/dor-dod/index.js +1 -1
  13. package/dist/engine/dor-dod/plan.d.ts +1 -0
  14. package/dist/engine/dor-dod/plan.js +2 -0
  15. package/dist/engine/evidence-gates/artifact-reader.js +1 -0
  16. package/dist/engine/evidence-gates/evidence-skeletons.js +1 -0
  17. package/dist/engine/execution-plan/plan-utils.d.ts +2 -6
  18. package/dist/engine/execution-plan/plan-utils.js +71 -37
  19. package/dist/engine/handoff-artifacts/implementation-review-reader.d.ts +4 -0
  20. package/dist/engine/handoff-artifacts/implementation-review-reader.js +72 -0
  21. package/dist/engine/validator/dor-dod.d.ts +2 -2
  22. package/dist/engine/validator/dor-dod.js +5 -4
  23. package/dist/storage/knowledge-store/knowledge.js +31 -1
  24. package/dist/tools/challenge-spec/agent-challenge-scenarios.js +30 -9
  25. package/dist/tools/challenge-spec/resilience-challenge-scenarios-a.d.ts +2 -4
  26. package/dist/tools/challenge-spec/resilience-challenge-scenarios-a.js +24 -24
  27. package/dist/tools/challenge-spec/resilience-challenge-scenarios.js +1 -1
  28. package/dist/tools/challenge-spec/scenarios-failure.js +1 -2
  29. package/dist/tools/challenge-spec/scenarios-utils.d.ts +3 -0
  30. package/dist/tools/challenge-spec/scenarios-utils.js +16 -0
  31. package/dist/tools/challenge-spec-helpers.d.ts +1 -1
  32. package/dist/tools/challenge-spec-helpers.js +5 -2
  33. package/dist/tools/challenge-spec.js +5 -2
  34. package/dist/tools/create-spec.js +18 -0
  35. package/dist/tools/generate-execution-plan.js +160 -54
  36. package/dist/tools/update-status/dod-gates.js +46 -64
  37. package/dist/tools/update-status/side-effects.d.ts +4 -0
  38. package/dist/tools/update-status/side-effects.js +45 -4
  39. package/dist/tools/validate.js +8 -4
  40. package/dist/transports/transport-factory.js +13 -0
  41. package/dist/types/cli.d.ts +3 -0
  42. package/dist/types/evidence-gates.d.ts +2 -0
  43. package/dist/types/execution.d.ts +5 -0
  44. package/dist/types/handoff-artifacts.d.ts +19 -0
  45. package/dist/types/index.d.ts +1 -0
  46. package/package.json +1 -1
  47. package/planu-plugin.json +1 -1
  48. package/scripts/lib/portable-paths.mjs +1 -0
  49. package/dist/engine/execution-plan/phases-b.d.ts +0 -4
  50. package/dist/engine/execution-plan/phases-b.js +0 -88
  51. package/dist/engine/execution-plan/phases.d.ts +0 -7
  52. package/dist/engine/execution-plan/phases.js +0 -216
@@ -1,18 +1,113 @@
1
- // tools/generate-execution-plan.ts Execution plan tool handler + orchestrator
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 { t, ti } from '../i18n/index.js';
9
- import { generatePlanMarkdown } from '../engine/dor-dod.js';
10
- import { generateSetupPhase, generateDataLayerPhase, generateBusinessLogicPhase, generateUIPhase, generateTestingPhase, generateIntegrationPhase, } from '../engine/execution-plan/phases.js';
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, readSpecContent, } from '../engine/execution-plan/plan-utils.js';
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: t('project.notFound') }],
123
+ content: [{ type: 'text', text: ti('project.notFound', {}) }],
29
124
  isError: true,
30
125
  };
31
126
  }
32
127
  await patternStore.listPatterns(projectId);
33
- const specContent = await readSpecContent(spec);
34
- const phases = generatePhases(spec, knowledge, specContent);
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 planPath;
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
- await specStore.updateSpec(projectId, specId, { planPath });
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
- planPath = undefined;
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: planPath ?? null,
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
- // --- Phase dispatcher (OCP: add new phases here without modifying phase files) ---
81
- function generatePhases(spec, knowledge, specContent) {
218
+ function generatePhases(spec, knowledge, groundedSteps) {
82
219
  const phases = [];
83
- let stepOrder = 1;
84
- const setupSteps = generateSetupPhase(spec, knowledge, stepOrder);
85
- if (setupSteps.length > 0) {
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 { readTransitionLog, transitionLogPath } from '../../storage/transition-log.js';
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 dod = await generateDoD(spec, undefined, undefined, projectPath);
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
- try {
669
- const { readArtifact } = await import('../../engine/handoff-artifacts/io.js');
670
- const result = await readArtifact({ projectId, specId, kind: 'implementation_review' });
671
- if (!result.ok) {
672
- const firstErr = result.errors[0];
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: firstErr?.code === 'ARTIFACT_NOT_FOUND'
675
+ error: verified.reason === 'missing'
676
676
  ? 'implementation_review_missing'
677
677
  : 'implementation_review_invalid',
678
- message: firstErr?.code === 'ARTIFACT_NOT_FOUND'
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(result.errors),
682
- fixHint: firstErr?.code === 'ARTIFACT_NOT_FOUND'
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
- const review = result.payload;
689
- if (isLifecycleStubBody(specId, review.body)) {
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.blockers,
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
- const implementationReviewerBlockers = [];
708
- if (review.reviewer.kind !== 'implementation-review-agent') {
709
- implementationReviewerBlockers.push(`reviewer.kind was ${review.reviewer.kind}. Expected implementation-review-agent.`);
710
- }
711
- if (review.reviewer.agent !== 'planu-implementation-reviewer') {
712
- implementationReviewerBlockers.push(`reviewer.agent was ${review.reviewer.agent}. Expected planu-implementation-reviewer.`);
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
- let implementerActor;
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: ${err instanceof Error ? err.message : String(err)}`,
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 "${implementerActor}".`],
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
- return null;
749
- }
750
- catch (err) {
751
- /* reliability-optional: IMPLEMENTATION_REVIEW_GATE_READ typed gate error blocks done */
752
- reportClassifiedDegradation('IMPLEMENTATION_REVIEW_GATE_READ', err);
753
- return implementationReviewGateError({
754
- specId,
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 = Math.max(policy.minimumLeaseMs, timeoutMs * policy.leaseMultiplier);
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
- })().catch((error) => {
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
  });
@@ -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
- dor: generateDoR(executionSpec),
355
- dod: await generateDoD(executionSpec, result, undefined, projectPath),
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(),
@@ -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;