@planu/cli 5.3.33 → 5.3.35

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,17 @@
1
+ ## [5.3.35] - 2026-08-23
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1488): scope marker-declared path extraction to the path-list run after each marker
5
+
6
+
7
+ ## [5.3.34] - 2026-08-22
8
+
9
+ ### Bug Fixes
10
+ - fix(spec-1588): fail closed on unresolved placeholders in raw generator output
11
+ - fix(spec-1509): stop check_readiness false positives on error-outcome and clean criteria
12
+ - fix(spec-1551): derive Goal and User Outcome from first acceptance criterion
13
+
14
+
1
15
  ## [5.3.33] - 2026-08-21
2
16
 
3
17
  ### Bug Fixes
@@ -7,7 +7,11 @@
7
7
  { "id": "quick", "word": "quick" },
8
8
  { "id": "better", "word": "better" },
9
9
  { "id": "simple", "word": "simple" },
10
- { "id": "clean", "word": "clean" }
10
+ {
11
+ "id": "clean",
12
+ "word": "clean",
13
+ "exemptFollowingNouns": ["checkout", "tree", "clone", "worktree", "working tree"]
14
+ }
11
15
  ],
12
16
  "thresholds": {
13
17
  "strictBlocker": 50,
@@ -9,8 +9,7 @@ export function buildImplementationContractSection(input) {
9
9
  const lines = [
10
10
  `## ${IMPLEMENTATION_CONTRACT_SECTION}`,
11
11
  '### User Outcome',
12
- firstConcreteSentence(input.description) ??
13
- criteria[0]?.text ??
12
+ criteria[0]?.text ??
14
13
  'Needs decision: define the exact observable outcome this spec must deliver.',
15
14
  '',
16
15
  '### File-Level Work Plan',
@@ -39,18 +38,6 @@ export function appendImplementationContractIfMissing(specBody, input) {
39
38
  }
40
39
  return `${specBody.trimEnd()}\n\n${buildImplementationContractSection(input)}`;
41
40
  }
42
- function firstConcreteSentence(description) {
43
- const body = description
44
- .replace(/^---[\s\S]*?---/, '')
45
- .replace(/^#{1,6}\s+.+$/gm, '')
46
- .split('\n')
47
- .map((line) => line.trim())
48
- .find((line) => line.length >= 24 && !line.startsWith('-'));
49
- if (!body) {
50
- return null;
51
- }
52
- return body.replace(/\s+/g, ' ');
53
- }
54
41
  function renderFilePlan(files, hasGroundedBehavior) {
55
42
  const lines = [];
56
43
  for (const [label, entries] of [
@@ -120,6 +120,17 @@ function frontmatterScenarioCriteria(raw) {
120
120
  return [scenario.title, tests].filter((part) => part.trim().length > 0).join(' ');
121
121
  });
122
122
  }
123
+ function isExemptedByFollowingNoun(lower, matchEnd, exemptNouns) {
124
+ const remainder = lower.slice(matchEnd).replace(/^\s+/, '');
125
+ return exemptNouns.some((noun) => {
126
+ const lowerNoun = noun.toLowerCase();
127
+ if (!remainder.startsWith(lowerNoun)) {
128
+ return false;
129
+ }
130
+ const after = remainder.charAt(lowerNoun.length);
131
+ return after === '' || /[^a-z0-9]/i.test(after);
132
+ });
133
+ }
123
134
  function scoreCriteria(criteriaLines, vagueWords) {
124
135
  const blockers = [];
125
136
  const warnings = [];
@@ -141,10 +152,25 @@ function scoreCriteria(criteriaLines, vagueWords) {
141
152
  // Vague criteria detection
142
153
  for (const line of criteriaLines) {
143
154
  const lower = line.toLowerCase();
144
- const vagueFound = vagueWords.filter((word) => {
145
- const pattern = new RegExp(`\\b${word}\\b`);
146
- return pattern.test(lower);
147
- });
155
+ const vagueFound = [];
156
+ for (const item of vagueWords) {
157
+ const escapedWord = item.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
158
+ const pattern = new RegExp(`\\b${escapedWord}\\b`, 'g');
159
+ let match;
160
+ let flagged = false;
161
+ while ((match = pattern.exec(lower)) !== null) {
162
+ const matchEnd = match.index + match[0].length;
163
+ if (item.exemptFollowingNouns &&
164
+ isExemptedByFollowingNoun(lower, matchEnd, item.exemptFollowingNouns)) {
165
+ continue;
166
+ }
167
+ flagged = true;
168
+ break;
169
+ }
170
+ if (flagged) {
171
+ vagueFound.push(item.word);
172
+ }
173
+ }
148
174
  if (vagueFound.length > 0) {
149
175
  warnings.push(`Vague criterion detected (words: ${vagueFound.join(', ')}): "${line.slice(0, 80)}"`);
150
176
  }
@@ -336,7 +362,7 @@ function checkSpecificityGate(spec, criteriaLines, fichaContent, anticipatedTest
336
362
  // ── Public API ───────────────────────────────────────────────────────────────
337
363
  export async function checkSpecReadiness(spec, mode, projectHash) {
338
364
  const config = loadReadinessConfig(projectHash);
339
- const vagueWords = config.vagueWords.map((v) => v.word);
365
+ const vagueWords = config.vagueWords;
340
366
  const huRaw = await readHuRaw(spec);
341
367
  const huContent = stripFrontmatter(huRaw);
342
368
  const anticipatedTestBreaksContent = extractSectionBody(huRaw, 'Anticipated Test Breaks');
@@ -1,5 +1,20 @@
1
+ import { z } from 'zod';
1
2
  import type { ReadinessConfig } from '../types/index.js';
3
+ declare const readinessConfigSchema: z.ZodObject<{
4
+ vagueWords: z.ZodArray<z.ZodObject<{
5
+ id: z.ZodString;
6
+ word: z.ZodString;
7
+ exemptFollowingNouns: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
+ }, z.core.$strip>>;
9
+ thresholds: z.ZodObject<{
10
+ strictBlocker: z.ZodNumber;
11
+ strictCaution: z.ZodNumber;
12
+ lenientBlocker: z.ZodNumber;
13
+ lenientCaution: z.ZodNumber;
14
+ }, z.core.$strip>;
15
+ }, z.core.$strip>;
2
16
  export type { ReadinessConfig };
17
+ export { readinessConfigSchema };
3
18
  /**
4
19
  * Load the merged readiness configuration for a given project.
5
20
  * Falls back to system defaults when no overrides exist.
@@ -10,6 +10,10 @@ import { ObjectConfigLoader } from './config-loader.js';
10
10
  const vagueWordItemSchema = z.object({
11
11
  id: z.string().min(1).describe('Unique identifier for this vague word entry'),
12
12
  word: z.string().min(1).describe('The vague word to detect in acceptance criteria'),
13
+ exemptFollowingNouns: z
14
+ .array(z.string().min(1))
15
+ .optional()
16
+ .describe('Nouns that, when immediately following the word, suppress the vague-word match'),
13
17
  });
14
18
  const readinessConfigSchema = z
15
19
  .object({
@@ -46,6 +50,7 @@ const readinessConfigSchema = z
46
50
  .describe('Score thresholds for each readiness mode'),
47
51
  })
48
52
  .describe('Readiness checker configuration including vague words and thresholds');
53
+ export { readinessConfigSchema };
49
54
  // ── Loader instance ───────────────────────────────────────────────────────────
50
55
  const loader = new ObjectConfigLoader('readiness-config', readinessConfigSchema);
51
56
  /**
@@ -1,4 +1,9 @@
1
1
  import type { LeanFileEntry } from '../../types/index.js';
2
+ /**
3
+ * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
4
+ * Returns null when no such marker is present (caller falls back to whole-text grounding).
5
+ */
6
+ export declare function extractMarkerDeclaredPaths(description: string): Set<string> | null;
2
7
  /**
3
8
  * Attempt to extract file paths from spec body for auto-populating ## Technical.
4
9
  * Returns null when no structured file section is found (caller uses placeholder).
@@ -107,6 +107,54 @@ async function isAdmissibleProseCandidate(candidate, projectPath) {
107
107
  }
108
108
  return hasLetterInitialExtension(candidate) && (await isExistingDirectory(dirname(full)));
109
109
  }
110
+ const MARKER_KEYWORD_RE = /\b(FILES|FILE|TESTS|TEST)\s*:/gi;
111
+ const PATH_SEPARATOR_RE = /^(?:[ \t,]+|\band\b)+/i;
112
+ const PATH_TOKEN_RE = /^`?([^\s,`]+)`?/;
113
+ const TRAILING_PUNCTUATION_RE = /[.,;:]+$/;
114
+ function isPathShaped(token) {
115
+ return token.includes('/') && /\.[a-zA-Z0-9]+$/.test(token);
116
+ }
117
+ function consumePathRun(remainder) {
118
+ const paths = new Set();
119
+ let cursor = remainder;
120
+ for (;;) {
121
+ cursor = cursor.replace(PATH_SEPARATOR_RE, '');
122
+ const tokenMatch = PATH_TOKEN_RE.exec(cursor);
123
+ if (tokenMatch?.[1] === undefined) {
124
+ break;
125
+ }
126
+ const raw = tokenMatch[1];
127
+ const endsWithTerminator = /[.;:]+$/.test(raw);
128
+ const trimmed = raw.replace(TRAILING_PUNCTUATION_RE, '');
129
+ if (!isPathShaped(trimmed)) {
130
+ break;
131
+ }
132
+ paths.add(trimmed.toLowerCase());
133
+ if (endsWithTerminator) {
134
+ break;
135
+ }
136
+ cursor = cursor.slice(tokenMatch[0].length);
137
+ }
138
+ return paths;
139
+ }
140
+ /**
141
+ * Extract the path list declared immediately after each FILES:/FILE:/TEST:/TESTS: marker.
142
+ * Returns null when no such marker is present (caller falls back to whole-text grounding).
143
+ */
144
+ export function extractMarkerDeclaredPaths(description) {
145
+ const markers = [...description.matchAll(MARKER_KEYWORD_RE)];
146
+ if (markers.length === 0) {
147
+ return null;
148
+ }
149
+ const paths = new Set();
150
+ for (const marker of markers) {
151
+ const start = marker.index + marker[0].length;
152
+ for (const path of consumePathRun(description.slice(start))) {
153
+ paths.add(path);
154
+ }
155
+ }
156
+ return paths;
157
+ }
110
158
  /**
111
159
  * Attempt to extract file paths from spec body for auto-populating ## Technical.
112
160
  * Returns null when no structured file section is found (caller uses placeholder).
@@ -13,6 +13,10 @@ export declare function buildCanonicalUnifiedSpecContent(input: CanonicalUnified
13
13
  * inject sections the user did not author themselves.
14
14
  */
15
15
  export declare function buildUnifiedSpecContent(leanSpecBody: string, leanTechnicalBody: string): string;
16
+ /** Masking-aware scan for an unresolved contract placeholder in raw generator output. */
17
+ export declare function containsUnresolvedContractPlaceholder(rawText: string, options?: {
18
+ excludeGeneratorMissingDecisions?: boolean;
19
+ }): boolean;
16
20
  /** Pure pre-persistence validation for the complete unified spec candidate. */
17
21
  export declare function validateUnifiedSpecCandidate(candidate: string, options?: {
18
22
  groundedFilePaths?: Iterable<string>;
@@ -20,7 +20,7 @@ export function buildCanonicalUnifiedSpecContent(input) {
20
20
  const proseBeforeHeading = source.split(/^##[ \t]+\S.*$/m, 1)[0]?.trim() ?? '';
21
21
  const problem = firstNonEmpty(explicitProblem, proseBeforeHeading, source);
22
22
  const explicitGoal = extractTopLevelSectionBody(source, 'Goal');
23
- const goal = firstNonEmpty(explicitGoal, firstSentence(problem), problem);
23
+ const goal = firstNonEmpty(explicitGoal, input.criteria[0]?.text, 'Needs decision: define the goal this spec must achieve.');
24
24
  const explicitTechnical = extractTopLevelSectionBody(source, 'Technical');
25
25
  const generatedNotes = extractTopLevelSectionBody(input.technicalBody, 'Implementation Notes');
26
26
  const technical = firstNonEmpty(explicitTechnical, generatedNotes, 'Implement only the grounded ownership and observable behavior declared by this contract.');
@@ -154,6 +154,27 @@ function hasUnresolvedContractPlaceholderLine(scannable) {
154
154
  return (CONTRACT_PLACEHOLDER_MARKERS.some((marker) => marker.toLowerCase() === stripped.toLowerCase()) || /^(?:needs decision|tbd|todo)\s*:/i.test(stripped));
155
155
  });
156
156
  }
157
+ function stripMissingDecisionsSection(scannable) {
158
+ const heading = /^ {0,3}##[ \t]+Missing Decisions[ \t]*$/im.exec(scannable);
159
+ if (!heading) {
160
+ return scannable;
161
+ }
162
+ const start = heading.index;
163
+ const nextHeadingRe = /^ {0,3}##[ \t]+\S.*$/gm;
164
+ nextHeadingRe.lastIndex = start + heading[0].length;
165
+ const next = nextHeadingRe.exec(scannable);
166
+ const end = next ? next.index : scannable.length;
167
+ return scannable.slice(0, start) + scannable.slice(end);
168
+ }
169
+ /** Masking-aware scan for an unresolved contract placeholder in raw generator output. */
170
+ export function containsUnresolvedContractPlaceholder(rawText, options = {}) {
171
+ const body = stripFrontmatter(rawText.replace(/\r\n/g, '\n'));
172
+ let scannable = maskFencedAndQuotedText(body);
173
+ if (options.excludeGeneratorMissingDecisions) {
174
+ scannable = stripMissingDecisionsSection(scannable);
175
+ }
176
+ return hasUnresolvedContractPlaceholderLine(scannable);
177
+ }
157
178
  /** Pure pre-persistence validation for the complete unified spec candidate. */
158
179
  // eslint-disable-next-line max-lines-per-function -- validation order mirrors the public issue contract
159
180
  export function validateUnifiedSpecCandidate(candidate, options = {}) {
@@ -285,13 +306,6 @@ function renderSection(title, body) {
285
306
  function firstNonEmpty(...values) {
286
307
  return (values.find((value) => value !== undefined && value !== null && value.length > 0) ?? '');
287
308
  }
288
- function firstSentence(text) {
289
- const sentence = text
290
- .split(/(?<=[.!?])\s+/)
291
- .map((part) => part.trim())
292
- .find((part) => part.length > 0 && !part.startsWith('#'));
293
- return sentence ?? null;
294
- }
295
309
  function fallbackCriteriaBullets(criteria) {
296
310
  return criteria
297
311
  .map((criterion) => `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${criterion.text}`)
@@ -87,6 +87,10 @@ const PLACEHOLDER_REFERENCE_RULES = [
87
87
  pattern: /(^|\/)(example|sample|mock|dummy)[\w.-]*\.[a-z]+$/i,
88
88
  reason: 'example file names must not be persisted as technical ownership',
89
89
  },
90
+ {
91
+ pattern: /(^|\/)(foo|bar|baz)\d*(?:\.[a-z0-9]+)+$/i,
92
+ reason: 'example file names must not be persisted as technical ownership',
93
+ },
90
94
  ];
91
95
  export function checkGenericSpecOutput(content) {
92
96
  const criteria = frontmatterCriteria(content);
@@ -299,12 +299,22 @@ function scoreAmbiguity(content, criteriaLines) {
299
299
  recommendations.push('Replace all TBD/TODO/FIXME markers with concrete requirements');
300
300
  }
301
301
  // Check for edge case coverage: criteria with error/failure/invalid/empty (-4 if missing)
302
- const edgeCaseKeywords = ['error', 'fail', 'invalid', 'empty', 'null', 'missing', 'timeout'];
303
- const hasEdgeCases = criteriaLines.length > 0 &&
304
- criteriaLines.some((line) => {
305
- const l = line.toLowerCase();
306
- return edgeCaseKeywords.some((k) => l.includes(k));
307
- });
302
+ const edgeCaseKeywords = [
303
+ 'error',
304
+ 'fail',
305
+ 'invalid',
306
+ 'empty',
307
+ 'null',
308
+ 'missing',
309
+ 'timeout',
310
+ 'halt',
311
+ 'abort',
312
+ 'refuse',
313
+ 'reject',
314
+ 'exit',
315
+ ];
316
+ const edgeCasePattern = new RegExp(`\\b(${edgeCaseKeywords.join('|')})`, 'i');
317
+ const hasEdgeCases = criteriaLines.length > 0 && criteriaLines.some((line) => edgeCasePattern.test(line));
308
318
  if (criteriaLines.length >= 3 && !hasEdgeCases) {
309
319
  score -= 4;
310
320
  issues.push('No edge case criteria found (error, failure, invalid inputs)');
@@ -14,8 +14,8 @@ import { validateConstitution } from './create-spec/constitution-validator.js';
14
14
  import { getAsyncAnalysisPath } from './create-spec/post-creation.js';
15
15
  import { extractCriteria, generateLeanSpecContent, } from '../engine/spec-format/lean-spec-generator.js';
16
16
  import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-technical-generator.js';
17
- import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
18
- import { buildCanonicalUnifiedSpecContent, validateUnifiedSpecCandidate, } from '../engine/spec-format/unified-spec-builder.js';
17
+ import { extractFilesFromSpecBody, extractMarkerDeclaredPaths, } from '../engine/spec-format/technical-md-populator.js';
18
+ import { buildCanonicalUnifiedSpecContent, containsUnresolvedContractPlaceholder, validateUnifiedSpecCandidate, } from '../engine/spec-format/unified-spec-builder.js';
19
19
  import { buildImplementationContractSection } from '../engine/implementation-contract/index.js';
20
20
  import { resolveEnglishOnlySpecGate } from '../engine/spec-language/english-only.js';
21
21
  import { FallbackGenerator } from '../engine/spec-generator/index.js';
@@ -116,8 +116,25 @@ export async function groundTechnicalFiles(input) {
116
116
  };
117
117
  const records = [];
118
118
  const advisoryFiles = [];
119
+ const markerPaths = extractMarkerDeclaredPaths(input.userInput);
119
120
  for (const section of ['create', 'modify', 'test']) {
120
121
  for (const file of input.files[section]) {
122
+ if (markerPaths !== null) {
123
+ if (markerPaths.has(file.path.toLowerCase())) {
124
+ grounded[section].push(file);
125
+ records.push({
126
+ path: file.path,
127
+ section,
128
+ source: 'user_input',
129
+ evidence: ['create_spec.description'],
130
+ confidence: 'high',
131
+ });
132
+ }
133
+ else {
134
+ advisoryFiles.push(file);
135
+ }
136
+ continue;
137
+ }
121
138
  const pathInUserInput = pathSurvivesNegativeClauseCheck(userInput, file.path.toLowerCase());
122
139
  const exists = await pathExists(pathJoin(input.projectPath, file.path));
123
140
  const fromAutopilot = autopilotPaths.has(file.path);
@@ -1182,6 +1199,30 @@ async function prepareCreateSpecCandidate(initialParams, server) {
1182
1199
  },
1183
1200
  });
1184
1201
  }
1202
+ if (containsUnresolvedContractPlaceholder(generatedSpec.specBody, {
1203
+ excludeGeneratorMissingDecisions: true,
1204
+ })) {
1205
+ return earlyPreparation({
1206
+ content: [
1207
+ {
1208
+ type: 'text',
1209
+ text: 'Unified spec candidate is invalid: Unified candidate contains an unresolved contract placeholder.',
1210
+ },
1211
+ ],
1212
+ isError: true,
1213
+ structuredContent: {
1214
+ error: 'SPEC_FORMAT_INVALID',
1215
+ code: 422,
1216
+ persisted: false,
1217
+ issues: [
1218
+ {
1219
+ code: 'UNRESOLVED_CONTRACT_PLACEHOLDER',
1220
+ message: 'Unified candidate contains an unresolved contract placeholder.',
1221
+ },
1222
+ ],
1223
+ },
1224
+ });
1225
+ }
1185
1226
  const actionableMetrics = calculateActionableSpecMetrics({
1186
1227
  criteria: [...baseCriteria, ...filteredCriteria],
1187
1228
  groundingRecords: groundingCriteria,
@@ -51,6 +51,7 @@ export interface FeasibilityRule {
51
51
  export interface VagueWordItem {
52
52
  id: string;
53
53
  word: string;
54
+ exemptFollowingNouns?: string[];
54
55
  }
55
56
  export interface ReadinessThresholds {
56
57
  strictBlocker: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.33",
3
+ "version": "5.3.35",
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.33",
5
+ "version": "5.3.35",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",