@planu/cli 5.3.63 → 5.3.64

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 (50) hide show
  1. package/CHANGELOG.md +29 -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/tools/challenge-spec/resilience-challenge-scenarios-a.d.ts +2 -4
  24. package/dist/tools/challenge-spec/resilience-challenge-scenarios-a.js +24 -24
  25. package/dist/tools/challenge-spec/resilience-challenge-scenarios.js +1 -1
  26. package/dist/tools/challenge-spec/scenarios-failure.js +1 -2
  27. package/dist/tools/challenge-spec/scenarios-utils.d.ts +3 -0
  28. package/dist/tools/challenge-spec/scenarios-utils.js +16 -0
  29. package/dist/tools/challenge-spec-helpers.d.ts +1 -1
  30. package/dist/tools/challenge-spec-helpers.js +5 -2
  31. package/dist/tools/challenge-spec.js +5 -2
  32. package/dist/tools/create-spec.js +18 -0
  33. package/dist/tools/generate-execution-plan.js +160 -54
  34. package/dist/tools/update-status/dod-gates.js +46 -64
  35. package/dist/tools/update-status/side-effects.d.ts +4 -0
  36. package/dist/tools/update-status/side-effects.js +45 -4
  37. package/dist/tools/validate.js +8 -4
  38. package/dist/transports/transport-factory.js +13 -0
  39. package/dist/types/cli.d.ts +3 -0
  40. package/dist/types/evidence-gates.d.ts +2 -0
  41. package/dist/types/execution.d.ts +5 -0
  42. package/dist/types/handoff-artifacts.d.ts +19 -0
  43. package/dist/types/index.d.ts +1 -0
  44. package/package.json +1 -1
  45. package/planu-plugin.json +1 -1
  46. package/scripts/lib/portable-paths.mjs +1 -0
  47. package/dist/engine/execution-plan/phases-b.d.ts +0 -4
  48. package/dist/engine/execution-plan/phases-b.js +0 -88
  49. package/dist/engine/execution-plan/phases.d.ts +0 -7
  50. package/dist/engine/execution-plan/phases.js +0 -216
@@ -0,0 +1,72 @@
1
+ import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
2
+ import { readTransitionLog } from '../../storage/transition-log.js';
3
+ import { readArtifact } from './io.js';
4
+ export function isLifecycleStubBody(specId, body) {
5
+ return (body === `Spec ${specId} reviewed by planu-spec-reviewer and approved for approval gates.` ||
6
+ body === `Spec ${specId} reviewed by planu-spec-reviewer with requested changes.`);
7
+ }
8
+ async function findImplementingActor(specId, projectId) {
9
+ const entries = await readTransitionLog(projectId, specId);
10
+ for (let i = entries.length - 1; i >= 0; i--) {
11
+ const entry = entries[i];
12
+ if (entry?.to === 'implementing' && entry.actor) {
13
+ return entry.actor;
14
+ }
15
+ }
16
+ return undefined;
17
+ }
18
+ export async function readVerifiedImplementationReview(projectId, specId) {
19
+ try {
20
+ const result = await readArtifact({ projectId, specId, kind: 'implementation_review' });
21
+ if (!result.ok) {
22
+ const firstErr = result.errors[0];
23
+ return {
24
+ ok: false,
25
+ reason: firstErr?.code === 'ARTIFACT_NOT_FOUND' ? 'missing' : 'schema_invalid',
26
+ errors: result.errors,
27
+ };
28
+ }
29
+ const review = result.payload;
30
+ if (review.specId !== specId) {
31
+ return { ok: false, reason: 'wrong_spec', review };
32
+ }
33
+ if (isLifecycleStubBody(specId, review.body)) {
34
+ return { ok: false, reason: 'stub_body', review };
35
+ }
36
+ if (review.verdict !== 'approved' || review.reviewer.verdict !== 'approved') {
37
+ return { ok: false, reason: 'not_approved', review };
38
+ }
39
+ if (review.reviewer.kind !== 'implementation-review-agent' ||
40
+ review.reviewer.agent !== 'planu-implementation-reviewer') {
41
+ return { ok: false, reason: 'wrong_agent_or_kind', review };
42
+ }
43
+ let implementerActor;
44
+ try {
45
+ implementerActor = await findImplementingActor(specId, projectId);
46
+ }
47
+ catch (err) {
48
+ /* reliability-optional: TRANSITION_LOG_GATE_READ — typed gate error blocks done */
49
+ reportClassifiedDegradation('TRANSITION_LOG_GATE_READ', err);
50
+ return {
51
+ ok: false,
52
+ reason: 'actor_unreadable',
53
+ review,
54
+ readError: err instanceof Error ? err.message : String(err),
55
+ };
56
+ }
57
+ if (implementerActor !== undefined && implementerActor === review.reviewer.agent) {
58
+ return { ok: false, reason: 'self_review', review, implementingActor: implementerActor };
59
+ }
60
+ return { ok: true, review, implementingActor: implementerActor ?? null };
61
+ }
62
+ catch (err) {
63
+ /* reliability-optional: IMPLEMENTATION_REVIEW_GATE_READ — typed gate error blocks done */
64
+ reportClassifiedDegradation('IMPLEMENTATION_REVIEW_GATE_READ', err);
65
+ return {
66
+ ok: false,
67
+ reason: 'unreadable',
68
+ readError: err instanceof Error ? err.message : String(err),
69
+ };
70
+ }
71
+ }
72
+ //# sourceMappingURL=implementation-review-reader.js.map
@@ -1,4 +1,4 @@
1
- import type { Spec, ValidateResult, DefinitionOfReady, DefinitionOfDone, CodeState } from '../../types/index.js';
1
+ import type { Spec, ValidateResult, DefinitionOfReady, DefinitionOfDone, CodeState, VerifiedImplementationReviewResult } from '../../types/index.js';
2
2
  export declare function isBlockingQualitySeverity(severity: unknown): boolean;
3
3
  /**
4
4
  * Generate a Definition of Ready checklist for a spec.
@@ -7,5 +7,5 @@ export declare function generateDoR(spec: Spec): DefinitionOfReady;
7
7
  /**
8
8
  * Generate a Definition of Done checklist for a spec.
9
9
  */
10
- export declare function generateDoD(spec: Spec, validationResult?: ValidateResult, codeState?: CodeState, projectPath?: string): Promise<DefinitionOfDone>;
10
+ export declare function generateDoD(spec: Spec, validationResult?: ValidateResult, codeState?: CodeState, projectPath?: string, verifiedReview?: VerifiedImplementationReviewResult): Promise<DefinitionOfDone>;
11
11
  //# sourceMappingURL=dor-dod.d.ts.map
@@ -120,10 +120,11 @@ export function generateDoR(spec) {
120
120
  generatedAt: new Date().toISOString(),
121
121
  };
122
122
  }
123
- function buildDoDItems(spec, score, hasBlockingQualityIssues, hasTestFiles) {
123
+ function buildDoDItems(spec, score, hasBlockingQualityIssues, hasTestFiles, verifiedReview) {
124
124
  // If spec is already marked "done", manual gates are implicitly satisfied —
125
125
  // the user explicitly transitioned the spec, which implies review/tests/docs passed.
126
126
  const isDone = spec.status === 'done';
127
+ const reviewApproved = isDone || verifiedReview?.ok === true;
127
128
  return [
128
129
  {
129
130
  id: 'dod-1',
@@ -157,7 +158,7 @@ function buildDoDItems(spec, score, hasBlockingQualityIssues, hasTestFiles) {
157
158
  category: 'review',
158
159
  required: true,
159
160
  autoCheck: false,
160
- status: isDone ? 'passed' : 'pending',
161
+ status: reviewApproved ? 'passed' : 'pending',
161
162
  },
162
163
  {
163
164
  id: 'dod-5',
@@ -321,12 +322,12 @@ function gateCategory(gate) {
321
322
  /**
322
323
  * Generate a Definition of Done checklist for a spec.
323
324
  */
324
- export async function generateDoD(spec, validationResult, codeState, projectPath) {
325
+ export async function generateDoD(spec, validationResult, codeState, projectPath, verifiedReview) {
325
326
  const score = validationResult?.score ?? 0;
326
327
  const hasBlockingQualityIssues = validationResult?.qualityIssues.some((issue) => isBlockingQualitySeverity(issue.severity)) ??
327
328
  false;
328
329
  const hasTestFiles = await detectTestFiles(spec, codeState);
329
- const items = buildDoDItems(spec, score, hasBlockingQualityIssues, hasTestFiles);
330
+ const items = buildDoDItems(spec, score, hasBlockingQualityIssues, hasTestFiles, verifiedReview);
330
331
  // Override actuals check with progress.md parsing
331
332
  const actualsItem = items.find((i) => i.id === 'dod-8');
332
333
  if (actualsItem?.status === 'pending') {
@@ -1,4 +1,5 @@
1
1
  import type { FailureScenario, Spec } from '../../types/index.js';
2
+ import { type ChallengeCapabilities } from './scenarios-utils.js';
2
3
  export declare const EXTERNAL_CALL_KEYWORDS: string[];
3
4
  export declare const VALIDATION_KEYWORDS: string[];
4
5
  export declare const RATE_LIMIT_KEYWORDS: string[];
@@ -24,10 +25,7 @@ export declare function hasConcurrencySignal(spec: Spec, specContent: string): b
24
25
  * Returns true when the spec describes a distributed saga.
25
26
  */
26
27
  export declare function hasSagaSignal(spec: Spec, specContent: string): boolean;
27
- /**
28
- * Generate validation boundary challenge scenarios.
29
- */
30
- export declare function generateValidationBoundaryScenarios(spec: Spec): FailureScenario[];
28
+ export declare function generateValidationBoundaryScenarios(spec: Spec, capabilities: ChallengeCapabilities): FailureScenario[];
31
29
  /**
32
30
  * Generate cascade failure challenge scenarios.
33
31
  */
@@ -97,11 +97,8 @@ export function hasConcurrencySignal(spec, specContent) {
97
97
  export function hasSagaSignal(spec, specContent) {
98
98
  return contentMentions(`${spec.title}\n${spec.tags.join(' ')}\n${specContent}`, SAGA_KEYWORDS);
99
99
  }
100
- /**
101
- * Generate validation boundary challenge scenarios.
102
- */
103
- export function generateValidationBoundaryScenarios(spec) {
104
- return [
100
+ export function generateValidationBoundaryScenarios(spec, capabilities) {
101
+ const scenarios = [
105
102
  {
106
103
  scenario: `[${spec.id}] Validation Boundary — String Fields: ` +
107
104
  'What happens when a required string field receives: (a) empty string "", (b) null, (c) field absent from body entirely, ' +
@@ -119,7 +116,9 @@ export function generateValidationBoundaryScenarios(spec) {
119
116
  dataConsistency: 'Accepting empty strings as valid inputs leads to records with meaningless values polluting the database.',
120
117
  userExperience: 'Clear per-field error messages with the specific constraint violated improve form UX significantly.',
121
118
  },
122
- {
119
+ ];
120
+ if (capabilities.numericInput) {
121
+ scenarios.push({
123
122
  scenario: `[${spec.id}] Validation Boundary — Numeric Fields: ` +
124
123
  'What happens when a numeric field receives: (a) value exactly at min boundary (e.g., min=0 → test with -1, 0, 1), ' +
125
124
  '(b) value exactly at max boundary, (c) NaN, (d) Infinity or -Infinity, ' +
@@ -132,24 +131,25 @@ export function generateValidationBoundaryScenarios(spec) {
132
131
  'Coercion policy must be explicit: either always reject type mismatches or always coerce with documented behavior.',
133
132
  dataConsistency: 'NaN or Infinity stored in numeric columns causes silent corruption or runtime exceptions.',
134
133
  userExperience: 'Off-by-one errors in validation create confusing UX where users cannot submit valid data.',
135
- },
136
- {
137
- scenario: `[${spec.id}] Validation Security — Injection Payloads: ` +
138
- 'Does the validation layer handle security-relevant strings without crashing or leaking internals? ' +
139
- "Test with: SQL injection ('; DROP TABLE users; --), XSS (<script>alert(1)</script>), " +
140
- 'path traversal (../../etc/passwd), null bytes (\\0), template injection ({{7*7}}). ' +
141
- 'Validation is not the primary defense (that is parameterized queries / output encoding), ' +
142
- 'but it must not crash and must not expose stack traces.',
143
- probability: 'medium',
144
- impact: 'critical',
145
- currentHandling: 'Injection payloads may cause unhandled exceptions in validation middleware, exposing internal error details.',
146
- requiredHandling: 'All string inputs must be treated as untrusted. ' +
147
- 'Validation rejects malformed types; sanitization and parameterized queries handle injection prevention downstream. ' +
148
- 'Error responses must never include the rejected payload or stack trace return sanitized field name + constraint only.',
149
- dataConsistency: 'If injection payloads reach the database layer (even as errors), they can leak schema information via error messages.',
150
- userExperience: 'Stack traces exposed in 400 responses are a security vulnerability use opaque error IDs instead.',
151
- },
152
- ];
134
+ });
135
+ }
136
+ scenarios.push({
137
+ scenario: `[${spec.id}] Validation Security Injection Payloads: ` +
138
+ 'Does the validation layer handle security-relevant strings without crashing or leaking internals? ' +
139
+ "Test with: SQL injection ('; DROP TABLE users; --), XSS (<script>alert(1)</script>), " +
140
+ 'path traversal (../../etc/passwd), null bytes (\\0), template injection ({{7*7}}). ' +
141
+ 'Validation is not the primary defense (that is parameterized queries / output encoding), ' +
142
+ 'but it must not crash and must not expose stack traces.',
143
+ probability: 'medium',
144
+ impact: 'critical',
145
+ currentHandling: 'Injection payloads may cause unhandled exceptions in validation middleware, exposing internal error details.',
146
+ requiredHandling: 'All string inputs must be treated as untrusted. ' +
147
+ 'Validation rejects malformed types; sanitization and parameterized queries handle injection prevention downstream. ' +
148
+ 'Error responses must never include the rejected payload or stack trace return sanitized field name + constraint only.',
149
+ dataConsistency: 'If injection payloads reach the database layer (even as errors), they can leak schema information via error messages.',
150
+ userExperience: 'Stack traces exposed in 400 responses are a security vulnerability — use opaque error IDs instead.',
151
+ });
152
+ return scenarios;
153
153
  }
154
154
  /**
155
155
  * Generate cascade failure challenge scenarios.
@@ -15,7 +15,7 @@ export function generateResilienceChallengeScenarios(spec, specContent, knowledg
15
15
  const resolvedCapabilities = capabilities ?? detectChallengeCapabilities(spec, specContent);
16
16
  if (resolvedCapabilities.validation &&
17
17
  (resolvedCapabilities.userInput || resolvedCapabilities.networkApi)) {
18
- scenarios.push(...generateValidationBoundaryScenarios(spec));
18
+ scenarios.push(...generateValidationBoundaryScenarios(spec, resolvedCapabilities));
19
19
  }
20
20
  if (resolvedCapabilities.externalService || resolvedCapabilities.payment) {
21
21
  scenarios.push(...generateCascadeFailureScenarios(spec));
@@ -3,8 +3,7 @@ import { contentMentions, detectChallengeCapabilities } from './scenarios-utils.
3
3
  export function generateFailureScenarios(spec, content, _knowledge) {
4
4
  const scenarios = [];
5
5
  const capabilities = detectChallengeCapabilities(spec, content);
6
- // Network failures
7
- if (capabilities.networkApi) {
6
+ if (capabilities.outboundDependency) {
8
7
  scenarios.push({
9
8
  scenario: 'API endpoint becomes unreachable (network timeout)',
10
9
  probability: 'medium',
@@ -24,6 +24,9 @@ export interface ChallengeCapabilities {
24
24
  rateLimit: boolean;
25
25
  saga: boolean;
26
26
  platform: boolean;
27
+ outboundDependency: boolean;
28
+ numericInput: boolean;
29
+ rollout: boolean;
27
30
  }
28
31
  /** Boundary-aware, affirmative replacement for advisory substring checks. */
29
32
  export declare function contentMentions(content: string, keywords: string[]): boolean;
@@ -87,6 +87,22 @@ const CAPABILITY_SIGNALS = {
87
87
  platform: [
88
88
  /\b(?:smart\s+contract|solidity|discord\s+bot|telegram\s+bot|iot\s+device|firmware|infrastructure\s+as\s+code|terraform|machine\s+learning\s+model)\b/i,
89
89
  ],
90
+ outboundDependency: [
91
+ /\boutbound\s+(?:http\s+)?(?:client|call|request|dependency)\b/i,
92
+ /\b(?:fetch(?:es|ing)?|calls?|invokes?)\b(?:\W+\w+){0,4}?\W+(?:api|endpoint|service|dependency)\b/i,
93
+ /\bremote\s+dependency\s+call\b/i,
94
+ ],
95
+ numericInput: [
96
+ /\bnumeric\s+(?:field|input|value|schema|constraint)\b/i,
97
+ /\b(?:integer|float|decimal)\s+field\b/i,
98
+ /\b(?:min|max)(?:imum)?\s+(?:value|constraint|boundary)\b/i,
99
+ /\bz\.number\(/i,
100
+ ],
101
+ rollout: [
102
+ /\b(?:canary|phased\s+rollout|gradual\s+rollout|blue-green\s+deploy(?:ment)?|rolling\s+deployment|feature\s+flag)\b/i,
103
+ /\broll(?:s|ed|ing)?\s+out\b(?:\W+\w+){0,4}?\W+(?:in\s+phases|gradually)\b/i,
104
+ /\bdeployment\s+strategy\b/i,
105
+ ],
90
106
  };
91
107
  const AUTHENTICATION_BARE_TOKEN_RE = /\bauth(?:entication|enticate)?\b/i;
92
108
  // A bare "auth" token next to registry/package-manager vocabulary describes a
@@ -9,7 +9,7 @@ export interface ConcurrencyAnalysisResult extends ConcurrencyAnalysis {
9
9
  suppressedCount: number;
10
10
  }
11
11
  export declare function generateConcurrencyAnalysis(_spec: Spec, content: string, _knowledge: ProjectKnowledge): ConcurrencyAnalysisResult;
12
- export declare function buildScalabilityAssessment(spec: Spec, knowledge: ProjectKnowledge, scenarios: FailureScenario[]): string;
12
+ export declare function buildScalabilityAssessment(spec: Spec, knowledge: ProjectKnowledge, scenarios: FailureScenario[], hasRolloutEvidence?: boolean): string;
13
13
  export declare function calculateOverallRisk(scenarios: FailureScenario[], concurrency: ConcurrencyAnalysis): RiskLevel;
14
14
  export declare function readSpecContent(spec: Spec): Promise<string>;
15
15
  //# sourceMappingURL=challenge-spec-helpers.d.ts.map
@@ -93,7 +93,7 @@ export function generateConcurrencyAnalysis(_spec, content, _knowledge) {
93
93
  return { hotPaths, raceConditions, sharedState, recommendations, suppressedCount };
94
94
  }
95
95
  // --- Scalability and risk ---
96
- export function buildScalabilityAssessment(spec, knowledge, scenarios) {
96
+ export function buildScalabilityAssessment(spec, knowledge, scenarios, hasRolloutEvidence = false) {
97
97
  const criticalCount = scenarios.filter((s) => s.impact === 'critical').length;
98
98
  const arch = knowledge.architecture.primary;
99
99
  const parts = [];
@@ -108,7 +108,10 @@ export function buildScalabilityAssessment(spec, knowledge, scenarios) {
108
108
  parts.push('No critical scalability concerns for the current scope.');
109
109
  }
110
110
  if (spec.scope === 'architectural' || spec.scope === 'cross-module') {
111
- parts.push('Cross-module scope increases blast radius. Consider phased rollout with feature flags.');
111
+ parts.push('Cross-module scope increases blast radius.');
112
+ if (hasRolloutEvidence) {
113
+ parts.push('Consider phased rollout with feature flags.');
114
+ }
112
115
  }
113
116
  return parts.join(' ');
114
117
  }
@@ -268,7 +268,7 @@ export async function handleChallengeSpec(args, server) {
268
268
  const actionableFailureScenarios = failureScenarios.filter((scenario) => isScenarioSupportedByCapabilities(scenario, capabilities));
269
269
  const suppressedScenarioCount = failureScenarios.length - actionableFailureScenarios.length;
270
270
  // 7. Build scalability assessment from grounded scenarios only.
271
- const scalabilityAssessment = buildScalabilityAssessment(spec, knowledge, actionableFailureScenarios);
271
+ const scalabilityAssessment = buildScalabilityAssessment(spec, knowledge, actionableFailureScenarios, capabilities.rollout);
272
272
  // 8. Calculate overall risk from grounded scenarios only.
273
273
  const overallRisk = calculateOverallRisk(actionableFailureScenarios, concurrencyAnalysis);
274
274
  // 9. Compute relevance scores and select top-3 diagnostics (SPEC-338) — retained as-is
@@ -465,7 +465,10 @@ function isScenarioSupportedByCapabilities(scenario, capabilities) {
465
465
  ],
466
466
  [/\b(?:sql|nosql|database|connection pool)\b/, capabilities.database],
467
467
  [/\b(?:event contract|event schema|message broker|kafka|rabbitmq|dlq)\b/, capabilities.events],
468
- [/\b(?:api endpoint|http route|http status|network timeout)\b/, capabilities.networkApi],
468
+ [
469
+ /\b(?:api endpoint|http route|http status|network timeout)\b/,
470
+ capabilities.outboundDependency,
471
+ ],
469
472
  [/\b(?:traffic spike|load test|auto-scaling|throughput)\b/, capabilities.scale],
470
473
  ];
471
474
  return rules.every(([pattern, supported]) => !pattern.test(haystack) || supported);
@@ -1335,8 +1335,26 @@ async function prepareCreateSpecCandidate(initialParams, server) {
1335
1335
  },
1336
1336
  };
1337
1337
  }
1338
+ function isNonEmptyString(value) {
1339
+ return typeof value === 'string' && value.trim().length > 0;
1340
+ }
1341
+ function buildMissingTitleResult() {
1342
+ return {
1343
+ content: [
1344
+ {
1345
+ type: 'text',
1346
+ text: 'create_spec requires a non-empty title. Provide the title field and call create_spec again.',
1347
+ },
1348
+ ],
1349
+ isError: true,
1350
+ structuredContent: { error: 'MISSING_TITLE', code: 'INVALID_INPUT', field: 'title' },
1351
+ };
1352
+ }
1338
1353
  // eslint-disable-next-line max-lines-per-function
1339
1354
  export async function handleCreateSpec(inputParams, server) {
1355
+ if (!isNonEmptyString(inputParams.title)) {
1356
+ return buildMissingTitleResult();
1357
+ }
1340
1358
  const enumValidation = validateCreateSpecEnums(inputParams);
1341
1359
  if (enumValidation) {
1342
1360
  return enumValidation;
@@ -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