@planu/cli 5.3.32 → 5.3.34
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 +18 -0
- package/dist/config/readiness-config.json +5 -1
- package/dist/engine/implementation-contract/renderer.js +1 -14
- package/dist/engine/readiness-checker.js +31 -5
- package/dist/engine/readiness-config-loader.d.ts +15 -0
- package/dist/engine/readiness-config-loader.js +5 -0
- package/dist/engine/spec-format/unified-spec-builder.d.ts +4 -0
- package/dist/engine/spec-format/unified-spec-builder.js +33 -9
- package/dist/engine/spec-quality-scorer.js +50 -16
- package/dist/tools/create-spec.js +25 -1
- package/dist/types/plugin-configs.d.ts +1 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [5.3.34] - 2026-08-22
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(spec-1588): fail closed on unresolved placeholders in raw generator output
|
|
5
|
+
- fix(spec-1509): stop check_readiness false positives on error-outcome and clean criteria
|
|
6
|
+
- fix(spec-1551): derive Goal and User Outcome from first acceptance criterion
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## [5.3.33] - 2026-08-21
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
- fix(spec-1587): scope creation-time placeholder scan to standalone line content
|
|
13
|
+
- fix(spec-1586): harden scoreAmbiguity against tautological substring-pairs and unanchored markers
|
|
14
|
+
|
|
15
|
+
### Chores
|
|
16
|
+
- chore(planu): sync autopilot state for SPEC-1586/1587
|
|
17
|
+
|
|
18
|
+
|
|
1
19
|
## [5.3.32] - 2026-08-21
|
|
2
20
|
|
|
3
21
|
### 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
|
-
{
|
|
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
|
-
|
|
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 =
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
/**
|
|
@@ -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,
|
|
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.');
|
|
@@ -144,6 +144,37 @@ function extractSection(body, sectionName) {
|
|
|
144
144
|
const end = next ? next.index : body.length;
|
|
145
145
|
return body.slice(start, end).trimEnd();
|
|
146
146
|
}
|
|
147
|
+
const CONTRACT_PLACEHOLDER_MARKERS = ['Needs decision', 'TBD', 'TODO'];
|
|
148
|
+
function stripLeadingListMarker(line) {
|
|
149
|
+
return line.replace(/^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/, '');
|
|
150
|
+
}
|
|
151
|
+
function hasUnresolvedContractPlaceholderLine(scannable) {
|
|
152
|
+
return scannable.split('\n').some((line) => {
|
|
153
|
+
const stripped = stripLeadingListMarker(line).trim();
|
|
154
|
+
return (CONTRACT_PLACEHOLDER_MARKERS.some((marker) => marker.toLowerCase() === stripped.toLowerCase()) || /^(?:needs decision|tbd|todo)\s*:/i.test(stripped));
|
|
155
|
+
});
|
|
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
|
+
}
|
|
147
178
|
/** Pure pre-persistence validation for the complete unified spec candidate. */
|
|
148
179
|
// eslint-disable-next-line max-lines-per-function -- validation order mirrors the public issue contract
|
|
149
180
|
export function validateUnifiedSpecCandidate(candidate, options = {}) {
|
|
@@ -188,7 +219,7 @@ export function validateUnifiedSpecCandidate(candidate, options = {}) {
|
|
|
188
219
|
message: `Required section heading is embedded in quoted, fenced, or nested content: ${section}`,
|
|
189
220
|
});
|
|
190
221
|
}
|
|
191
|
-
if (
|
|
222
|
+
if (hasUnresolvedContractPlaceholderLine(scannable)) {
|
|
192
223
|
issues.push({
|
|
193
224
|
code: 'UNRESOLVED_CONTRACT_PLACEHOLDER',
|
|
194
225
|
message: 'Unified candidate contains an unresolved contract placeholder.',
|
|
@@ -275,13 +306,6 @@ function renderSection(title, body) {
|
|
|
275
306
|
function firstNonEmpty(...values) {
|
|
276
307
|
return (values.find((value) => value !== undefined && value !== null && value.length > 0) ?? '');
|
|
277
308
|
}
|
|
278
|
-
function firstSentence(text) {
|
|
279
|
-
const sentence = text
|
|
280
|
-
.split(/(?<=[.!?])\s+/)
|
|
281
|
-
.map((part) => part.trim())
|
|
282
|
-
.find((part) => part.length > 0 && !part.startsWith('#'));
|
|
283
|
-
return sentence ?? null;
|
|
284
|
-
}
|
|
285
309
|
function fallbackCriteriaBullets(criteria) {
|
|
286
310
|
return criteria
|
|
287
311
|
.map((criterion) => `- GIVEN the requested change is implemented WHEN the behavior is exercised THEN ${criterion.text}`)
|
|
@@ -26,12 +26,12 @@ const VAGUE_WORDS = [
|
|
|
26
26
|
'intuitive',
|
|
27
27
|
];
|
|
28
28
|
const CONTRADICTION_PAIRS = [
|
|
29
|
-
['always', 'never'],
|
|
30
|
-
['mandatory', 'optional'],
|
|
31
|
-
['required', 'not required'],
|
|
32
|
-
['must', 'must not'],
|
|
33
|
-
['synchronous', 'asynchronous'],
|
|
34
|
-
['blocking', 'non-blocking'],
|
|
29
|
+
['always', 'never', 'genuine'],
|
|
30
|
+
['mandatory', 'optional', 'genuine'],
|
|
31
|
+
['required', 'not required', 'substring'],
|
|
32
|
+
['must', 'must not', 'substring'],
|
|
33
|
+
['synchronous', 'asynchronous', 'substring'],
|
|
34
|
+
['blocking', 'non-blocking', 'substring'],
|
|
35
35
|
];
|
|
36
36
|
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
37
37
|
function gradeFromTotal(total) {
|
|
@@ -234,6 +234,30 @@ function scoreTestability(criteriaLines) {
|
|
|
234
234
|
bddCoverage,
|
|
235
235
|
};
|
|
236
236
|
}
|
|
237
|
+
function wordBoundaryPattern(term) {
|
|
238
|
+
return new RegExp(`\\b${term}\\b`);
|
|
239
|
+
}
|
|
240
|
+
function hasWordBoundaryMatch(text, term) {
|
|
241
|
+
return wordBoundaryPattern(term).test(text);
|
|
242
|
+
}
|
|
243
|
+
function substringPairContradicts(line, shorter, longer) {
|
|
244
|
+
if (!hasWordBoundaryMatch(line, longer)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const withoutLonger = line.replace(new RegExp(`\\b${longer}\\b`, 'g'), ' ');
|
|
248
|
+
return hasWordBoundaryMatch(withoutLonger, shorter);
|
|
249
|
+
}
|
|
250
|
+
function genuinePairContradicts(line, termA, termB) {
|
|
251
|
+
return hasWordBoundaryMatch(line, termA) && hasWordBoundaryMatch(line, termB);
|
|
252
|
+
}
|
|
253
|
+
function pairContradictsInCriteria(criteriaLines, termA, termB, kind) {
|
|
254
|
+
return criteriaLines.some((rawLine) => {
|
|
255
|
+
const line = rawLine.toLowerCase();
|
|
256
|
+
return kind === 'substring'
|
|
257
|
+
? substringPairContradicts(line, termA, termB)
|
|
258
|
+
: genuinePairContradicts(line, termA, termB);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
237
261
|
function scoreAmbiguity(content, criteriaLines) {
|
|
238
262
|
const issues = [];
|
|
239
263
|
const recommendations = [];
|
|
@@ -247,10 +271,10 @@ function scoreAmbiguity(content, criteriaLines) {
|
|
|
247
271
|
};
|
|
248
272
|
}
|
|
249
273
|
const lower = content.toLowerCase();
|
|
250
|
-
// Check for contradiction pairs (-3 pts each, max -9)
|
|
274
|
+
// Check for contradiction pairs, scoped per criterion (-3 pts each, max -9)
|
|
251
275
|
let contradictions = 0;
|
|
252
|
-
for (const [termA, termB] of CONTRADICTION_PAIRS) {
|
|
253
|
-
if (
|
|
276
|
+
for (const [termA, termB, kind] of CONTRADICTION_PAIRS) {
|
|
277
|
+
if (pairContradictsInCriteria(criteriaLines, termA, termB, kind)) {
|
|
254
278
|
contradictions++;
|
|
255
279
|
if (contradictions <= 3) {
|
|
256
280
|
issues.push(`Potential contradiction: "${termA}" and "${termB}" both appear in spec`);
|
|
@@ -265,7 +289,7 @@ function scoreAmbiguity(content, criteriaLines) {
|
|
|
265
289
|
const undefinedTerms = ['tbd', 'todo', 'fixme', 'placeholder', 'to be defined', 'to be decided'];
|
|
266
290
|
let undefinedCount = 0;
|
|
267
291
|
for (const term of undefinedTerms) {
|
|
268
|
-
if (lower
|
|
292
|
+
if (hasWordBoundaryMatch(lower, term)) {
|
|
269
293
|
undefinedCount++;
|
|
270
294
|
issues.push(`Undefined placeholder found: "${term}"`);
|
|
271
295
|
}
|
|
@@ -275,12 +299,22 @@ function scoreAmbiguity(content, criteriaLines) {
|
|
|
275
299
|
recommendations.push('Replace all TBD/TODO/FIXME markers with concrete requirements');
|
|
276
300
|
}
|
|
277
301
|
// Check for edge case coverage: criteria with error/failure/invalid/empty (-4 if missing)
|
|
278
|
-
const edgeCaseKeywords = [
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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));
|
|
284
318
|
if (criteriaLines.length >= 3 && !hasEdgeCases) {
|
|
285
319
|
score -= 4;
|
|
286
320
|
issues.push('No edge case criteria found (error, failure, invalid inputs)');
|
|
@@ -15,7 +15,7 @@ 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
17
|
import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
|
|
18
|
-
import { buildCanonicalUnifiedSpecContent, validateUnifiedSpecCandidate, } from '../engine/spec-format/unified-spec-builder.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';
|
|
@@ -1182,6 +1182,30 @@ async function prepareCreateSpecCandidate(initialParams, server) {
|
|
|
1182
1182
|
},
|
|
1183
1183
|
});
|
|
1184
1184
|
}
|
|
1185
|
+
if (containsUnresolvedContractPlaceholder(generatedSpec.specBody, {
|
|
1186
|
+
excludeGeneratorMissingDecisions: true,
|
|
1187
|
+
})) {
|
|
1188
|
+
return earlyPreparation({
|
|
1189
|
+
content: [
|
|
1190
|
+
{
|
|
1191
|
+
type: 'text',
|
|
1192
|
+
text: 'Unified spec candidate is invalid: Unified candidate contains an unresolved contract placeholder.',
|
|
1193
|
+
},
|
|
1194
|
+
],
|
|
1195
|
+
isError: true,
|
|
1196
|
+
structuredContent: {
|
|
1197
|
+
error: 'SPEC_FORMAT_INVALID',
|
|
1198
|
+
code: 422,
|
|
1199
|
+
persisted: false,
|
|
1200
|
+
issues: [
|
|
1201
|
+
{
|
|
1202
|
+
code: 'UNRESOLVED_CONTRACT_PLACEHOLDER',
|
|
1203
|
+
message: 'Unified candidate contains an unresolved contract placeholder.',
|
|
1204
|
+
},
|
|
1205
|
+
],
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1185
1209
|
const actionableMetrics = calculateActionableSpecMetrics({
|
|
1186
1210
|
criteria: [...baseCriteria, ...filteredCriteria],
|
|
1187
1211
|
groundingRecords: groundingCriteria,
|
package/package.json
CHANGED
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.
|
|
5
|
+
"version": "5.3.34",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|