@planu/cli 4.2.6 → 4.3.1

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## [4.3.0] - 2026-05-22
2
+
3
+ **Tarball SHA-256:** `ebc3e7fe0a284d6ba6062dfb9aab41fe6e9396a7763c6d39764d676d20a0b6c3`
4
+
5
+ ### Features
6
+ - feat(planu): enforce BDD SDD evidence gates
7
+
8
+
1
9
  ## [4.2.6] - 2026-05-22
2
10
 
3
11
  **Tarball SHA-256:** `07356a69166b2f47742118dcf06af14a105a44bb27024d6e28213c433530089e`
@@ -0,0 +1,9 @@
1
+ import type { Spec } from '../../types/spec/core.js';
2
+ import type { EvidenceArtifacts } from '../../types/evidence-gates.js';
3
+ export declare function readEvidenceArtifacts(args: {
4
+ spec: Spec;
5
+ projectId: string;
6
+ specId: string;
7
+ projectPath?: string;
8
+ }): Promise<EvidenceArtifacts>;
9
+ //# sourceMappingURL=artifact-reader.d.ts.map
@@ -0,0 +1,158 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, isAbsolute, join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { projectDataDir } from '../../storage/base-store.js';
5
+ const DiscoverySchema = z.object({
6
+ version: z.literal(1),
7
+ rules: z.array(z.string().min(1)).max(200),
8
+ examples: z.array(z.object({ rule: z.string().min(1), example: z.string().min(1) })).max(500),
9
+ openQuestions: z
10
+ .array(z.object({
11
+ question: z.string().min(1),
12
+ status: z.enum(['open', 'resolved', 'out_of_scope']),
13
+ resolution: z.string().optional(),
14
+ }))
15
+ .max(200),
16
+ outOfScope: z.array(z.string()).max(200),
17
+ glossary: z.array(z.object({ term: z.string().min(1), meaning: z.string().min(1) })).max(200),
18
+ });
19
+ const TaskPlanSchema = z.object({
20
+ version: z.literal(1),
21
+ tasks: z
22
+ .array(z.object({
23
+ id: z.string().min(1).max(120),
24
+ title: z.string().min(1).max(500),
25
+ acceptanceCriteria: z.array(z.string().min(1)).max(200),
26
+ status: z.enum(['pending', 'doing', 'done']),
27
+ }))
28
+ .max(500),
29
+ });
30
+ const TraceabilityMatrixSchema = z.object({
31
+ version: z.literal(1),
32
+ rows: z
33
+ .array(z.object({
34
+ acceptanceCriterion: z.string().min(1),
35
+ scenario: z.string().optional(),
36
+ testEvidence: z.array(z.string().min(1)).optional(),
37
+ contractEvidence: z.array(z.string().min(1)).optional(),
38
+ manualEvidence: z.string().optional(),
39
+ changedFiles: z.array(z.string().min(1)).max(200),
40
+ validationEvidence: z.string().optional(),
41
+ reviewerEvidence: z.string().optional(),
42
+ }))
43
+ .max(1000),
44
+ });
45
+ const ContractValidationSchema = z.object({
46
+ version: z.literal(1),
47
+ kind: z.enum(['api', 'graphql', 'event', 'ui', 'mcp']),
48
+ passed: z.boolean(),
49
+ reportPath: z.string().optional(),
50
+ summary: z.string().optional(),
51
+ });
52
+ function resolveSpecPath(spec, projectPath) {
53
+ if (isAbsolute(spec.specPath) || !projectPath) {
54
+ return spec.specPath;
55
+ }
56
+ return join(projectPath, spec.specPath);
57
+ }
58
+ function specEvidencePath(spec, filename, projectPath) {
59
+ return join(dirname(resolveSpecPath(spec, projectPath)), 'evidence', filename);
60
+ }
61
+ function handoffEvidencePath(projectId, specId, filename) {
62
+ return join(projectDataDir(projectId), 'handoffs', specId, filename);
63
+ }
64
+ async function readUnknown(paths) {
65
+ for (const path of paths) {
66
+ try {
67
+ const raw = await readFile(path, 'utf-8');
68
+ return { path, value: JSON.parse(raw) };
69
+ }
70
+ catch (err) {
71
+ const code = err instanceof Error && 'code' in err ? err.code : undefined;
72
+ if (code === 'ENOENT') {
73
+ continue;
74
+ }
75
+ throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)}`, {
76
+ cause: err,
77
+ });
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ async function readOptional(args) {
83
+ try {
84
+ const found = await readUnknown(args.paths);
85
+ if (!found) {
86
+ return undefined;
87
+ }
88
+ const parsed = args.schema.safeParse(found.value);
89
+ if (!parsed.success) {
90
+ args.invalidArtifacts.push(`${args.label} is invalid at ${found.path}: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`);
91
+ return undefined;
92
+ }
93
+ return parsed.data;
94
+ }
95
+ catch (err) {
96
+ args.invalidArtifacts.push(`${args.label} is unreadable: ${err instanceof Error ? err.message : String(err)}`);
97
+ return undefined;
98
+ }
99
+ }
100
+ export async function readEvidenceArtifacts(args) {
101
+ const invalidArtifacts = [];
102
+ const discovery = await readOptional({
103
+ label: 'Discovery evidence',
104
+ invalidArtifacts,
105
+ schema: DiscoverySchema,
106
+ paths: [
107
+ specEvidencePath(args.spec, 'discovery.json', args.projectPath),
108
+ handoffEvidencePath(args.projectId, args.specId, 'discovery.json'),
109
+ ],
110
+ });
111
+ const taskPlan = await readOptional({
112
+ label: 'Task plan evidence',
113
+ invalidArtifacts,
114
+ schema: TaskPlanSchema,
115
+ paths: [
116
+ specEvidencePath(args.spec, 'task-plan.json', args.projectPath),
117
+ handoffEvidencePath(args.projectId, args.specId, 'task-plan.json'),
118
+ ],
119
+ });
120
+ const traceabilityMatrix = await readOptional({
121
+ label: 'Traceability matrix evidence',
122
+ invalidArtifacts,
123
+ schema: TraceabilityMatrixSchema,
124
+ paths: [
125
+ specEvidencePath(args.spec, 'traceability-matrix.json', args.projectPath),
126
+ handoffEvidencePath(args.projectId, args.specId, 'traceability-matrix.json'),
127
+ ],
128
+ });
129
+ const contractValidations = [];
130
+ for (const filename of [
131
+ 'contract-validation-api.json',
132
+ 'contract-validation-graphql.json',
133
+ 'contract-validation-event.json',
134
+ 'contract-validation-ui.json',
135
+ 'contract-validation-mcp.json',
136
+ ]) {
137
+ const evidence = await readOptional({
138
+ label: filename,
139
+ invalidArtifacts,
140
+ schema: ContractValidationSchema,
141
+ paths: [
142
+ specEvidencePath(args.spec, filename, args.projectPath),
143
+ handoffEvidencePath(args.projectId, args.specId, filename),
144
+ ],
145
+ });
146
+ if (evidence) {
147
+ contractValidations.push(evidence);
148
+ }
149
+ }
150
+ return {
151
+ discovery,
152
+ taskPlan,
153
+ traceabilityMatrix,
154
+ contractValidations,
155
+ invalidArtifacts,
156
+ };
157
+ }
158
+ //# sourceMappingURL=artifact-reader.js.map
@@ -0,0 +1,13 @@
1
+ import type { Spec } from '../../types/spec/core.js';
2
+ import type { ContractValidationEvidence, EvidenceArtifacts, EvidenceGateIssue, EvidenceGateResult, EvidenceGateTransition } from '../../types/evidence-gates.js';
3
+ export declare function inferRequiredContractEvidence(spec: Spec, criteria: string[]): ContractValidationEvidence['kind'][];
4
+ export declare function checkDiscoveryGate(spec: Spec, artifacts: EvidenceArtifacts): EvidenceGateIssue[];
5
+ export declare function checkTaskPlanGate(spec: Spec, criteria: string[], artifacts: EvidenceArtifacts): EvidenceGateIssue[];
6
+ export declare function checkDoneEvidenceGate(spec: Spec, criteria: string[], artifacts: EvidenceArtifacts): EvidenceGateIssue[];
7
+ export declare function checkLifecycleEvidenceGate(args: {
8
+ transition: EvidenceGateTransition;
9
+ spec: Spec;
10
+ criteria: string[];
11
+ artifacts: EvidenceArtifacts;
12
+ }): EvidenceGateResult;
13
+ //# sourceMappingURL=lifecycle-gate.d.ts.map
@@ -0,0 +1,153 @@
1
+ const CONTRACT_HINTS = {
2
+ api: /\b(api|rest|endpoint|openapi|http route|controller)\b/i,
3
+ graphql: /\b(graphql|gql|schema\.graphql|resolver)\b/i,
4
+ event: /\b(event|webhook|queue|topic|message broker|kafka|rabbitmq|pubsub)\b/i,
5
+ ui: /\b(ui contract|component contract|screen contract|design contract)\b/i,
6
+ mcp: /\b(mcp|tool contract|model context protocol|registerTool|server tool)\b/i,
7
+ };
8
+ function isNonTrivial(spec) {
9
+ return spec.scope !== 'trivial';
10
+ }
11
+ function normalizeCriterion(value) {
12
+ return value
13
+ .replace(/^-\s*\[[ x]\]\s*/i, '')
14
+ .replace(/\s+/g, ' ')
15
+ .trim()
16
+ .toLowerCase();
17
+ }
18
+ function hasText(value) {
19
+ return value !== undefined && value.trim().length > 0;
20
+ }
21
+ export function inferRequiredContractEvidence(spec, criteria) {
22
+ const text = `${spec.title} ${spec.tags.join(' ')} ${criteria.join(' ')}`;
23
+ const required = [];
24
+ for (const [kind, pattern] of Object.entries(CONTRACT_HINTS)) {
25
+ if (pattern.test(text)) {
26
+ required.push(kind);
27
+ }
28
+ }
29
+ return required;
30
+ }
31
+ export function checkDiscoveryGate(spec, artifacts) {
32
+ if (!isNonTrivial(spec)) {
33
+ return [];
34
+ }
35
+ const issues = [];
36
+ if (!artifacts.discovery) {
37
+ issues.push({
38
+ code: 'discovery_missing',
39
+ message: 'Discovery evidence is required before approval. Add discovery.json with rules, examples, open questions, out-of-scope boundaries, and glossary.',
40
+ });
41
+ return issues;
42
+ }
43
+ const unresolved = artifacts.discovery.openQuestions.filter((q) => q.status === 'open');
44
+ if (unresolved.length > 0) {
45
+ issues.push({
46
+ code: 'discovery_unresolved_questions',
47
+ message: `Discovery has unresolved open questions: ${unresolved.map((q) => q.question).join('; ')}`,
48
+ });
49
+ }
50
+ return issues;
51
+ }
52
+ export function checkTaskPlanGate(spec, criteria, artifacts) {
53
+ if (!isNonTrivial(spec)) {
54
+ return [];
55
+ }
56
+ if (!artifacts.taskPlan) {
57
+ return [
58
+ {
59
+ code: 'task_plan_missing',
60
+ message: 'Task plan evidence is required before implementation. Add task-plan.json with tasks mapped to acceptance criteria.',
61
+ },
62
+ ];
63
+ }
64
+ const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.map(normalizeCriterion)));
65
+ const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
66
+ if (uncovered.length === 0) {
67
+ return [];
68
+ }
69
+ return [
70
+ {
71
+ code: 'task_plan_uncovered_criteria',
72
+ message: `Task plan does not cover acceptance criteria: ${uncovered.join('; ')}`,
73
+ },
74
+ ];
75
+ }
76
+ function rowHasEvidence(row) {
77
+ return (hasText(row.scenario) ||
78
+ (row.testEvidence !== undefined && row.testEvidence.length > 0) ||
79
+ (row.contractEvidence !== undefined && row.contractEvidence.length > 0) ||
80
+ hasText(row.manualEvidence));
81
+ }
82
+ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
83
+ const issues = [];
84
+ const matrix = artifacts.traceabilityMatrix;
85
+ if (!matrix) {
86
+ issues.push({
87
+ code: 'traceability_missing',
88
+ message: 'Traceability matrix evidence is required before done. Add traceability-matrix.json covering each acceptance criterion.',
89
+ });
90
+ }
91
+ else {
92
+ const covered = new Set(matrix.rows.map((row) => normalizeCriterion(row.acceptanceCriterion)));
93
+ const uncovered = criteria.filter((criterion) => !covered.has(normalizeCriterion(criterion)));
94
+ if (uncovered.length > 0) {
95
+ issues.push({
96
+ code: 'traceability_uncovered_criteria',
97
+ message: `Traceability matrix does not cover acceptance criteria: ${uncovered.join('; ')}`,
98
+ });
99
+ }
100
+ const incompleteRows = matrix.rows.filter((row) => !rowHasEvidence(row) ||
101
+ row.changedFiles.length === 0 ||
102
+ !hasText(row.validationEvidence) ||
103
+ !hasText(row.reviewerEvidence));
104
+ if (incompleteRows.length > 0) {
105
+ issues.push({
106
+ code: 'traceability_incomplete_rows',
107
+ message: 'Traceability rows must include scenario/test/contract/manual evidence, changed files, validation evidence, and reviewer evidence.',
108
+ });
109
+ }
110
+ }
111
+ const requiredKinds = inferRequiredContractEvidence(spec, criteria);
112
+ const passedKinds = new Set(artifacts.contractValidations
113
+ .filter((artifact) => artifact.passed)
114
+ .map((artifact) => artifact.kind));
115
+ const failedKinds = artifacts.contractValidations
116
+ .filter((artifact) => !artifact.passed)
117
+ .map((artifact) => artifact.kind);
118
+ const missingKinds = requiredKinds.filter((kind) => !passedKinds.has(kind));
119
+ if (missingKinds.length > 0) {
120
+ issues.push({
121
+ code: 'contract_validation_missing',
122
+ message: `Contract validation evidence is required for: ${missingKinds.join(', ')}`,
123
+ });
124
+ }
125
+ if (failedKinds.length > 0) {
126
+ issues.push({
127
+ code: 'contract_validation_failed',
128
+ message: `Contract validation evidence failed for: ${failedKinds.join(', ')}`,
129
+ });
130
+ }
131
+ return issues;
132
+ }
133
+ export function checkLifecycleEvidenceGate(args) {
134
+ const issues = args.artifacts.invalidArtifacts.map((artifact) => ({
135
+ code: 'evidence_artifact_invalid',
136
+ message: artifact,
137
+ }));
138
+ if (args.transition === 'approved') {
139
+ issues.push(...checkDiscoveryGate(args.spec, args.artifacts));
140
+ }
141
+ else if (args.transition === 'implementing') {
142
+ issues.push(...checkTaskPlanGate(args.spec, args.criteria, args.artifacts));
143
+ }
144
+ else {
145
+ issues.push(...checkDoneEvidenceGate(args.spec, args.criteria, args.artifacts));
146
+ }
147
+ return {
148
+ passed: issues.length === 0,
149
+ issues,
150
+ requiredContractKinds: inferRequiredContractEvidence(args.spec, args.criteria),
151
+ };
152
+ }
153
+ //# sourceMappingURL=lifecycle-gate.js.map
@@ -44,13 +44,19 @@ function buildSharedRules() {
44
44
  '| Trigger | Automatic action |',
45
45
  '|---------|-----------------|',
46
46
  '| New feature / bug / task described | `create_spec` |',
47
+ '| Spec needs approval | Create Discovery evidence before `update_status(approved)` |',
47
48
  '| Spec written | Run an independent `planu-spec-reviewer` review before approval |',
49
+ '| Spec approved for implementation | Create task-plan evidence mapped to acceptance criteria |',
48
50
  '| Spec status change | `update_status` |',
49
- '| Implementation complete | `validate` + independent `planu-implementation-reviewer` review |',
51
+ '| Implementation complete | Create traceability evidence, run `validate`, and use independent `planu-implementation-reviewer` review |',
50
52
  '| Project start | `init_project` |',
51
53
  '',
52
54
  '### Review gates — non-bypassable',
55
+ '- A non-trivial spec cannot move to `approved` without Discovery evidence: rules, examples, open questions, out-of-scope, and glossary.',
53
56
  '- A spec cannot move to `approved` without `review_feedback` from `planu-spec-reviewer`.',
57
+ '- A non-trivial spec cannot move to `implementing` without task-plan evidence mapped to acceptance criteria.',
58
+ '- A spec cannot move to `done` without a traceability matrix covering every acceptance criterion.',
59
+ '- API, UI, event, GraphQL, and MCP specs require matching contract validation evidence before `done`.',
54
60
  '- The planner/spec author must not self-approve the spec.',
55
61
  '- A spec cannot move to `done` without validation evidence and review evidence from `planu-implementation-reviewer`.',
56
62
  '- The implementation reviewer must be different from the implementation agent.',
@@ -31,7 +31,7 @@ export async function reconcileSkills(projectPath, knowledge) {
31
31
  const newSkillsDetected = recommendedSkills
32
32
  .filter((s) => !installedSet.has(s.name))
33
33
  .map((s) => s.name);
34
- const staleSkills = installedNames.filter((name) => !recommendedNames.has(name));
34
+ const staleSkills = installedNames.filter((name) => !recommendedNames.has(name) && !isProtectedBuiltInSkill(name, knowledge));
35
35
  const totalRelevant = recommendedNames.size;
36
36
  const alreadyInSync = installedNames.filter((name) => recommendedNames.has(name)).length;
37
37
  const healthScore = computeHealthScore(totalRelevant, alreadyInSync, staleSkills.length);
@@ -188,6 +188,16 @@ function computeHealthScore(totalRelevant, alreadyInSync, staleCount) {
188
188
  const penalty = staleCount * 5;
189
189
  return Math.max(0, base - penalty);
190
190
  }
191
+ function isProtectedBuiltInSkill(name, knowledge) {
192
+ if (name !== 'typescript-patterns') {
193
+ return false;
194
+ }
195
+ const language = knowledge.language.toLowerCase();
196
+ const stack = knowledge.stack.map((item) => item.toLowerCase());
197
+ return (language === 'typescript' ||
198
+ language === 'javascript' ||
199
+ stack.some((item) => item.includes('typescript') || item.includes('javascript')));
200
+ }
191
201
  /** Return a neutral reconciliation when prerequisites are missing. */
192
202
  function buildEmptyReconciliation(_reason) {
193
203
  return {
@@ -1,34 +1,16 @@
1
- const MAX_SUMMARY_LENGTH = 700;
2
1
  export class FallbackGenerator {
3
2
  generate(request) {
4
- const slug = slugify(request.title);
5
- const pascalName = toPascalCase(request.title);
6
- const sourceSummary = normalizeSummary(request.description, request.title);
3
+ const sourceDescription = normalizeSourceDescription(request.description, request.title);
7
4
  const contextLine = buildContextLine(request);
8
- const technicalSection = buildTechnicalSection(slug, pascalName, contextLine);
5
+ const technicalSection = buildTechnicalSection(contextLine);
9
6
  const specBody = [
10
7
  '## Problem',
11
- sourceSummary,
8
+ sourceDescription,
12
9
  '',
13
10
  '## Solution',
14
- `Implement ${request.title} with explicit behavior, project-scoped file ownership, and verification gates. ${contextLine}`,
15
- '',
16
- '## Acceptance Criteria',
17
- '',
18
- `Scenario: ${request.title} happy path`,
19
- 'GIVEN the relevant user or system context exists',
20
- `WHEN the requested ${request.title} behavior is executed`,
21
- 'THEN the expected outcome from the request is observable',
22
- '',
23
- `Scenario: ${request.title} failure handling`,
24
- 'GIVEN invalid input or an unavailable dependency',
25
- `WHEN the requested ${request.title} behavior runs`,
26
- 'THEN the system returns a clear failure state without corrupting data',
27
- '',
28
- `Scenario: ${request.title} verification`,
29
- 'GIVEN the implementation is complete',
30
- 'WHEN the configured verification commands run',
31
- 'THEN tests and lint pass without regressions',
11
+ 'Use the preserved source description above as the authoritative requirements body. ' +
12
+ 'Do not infer implementation ownership beyond details explicitly provided in the source. ' +
13
+ contextLine,
32
14
  '',
33
15
  technicalSection,
34
16
  ].join('\n');
@@ -38,25 +20,18 @@ export class FallbackGenerator {
38
20
  generatedWithModel: 'deterministic-fallback',
39
21
  generatedAt: new Date().toISOString(),
40
22
  qualityWarnings: [
41
- 'Fallback generator used. Review candidate file paths and replace placeholders with exact code owners before approval.',
23
+ 'Fallback generator used. Source description was preserved; no implementation files, type signatures, or ownership were inferred.',
42
24
  ],
43
25
  fallbackReason: 'No external model generator is configured for create_spec.',
44
26
  });
45
27
  }
46
28
  }
47
- function normalizeSummary(description, title) {
48
- const compact = description
49
- .replace(/\r\n/g, '\n')
50
- .split('\n')
51
- .map((line) => line.trim())
52
- .filter((line) => line.length > 0 && !line.startsWith('- [ ]'))
53
- .join(' ')
54
- .replace(/\s+/g, ' ')
55
- .trim();
56
- const summary = compact.length > 0
57
- ? compact.slice(0, MAX_SUMMARY_LENGTH)
58
- : `The project needs ${title} defined with enough implementation detail to proceed safely.`;
59
- return summary.endsWith('.') ? summary : `${summary}.`;
29
+ function normalizeSourceDescription(description, title) {
30
+ const compact = description.replace(/\r\n/g, '\n').trim();
31
+ if (compact.length > 0) {
32
+ return compact;
33
+ }
34
+ return `The project needs ${title} defined with enough implementation detail to proceed safely.`;
60
35
  }
61
36
  function buildContextLine(request) {
62
37
  const parts = [
@@ -69,34 +44,22 @@ function buildContextLine(request) {
69
44
  }
70
45
  return `Use the existing ${parts.join(' / ')} project conventions.`;
71
46
  }
72
- function buildTechnicalSection(slug, pascalName, contextLine) {
47
+ function buildTechnicalSection(contextLine) {
73
48
  return [
74
49
  '## Technical',
75
50
  '',
76
51
  '### Implementation Notes',
77
52
  `- ${contextLine}`,
78
- '- Replace candidate paths with exact files before moving the spec to approved.',
53
+ '- Preserve the source requirements as the contract until a reviewer or agent adds exact implementation ownership.',
79
54
  '',
80
55
  '## Files',
81
56
  '',
82
57
  '### Create',
83
- `- src/${slug}.ts (candidate path; replace with exact owner during planning)`,
58
+ '- _Not specified in source description._',
84
59
  '### Modify',
85
- '- src/index.ts (candidate integration point; replace with exact owner during planning)',
60
+ '- _Not specified in source description._',
86
61
  '### Test',
87
- `- tests/${slug}.test.ts (focused coverage for the behavior and failure path)`,
88
- '',
89
- '## Type Signatures',
90
- '```typescript',
91
- `interface ${pascalName}Input {`,
92
- ' readonly requestId?: string;',
93
- '}',
94
- '',
95
- `interface ${pascalName}Result {`,
96
- ' readonly ok: boolean;',
97
- ' readonly reason?: string;',
98
- '}',
99
- '```',
62
+ '- _Not specified in source description._',
100
63
  '',
101
64
  '## Verification',
102
65
  '- pnpm typecheck',
@@ -104,17 +67,4 @@ function buildTechnicalSection(slug, pascalName, contextLine) {
104
67
  '- pnpm test',
105
68
  ].join('\n');
106
69
  }
107
- function slugify(input) {
108
- const slug = input
109
- .toLowerCase()
110
- .replace(/[^a-z0-9]+/g, '-')
111
- .replace(/^-+|-+$/g, '');
112
- return slug.length > 0 ? slug : 'generated-spec';
113
- }
114
- function toPascalCase(input) {
115
- const words = input.match(/[a-zA-Z0-9]+/g) ?? ['Generated', 'Spec'];
116
- return words
117
- .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
118
- .join('');
119
- }
120
70
  //# sourceMappingURL=fallback-generator.js.map
@@ -15,7 +15,20 @@ Auto-generated by \`init_project\`. Do not edit manually.
15
15
  | implementing | work in progress |
16
16
  | done | implemented + validated |
17
17
 
18
- Flow: \`facilitate → create_spec → challenge_spec → check_readiness → approve → implement → validate → done\`
18
+ Flow: \`facilitate → create_spec → discovery evidence → challenge_spec → check_readiness → approve → task plan → implement → traceability evidence → validate → done\`
19
+
20
+ ## Evidence Gates
21
+
22
+ For non-trivial specs, Planu blocks lifecycle transitions unless evidence is present:
23
+
24
+ | Transition | Required evidence |
25
+ |------------|-------------------|
26
+ | approved | Discovery evidence: rules, examples, open questions, out-of-scope, glossary |
27
+ | implementing | Task plan mapped to acceptance criteria |
28
+ | done | Traceability matrix covering every acceptance criterion |
29
+ | done for API/UI/event/MCP work | Passing contract validation evidence |
30
+
31
+ Evidence can live under \`planu/specs/<spec>/evidence/\` or the external Planu handoff store. Trivial specs may use lightweight evidence, but \`done\` still needs at least one traceability row.
19
32
 
20
33
  ## When to use \`facilitate\`
21
34
 
@@ -0,0 +1,12 @@
1
+ import type { ToolResult } from '../../types/index.js';
2
+ import type { Spec } from '../../types/spec/core.js';
3
+ import type { EvidenceGateTransition } from '../../types/evidence-gates.js';
4
+ /** SPEC-1054: BDD/SDD lifecycle evidence gate. */
5
+ export declare function checkLifecycleEvidenceTransitionGate(args: {
6
+ spec: Spec;
7
+ specId: string;
8
+ projectId: string;
9
+ projectPath?: string;
10
+ transition: EvidenceGateTransition;
11
+ }): Promise<ToolResult | null>;
12
+ //# sourceMappingURL=evidence-gate.d.ts.map
@@ -0,0 +1,85 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { extractCriteria, extractListItems, extractSection, } from '../../engine/validator/extractors.js';
4
+ import { stripFrontmatter } from '../../engine/frontmatter-parser.js';
5
+ import { readEvidenceArtifacts } from '../../engine/evidence-gates/artifact-reader.js';
6
+ import { checkLifecycleEvidenceGate } from '../../engine/evidence-gates/lifecycle-gate.js';
7
+ /** SPEC-1054: BDD/SDD lifecycle evidence gate. */
8
+ export async function checkLifecycleEvidenceTransitionGate(args) {
9
+ const [criteria, artifacts] = await Promise.all([
10
+ extractLifecycleGateCriteria(args.spec, args.projectPath),
11
+ readEvidenceArtifacts({
12
+ spec: args.spec,
13
+ projectId: args.projectId,
14
+ specId: args.specId,
15
+ projectPath: args.projectPath,
16
+ }),
17
+ ]);
18
+ const result = checkLifecycleEvidenceGate({
19
+ transition: args.transition,
20
+ spec: args.spec,
21
+ criteria,
22
+ artifacts,
23
+ });
24
+ if (result.passed) {
25
+ return null;
26
+ }
27
+ return evidenceGateError({
28
+ specId: args.specId,
29
+ transition: args.transition,
30
+ issues: result.issues.map((issue) => ({
31
+ code: issue.code,
32
+ message: issue.message,
33
+ })),
34
+ requiredContractKinds: result.requiredContractKinds,
35
+ });
36
+ }
37
+ async function extractLifecycleGateCriteria(spec, projectPath) {
38
+ const specPath = isAbsolute(spec.specPath) || !projectPath ? spec.specPath : join(projectPath, spec.specPath);
39
+ try {
40
+ const raw = await readFile(specPath, 'utf-8');
41
+ const body = stripFrontmatter(raw);
42
+ const acceptanceCriteria = extractSection(body, 'acceptance criteria', 'criterios de aceptaci');
43
+ if (acceptanceCriteria) {
44
+ const items = extractListItems(acceptanceCriteria);
45
+ if (items.length > 0) {
46
+ return [...new Set(items)];
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ // Fall back to the broader validator extractor when the canonical file is unavailable.
52
+ }
53
+ return extractCriteria(spec);
54
+ }
55
+ function evidenceGateError(args) {
56
+ return {
57
+ content: [
58
+ {
59
+ type: 'text',
60
+ text: JSON.stringify({
61
+ error: 'lifecycle_evidence_gate_failed',
62
+ message: `BDD/SDD evidence gate blocked ${args.transition}.`,
63
+ specId: args.specId,
64
+ transition: args.transition,
65
+ issues: args.issues,
66
+ requiredContractKinds: args.requiredContractKinds,
67
+ fixHint: 'Add the missing evidence artifact under planu/specs/<spec>/evidence/ or external Planu project data handoffs/<specId>/, then retry the transition.',
68
+ }, null, 2),
69
+ },
70
+ ],
71
+ isError: true,
72
+ structuredContent: {
73
+ error: 'lifecycle_evidence_gate_failed',
74
+ code: 422,
75
+ context: {
76
+ specId: args.specId,
77
+ transition: args.transition,
78
+ issues: args.issues,
79
+ requiredContractKinds: args.requiredContractKinds,
80
+ },
81
+ fixHint: 'Add discovery.json, task-plan.json, traceability-matrix.json, and contract-validation-*.json as required for this transition.',
82
+ },
83
+ };
84
+ }
85
+ //# sourceMappingURL=evidence-gate.js.map
@@ -14,6 +14,7 @@ import { checkApprovalGate } from '../../engine/approval-workflow.js';
14
14
  import * as approvalStore from '../../storage/approval-store.js';
15
15
  import { isLocked, getLock } from '../../storage/spec-lock-store.js';
16
16
  import { runValidateGate, checkDoneGates, checkComplianceGate, checkQaGate, checkApprovedFormatGate, checkValidationReportGate, checkSpecReviewGate, writeSpecReviewArtifact, } from './dod-gates.js';
17
+ import { checkLifecycleEvidenceTransitionGate } from './evidence-gate.js';
17
18
  import { buildStatusResponse, buildValidateBlockedResponse, buildDryRunResponse, } from './response-builder.js';
18
19
  import { getSuggestedMode } from './mode-hints.js';
19
20
  import { autoFillActuals, createDefaultActuals } from '../../engine/actuals-estimator.js';
@@ -227,6 +228,9 @@ async function resolveOrchestrationPlan(newStatus, specScope, specId, projectPat
227
228
  function shouldSkipSddRoutingGateForLegacyTestHarness() {
228
229
  return process.env.VITEST === 'true' && process.env.PLANU_TEST_STRICT_SDD_GATES !== 'true';
229
230
  }
231
+ function shouldSkipEvidenceGateForLegacyTestHarness() {
232
+ return process.env.VITEST === 'true' && process.env.PLANU_TEST_STRICT_EVIDENCE_GATES !== 'true';
233
+ }
230
234
  /* eslint-disable complexity, max-lines-per-function */
231
235
  export async function handleUpdateStatus(params, server) {
232
236
  return trackCost(params.projectPath ?? '', 'update_status', async () => {
@@ -434,6 +438,22 @@ export async function handleUpdateStatus(params, server) {
434
438
  if (sddRoutingGate.blockResult) {
435
439
  return sddRoutingGate.blockResult;
436
440
  }
441
+ // SPEC-1054: BDD/SDD evidence gates. Non-trivial specs must carry
442
+ // Discovery before approval, task-plan before implementation, and
443
+ // traceability/contract evidence before done.
444
+ if (!shouldSkipEvidenceGateForLegacyTestHarness() &&
445
+ (newStatus === 'approved' || newStatus === 'implementing' || newStatus === 'done')) {
446
+ const evidenceGate = await checkLifecycleEvidenceTransitionGate({
447
+ spec,
448
+ specId,
449
+ projectId,
450
+ projectPath: effectiveGatePath,
451
+ transition: newStatus,
452
+ });
453
+ if (evidenceGate !== null) {
454
+ return evidenceGate;
455
+ }
456
+ }
437
457
  // ---------------------------------------------------------------------------
438
458
  // BATCH A (parallel): code-reality + done-gates — independent of each other
439
459
  // SPEC-441: Code reality check before transitioning to 'implementing'
@@ -0,0 +1,65 @@
1
+ export type EvidenceGateTransition = 'approved' | 'implementing' | 'done';
2
+ export interface DiscoveryEvidence {
3
+ version: 1;
4
+ rules: string[];
5
+ examples: {
6
+ rule: string;
7
+ example: string;
8
+ }[];
9
+ openQuestions: {
10
+ question: string;
11
+ status: 'open' | 'resolved' | 'out_of_scope';
12
+ resolution?: string;
13
+ }[];
14
+ outOfScope: string[];
15
+ glossary: {
16
+ term: string;
17
+ meaning: string;
18
+ }[];
19
+ }
20
+ export interface TaskPlanEvidence {
21
+ version: 1;
22
+ tasks: {
23
+ id: string;
24
+ title: string;
25
+ acceptanceCriteria: string[];
26
+ status: 'pending' | 'doing' | 'done';
27
+ }[];
28
+ }
29
+ export interface TraceabilityMatrixEvidence {
30
+ version: 1;
31
+ rows: {
32
+ acceptanceCriterion: string;
33
+ scenario?: string;
34
+ testEvidence?: string[];
35
+ contractEvidence?: string[];
36
+ manualEvidence?: string;
37
+ changedFiles: string[];
38
+ validationEvidence?: string;
39
+ reviewerEvidence?: string;
40
+ }[];
41
+ }
42
+ export interface ContractValidationEvidence {
43
+ version: 1;
44
+ kind: 'api' | 'graphql' | 'event' | 'ui' | 'mcp';
45
+ passed: boolean;
46
+ reportPath?: string;
47
+ summary?: string;
48
+ }
49
+ export interface EvidenceArtifacts {
50
+ discovery?: DiscoveryEvidence;
51
+ taskPlan?: TaskPlanEvidence;
52
+ traceabilityMatrix?: TraceabilityMatrixEvidence;
53
+ contractValidations: ContractValidationEvidence[];
54
+ invalidArtifacts: string[];
55
+ }
56
+ export interface EvidenceGateIssue {
57
+ code: 'discovery_missing' | 'discovery_unresolved_questions' | 'task_plan_missing' | 'task_plan_uncovered_criteria' | 'traceability_missing' | 'traceability_uncovered_criteria' | 'traceability_incomplete_rows' | 'contract_validation_missing' | 'contract_validation_failed' | 'evidence_artifact_invalid';
58
+ message: string;
59
+ }
60
+ export interface EvidenceGateResult {
61
+ passed: boolean;
62
+ issues: EvidenceGateIssue[];
63
+ requiredContractKinds: ContractValidationEvidence['kind'][];
64
+ }
65
+ //# sourceMappingURL=evidence-gates.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=evidence-gates.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.2.6",
3
+ "version": "4.3.1",
4
4
  "description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -32,14 +32,14 @@
32
32
  "packageName": "@planu/core"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@planu/core-darwin-arm64": "4.2.6",
36
- "@planu/core-darwin-x64": "4.2.6",
37
- "@planu/core-linux-arm64-gnu": "4.2.6",
38
- "@planu/core-linux-arm64-musl": "4.2.6",
39
- "@planu/core-linux-x64-gnu": "4.2.6",
40
- "@planu/core-linux-x64-musl": "4.2.6",
41
- "@planu/core-win32-arm64-msvc": "4.2.6",
42
- "@planu/core-win32-x64-msvc": "4.2.6"
35
+ "@planu/core-darwin-arm64": "4.3.1",
36
+ "@planu/core-darwin-x64": "4.3.1",
37
+ "@planu/core-linux-arm64-gnu": "4.3.1",
38
+ "@planu/core-linux-arm64-musl": "4.3.1",
39
+ "@planu/core-linux-x64-gnu": "4.3.1",
40
+ "@planu/core-linux-x64-musl": "4.3.1",
41
+ "@planu/core-win32-arm64-msvc": "4.3.1",
42
+ "@planu/core-win32-x64-msvc": "4.3.1"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=24.0.0"