@planu/cli 5.3.66 → 5.3.67

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,15 @@
1
+ ## [5.3.67] - 2026-08-28
2
+
3
+ ### Bug Fixes
4
+ - fix(SPEC-1214): make readiness executable-evidence fallback reachable outside planu/specs
5
+ - fix(SPEC-1214): align readiness and validation on one executable-evidence contract
6
+ - fix(SPEC-1306): forward caller cwd as explicit projectPath in CLI status
7
+
8
+ ### Chores
9
+ - chore(SPEC-1315): transition to implementing
10
+ - chore: mark SPEC-1306/SPEC-1214 done and file SPEC-1664 dogfood bug
11
+
12
+
1
13
  ## [5.3.66] - 2026-08-28
2
14
 
3
15
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"e6b315bb677135204b8ade0a8d43149075ba3ba4"}
1
+ {"schemaVersion":1,"commit":"fde3438aa03084c32064ee5f7ec4179e455e6062"}
@@ -58,7 +58,7 @@ export function buildSingleStatusInput(values, specId, projectId, status) {
58
58
  return {
59
59
  specId,
60
60
  projectId,
61
- projectPath: values['project-path'] ? resolve(values['project-path']) : undefined,
61
+ projectPath: values['project-path'] ? resolve(values['project-path']) : process.cwd(),
62
62
  status: status,
63
63
  reviewNotes: values.notes,
64
64
  modelId: values['model-id'],
@@ -1,10 +1,13 @@
1
1
  // engine/readiness-checker.ts — Spec readiness evaluation for implementation gate (SPEC-039)
2
+ import { existsSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
3
5
  import { extractSectionBody, readSpecTechnicalSection, } from './spec-format/read-technical-section.js';
4
6
  import { stripFrontmatter } from './frontmatter-parser.js';
5
7
  import { loadReadinessConfig } from './readiness-config-loader.js';
6
8
  import { runEarsGate } from './ears-gate.js';
7
9
  import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
10
+ import { normalizeExecutableEvidence } from './validator/executable-evidence.js';
8
11
  import { evaluateImplementationContract } from './implementation-contract/index.js';
9
12
  import { extractNormalizedAcceptanceCriteria } from './spec-format/acceptance-criteria.js';
10
13
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
@@ -33,6 +36,13 @@ function scoreHuCompleteness(spec) {
33
36
  }
34
37
  return { points, blockers, warnings };
35
38
  }
39
+ function projectPathFromSpecPath(specPath) {
40
+ const marker = /[/\\]planu[/\\]specs[/\\]/;
41
+ if (!specPath || !marker.test(specPath)) {
42
+ return undefined;
43
+ }
44
+ return specPath.split(marker)[0];
45
+ }
36
46
  async function readHuRaw(spec) {
37
47
  if (!spec.specPath) {
38
48
  return '';
@@ -421,6 +431,16 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
421
431
  allBlockers.push(`scenarios_missing_tests: scenario "${title}" has no tests array — add tests entries (SPEC-732)`);
422
432
  }
423
433
  }
434
+ // SPEC-1214: BDD-criteria executable-evidence gate — when a spec has no frontmatter
435
+ // scenarios, its criteria must carry a TEST: marker mapping to an executable test file,
436
+ // or validation will later score 0 with "No executable scenarios declared".
437
+ if (bddScenarioCount === 0 && spec.scope !== 'trivial') {
438
+ const readinessProjectPath = projectPathFromSpecPath(spec.specPath);
439
+ const executableEvidence = normalizeExecutableEvidence(huRaw, parseFrontmatterScenarios(huRaw), (path) => (readinessProjectPath ? existsSync(join(readinessProjectPath, path)) : true));
440
+ for (const title of executableEvidence.unmappedCriteria) {
441
+ allBlockers.push(`criteria_missing_executable_test: criterion "${title}" has no TEST: marker mapping to an executable test file (SPEC-1214)`);
442
+ }
443
+ }
424
444
  // SPEC-784: Technical section quality gates — blocker-severity issues
425
445
  if (detectTechnicalPlaceholder(huContent)) {
426
446
  allBlockers.push('TECHNICAL_PLACEHOLDER_DETECTED: ## Technical section contains a placeholder pointer ' +
@@ -0,0 +1,31 @@
1
+ export interface EvidenceTestLink {
2
+ path: string;
3
+ }
4
+ export interface EvidenceScenario {
5
+ title: string;
6
+ tests: EvidenceTestLink[];
7
+ }
8
+ export type ExecutableEvidenceSource = 'frontmatter' | 'criteria' | 'none';
9
+ export interface ExecutableEvidence {
10
+ source: ExecutableEvidenceSource;
11
+ scenarios: EvidenceScenario[];
12
+ unmappedCriteria: string[];
13
+ ignoredCriteriaTitles: string[];
14
+ }
15
+ /**
16
+ * Single evidence normalizer shared by spec-compliance-runner.ts and
17
+ * readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
18
+ * present; otherwise BDD criteria `TEST:` markers become the executable evidence,
19
+ * so a spec approved by readiness on `TEST:` markers cannot later validate as
20
+ * "no executable scenarios declared". `isValidTestPath` lets each caller reject a
21
+ * `TEST:` marker pointing at a file that does not exist on disk, instead of
22
+ * silently treating it as executable evidence; it defaults to accepting every
23
+ * path when a caller has no filesystem to check against.
24
+ */
25
+ export declare function normalizeExecutableEvidence(raw: string, frontmatterScenarios: {
26
+ title: string;
27
+ tests?: {
28
+ path: string;
29
+ }[];
30
+ }[], isValidTestPath?: (path: string) => boolean): ExecutableEvidence;
31
+ //# sourceMappingURL=executable-evidence.d.ts.map
@@ -0,0 +1,104 @@
1
+ import { extractSection } from '../spec-format/markdown-sections.js';
2
+ import { stripFrontmatter } from '../frontmatter-parser.js';
3
+ const CRITERION_HEADING = /^#{2,6}\s+((?:AC|AB)\s*-?\s*\d+)\b/i;
4
+ const GIVEN_BULLET = /^-\s*GIVEN\b/i;
5
+ const BDD_STEP = /^(?:[-*]\s+)?(GIVEN|WHEN|THEN|AND)\b/i;
6
+ const TEST_MARKER_LINE = /^\s*TEST:\s*(.+)$/im;
7
+ const TEST_FILE_TOKEN = /`?([\w./-]+\.(?:test|spec)\.[cm]?[jt]sx?)`?/g;
8
+ function splitByLineStarts(lines, starts) {
9
+ return starts.map((start, position) => {
10
+ const end = starts[position + 1] ?? lines.length;
11
+ return lines.slice(start, end).join('\n');
12
+ });
13
+ }
14
+ function splitCriterionBlocks(section) {
15
+ const lines = section.split('\n');
16
+ const headingStarts = lines
17
+ .map((line, index) => (CRITERION_HEADING.test(line.trim()) ? index : -1))
18
+ .filter((index) => index !== -1);
19
+ if (headingStarts.length > 0) {
20
+ return splitByLineStarts(lines, headingStarts);
21
+ }
22
+ const bulletStarts = lines
23
+ .map((line, index) => (GIVEN_BULLET.test(line.trim()) ? index : -1))
24
+ .filter((index) => index !== -1);
25
+ if (bulletStarts.length > 0) {
26
+ return splitByLineStarts(lines, bulletStarts);
27
+ }
28
+ return section.split(/\n\s*\n/).filter((block) => /\bGIVEN\b/i.test(block));
29
+ }
30
+ function blockTitle(block) {
31
+ const stepLines = block
32
+ .split('\n')
33
+ .map((line) => line.trim())
34
+ .filter((line) => BDD_STEP.test(line))
35
+ .map((line) => line.replace(/^[-*]\s+/, ''));
36
+ if (stepLines.length > 0) {
37
+ return stepLines.join(' ');
38
+ }
39
+ const heading = /^#{2,6}\s+(.+)$/.exec(block.split('\n')[0]?.trim() ?? '');
40
+ return (heading?.[1] ?? block.trim()).slice(0, 200);
41
+ }
42
+ function blockTestLinks(block, isValidTestPath) {
43
+ const marker = TEST_MARKER_LINE.exec(block);
44
+ if (!marker?.[1]) {
45
+ return [];
46
+ }
47
+ return [...marker[1].matchAll(TEST_FILE_TOKEN)]
48
+ .map((match) => match[1])
49
+ .filter((path) => Boolean(path))
50
+ .filter(isValidTestPath)
51
+ .map((path) => ({ path }));
52
+ }
53
+ function extractCriteriaScenarios(raw, isValidTestPath) {
54
+ const body = stripFrontmatter(raw);
55
+ const section = extractSection(body, 'acceptance criteria', 'criterios de aceptaci') ?? body;
56
+ return splitCriterionBlocks(section).map((block) => ({
57
+ title: blockTitle(block),
58
+ tests: blockTestLinks(block, isValidTestPath),
59
+ }));
60
+ }
61
+ /**
62
+ * Single evidence normalizer shared by spec-compliance-runner.ts and
63
+ * readiness-checker.ts (SPEC-1214). Frontmatter `scenarios` stay canonical when
64
+ * present; otherwise BDD criteria `TEST:` markers become the executable evidence,
65
+ * so a spec approved by readiness on `TEST:` markers cannot later validate as
66
+ * "no executable scenarios declared". `isValidTestPath` lets each caller reject a
67
+ * `TEST:` marker pointing at a file that does not exist on disk, instead of
68
+ * silently treating it as executable evidence; it defaults to accepting every
69
+ * path when a caller has no filesystem to check against.
70
+ */
71
+ export function normalizeExecutableEvidence(raw, frontmatterScenarios, isValidTestPath = () => true) {
72
+ const criteriaScenarios = extractCriteriaScenarios(raw, isValidTestPath);
73
+ if (frontmatterScenarios.length > 0) {
74
+ return {
75
+ source: 'frontmatter',
76
+ scenarios: frontmatterScenarios.map((scenario) => ({
77
+ title: scenario.title,
78
+ tests: (scenario.tests ?? []).map((test) => ({ path: test.path })),
79
+ })),
80
+ unmappedCriteria: [],
81
+ ignoredCriteriaTitles: criteriaScenarios
82
+ .filter((scenario) => scenario.tests.length > 0)
83
+ .map((scenario) => scenario.title),
84
+ };
85
+ }
86
+ const mapped = criteriaScenarios.filter((scenario) => scenario.tests.length > 0);
87
+ if (mapped.length > 0) {
88
+ return {
89
+ source: 'criteria',
90
+ scenarios: criteriaScenarios,
91
+ unmappedCriteria: criteriaScenarios
92
+ .filter((scenario) => scenario.tests.length === 0)
93
+ .map((scenario) => scenario.title),
94
+ ignoredCriteriaTitles: [],
95
+ };
96
+ }
97
+ return {
98
+ source: 'none',
99
+ scenarios: [],
100
+ unmappedCriteria: criteriaScenarios.map((scenario) => scenario.title),
101
+ ignoredCriteriaTitles: [],
102
+ };
103
+ }
104
+ //# sourceMappingURL=executable-evidence.js.map
@@ -27,6 +27,8 @@ export interface SpecComplianceResult {
27
27
  dimensionScore: number;
28
28
  perScenario: PerScenarioResult[];
29
29
  command: string;
30
+ evidenceSource?: 'frontmatter' | 'criteria' | 'none';
31
+ ignoredCriteriaTitles?: string[];
30
32
  }
31
33
  /** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
32
34
  export declare class ComplianceCommandTerminalError extends Error {
@@ -8,6 +8,7 @@ import { technologyValue } from '../technology-registry.js';
8
8
  import { readMatchingValidationEvidence } from '../validation-evidence-ledger.js';
9
9
  import { readEvidenceArtifacts, traceabilityRowHasCurrentCommandEvidence, } from '../evidence-gates/artifact-reader.js';
10
10
  import { normalizeCriterionText } from '../criterion-identity.js';
11
+ import { normalizeExecutableEvidence } from './executable-evidence.js';
11
12
  /** A sanitized terminal adapter failure that cannot be mistaken for a failed test verdict. */
12
13
  export class ComplianceCommandTerminalError extends Error {
13
14
  code;
@@ -450,16 +451,31 @@ async function loadExactCommandRows(spec, projectPath, canonicalProjectId) {
450
451
  // eslint-disable-next-line max-lines-per-function -- coordinates validation, adapters, and isolated per-scenario evidence
451
452
  export async function runSpecCompliance(spec, projectPath, signal, canonicalProjectId) {
452
453
  const { readFile } = await import('node:fs/promises');
454
+ const { existsSync } = await import('node:fs');
453
455
  let raw;
454
456
  try {
455
457
  raw = await readFile(spec.specPath, 'utf-8');
456
458
  }
457
459
  catch {
458
- return { dimensionScore: 0, perScenario: [], command: '' };
460
+ return { dimensionScore: 0, perScenario: [], command: '', evidenceSource: 'none' };
459
461
  }
460
- const scenarios = parseFrontmatterScenarios(raw);
462
+ const frontmatterScenarios = parseFrontmatterScenarios(raw);
463
+ const executableEvidence = normalizeExecutableEvidence(raw, frontmatterScenarios, (path) => existsSync(join(projectPath, path)));
464
+ const scenarios = executableEvidence.source === 'frontmatter'
465
+ ? frontmatterScenarios
466
+ : executableEvidence.scenarios.map((scenario) => ({
467
+ title: scenario.title,
468
+ done: false,
469
+ tests: scenario.tests.map((test) => ({ path: test.path })).filter(isExecutableTestLink),
470
+ }));
461
471
  if (scenarios.length === 0) {
462
- return { dimensionScore: 0, perScenario: [], command: '' };
472
+ return {
473
+ dimensionScore: 0,
474
+ perScenario: [],
475
+ command: '',
476
+ evidenceSource: executableEvidence.source,
477
+ ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
478
+ };
463
479
  }
464
480
  const allLinks = scenarios.flatMap((scenario) => scenario.tests ?? []);
465
481
  const executableLinks = allLinks.filter(isExecutableTestLink);
@@ -570,6 +586,8 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
570
586
  perScenario,
571
587
  command: commands.join(' && ') ||
572
588
  `${technologyValue('technology-vitest-a9127f')} --reporter=json --run (no test files linked)`,
589
+ evidenceSource: executableEvidence.source,
590
+ ignoredCriteriaTitles: executableEvidence.ignoredCriteriaTitles,
573
591
  };
574
592
  }
575
593
  export function findScenariosWithoutTests(raw) {
@@ -130,12 +130,18 @@ function buildMinimalityGate(report) {
130
130
  : undefined,
131
131
  };
132
132
  }
133
+ function ignoredCriteriaNote(result) {
134
+ return result.ignoredCriteriaTitles?.length
135
+ ? ` Ignored BDD criteria TEST: mappings (frontmatter scenarios are canonical): ${result.ignoredCriteriaTitles.join(', ')}.`
136
+ : '';
137
+ }
133
138
  function buildSpecComplianceGate(result) {
139
+ const ignoredNote = ignoredCriteriaNote(result);
134
140
  if (result.perScenario.length === 0) {
135
141
  return {
136
142
  name: 'spec-compliance',
137
- passed: true,
138
- reason: 'No executable scenarios declared; spec-compliance runner skipped.',
143
+ passed: false,
144
+ reason: `No executable scenarios declared (evidence source: ${result.evidenceSource ?? 'none'}) — add BDD criteria TEST: markers or frontmatter scenarios before done.${ignoredNote}`,
139
145
  };
140
146
  }
141
147
  const passed = result.dimensionScore === 100;
@@ -143,8 +149,8 @@ function buildSpecComplianceGate(result) {
143
149
  name: 'spec-compliance',
144
150
  passed,
145
151
  reason: passed
146
- ? undefined
147
- : `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.`,
152
+ ? ignoredNote.trim() || undefined
153
+ : `Expected all executable scenarios to pass, got ${String(result.dimensionScore)}.${ignoredNote}`,
148
154
  };
149
155
  }
150
156
  function toValidationReportCompliance(result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.66",
3
+ "version": "5.3.67",
4
4
  "description": "Planu — MCP Server for Spec Driven Development. Cross-platform (Linux/macOS/Windows, x64/arm64).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "5.3.66",
5
+ "version": "5.3.67",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",