@planu/cli 5.3.31 → 5.3.33
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,19 @@
|
|
|
1
|
+
## [5.3.33] - 2026-08-21
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(spec-1587): scope creation-time placeholder scan to standalone line content
|
|
5
|
+
- fix(spec-1586): harden scoreAmbiguity against tautological substring-pairs and unanchored markers
|
|
6
|
+
|
|
7
|
+
### Chores
|
|
8
|
+
- chore(planu): sync autopilot state for SPEC-1586/1587
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
## [5.3.32] - 2026-08-21
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
- fix(spec-1585): harden scope-boundary contradiction checker against topical false positives
|
|
15
|
+
|
|
16
|
+
|
|
1
17
|
## [5.3.31] - 2026-08-21
|
|
2
18
|
|
|
3
19
|
### Bug Fixes
|
|
@@ -37,12 +37,35 @@ const STOP_WORDS = new Set([
|
|
|
37
37
|
'all',
|
|
38
38
|
'can',
|
|
39
39
|
'may',
|
|
40
|
+
'rather',
|
|
41
|
+
'than',
|
|
42
|
+
'which',
|
|
43
|
+
'would',
|
|
44
|
+
'such',
|
|
45
|
+
'same',
|
|
46
|
+
'each',
|
|
47
|
+
'also',
|
|
48
|
+
'over',
|
|
49
|
+
'onto',
|
|
40
50
|
]);
|
|
41
51
|
function keywords(phrase) {
|
|
42
52
|
return normalize(phrase)
|
|
43
53
|
.split(' ')
|
|
44
54
|
.filter((w) => w.length > 3 && !STOP_WORDS.has(w));
|
|
45
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Shared negation detector: "not/without ..." or "no <non-idiom> ..." immediately before a verb.
|
|
58
|
+
* Excludes adverbial idioms ("no doubt/longer/way/matter") that do NOT negate the verb.
|
|
59
|
+
*/
|
|
60
|
+
function endsWithNegation(before) {
|
|
61
|
+
if (/\b(?:not|without|never)(?:\s+\w+){0,6}\s*$/.test(before)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (!/\bno(?:\s+\w+){0,6}\s*$/.test(before)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return !/\bno\s+(?:doubt)\b(?:\s+\w+){0,5}\s*$/.test(before);
|
|
68
|
+
}
|
|
46
69
|
/** Return true only for explicit language that preserves an existing boundary. */
|
|
47
70
|
function preservesBoundary(text) {
|
|
48
71
|
const normalized = normalize(text);
|
|
@@ -53,8 +76,19 @@ function preservesBoundary(text) {
|
|
|
53
76
|
/\bkeeps?\b.{0,80}\bunchanged\b/,
|
|
54
77
|
/\bpreserves?\b.{0,80}\b(?:behavior|contract|boundary|semantics|state)\b/,
|
|
55
78
|
/\bwithout (?:any )?(?:changing|modifying|altering|adding|introducing)\b/,
|
|
56
|
-
/\b(?:does|do|must|will|should) not (?:change|modify|alter|add|introduce|implement|enable)\b/,
|
|
79
|
+
/\b(?:does|do|must|will|should) not (?:change|modify|alter|add|introduce|implement|enable|upgrade)\b/,
|
|
57
80
|
/\bno changes? to\b/,
|
|
81
|
+
/\bno\b(?!\s+(?:doubt)\b)(?:\s+\w+){0,6}\s+changes?\b/,
|
|
82
|
+
/\bunchanged\b/,
|
|
83
|
+
/\bstill contains\b.{0,60}\bunchanged\b/,
|
|
84
|
+
/\bis preserved\b/,
|
|
85
|
+
/\b(?:remains?|stays?) (?:preserved|intact|in place)\b/,
|
|
86
|
+
/\brefuses? to\b/,
|
|
87
|
+
/\bmust still be rejected\b/,
|
|
88
|
+
/\bnever (?:rebound|rebind|launders?|laundered)\b/,
|
|
89
|
+
/\bstays? (?:rejected|blocked)\b/,
|
|
90
|
+
/\bdoes not emit\b/,
|
|
91
|
+
/\bstill emits?\b.{0,60}\binvalid\b/,
|
|
58
92
|
].some((pattern) => pattern.test(normalized));
|
|
59
93
|
}
|
|
60
94
|
const CLAUSE_KEYWORD_OR_PUNCTUATION = /\b(?:but|while|although|however)\b|[,;]/i;
|
|
@@ -66,7 +100,7 @@ function splitClauses(text) {
|
|
|
66
100
|
.map((clause) => clause.trim())
|
|
67
101
|
.filter(Boolean);
|
|
68
102
|
}
|
|
69
|
-
const AFFIRMATIVE_ACTION_VERBS = /\b(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten))\b/;
|
|
103
|
+
const AFFIRMATIVE_ACTION_VERBS = /\b(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten)|remov(?:e|es|ing|ed)|forc(?:e|es|ing))\b/;
|
|
70
104
|
function assertsForbiddenAction(text) {
|
|
71
105
|
return AFFIRMATIVE_ACTION_VERBS.test(normalize(text));
|
|
72
106
|
}
|
|
@@ -78,9 +112,9 @@ function hasAffirmativeDrift(text, outOfScopeItem) {
|
|
|
78
112
|
new RegExp(`^${AFFIRMATIVE_ACTION_VERBS.source}`).test(segment) &&
|
|
79
113
|
clauseMatchesScope(actionSegments[index - 1] ?? '', outOfScopeItem);
|
|
80
114
|
for (const match of segment.matchAll(action)) {
|
|
81
|
-
const before = segment.slice(Math.max(0, match.index -
|
|
115
|
+
const before = segment.slice(Math.max(0, match.index - 80), match.index);
|
|
82
116
|
const after = segment.slice(match.index + match[0].length);
|
|
83
|
-
if (
|
|
117
|
+
if (endsWithNegation(before)) {
|
|
84
118
|
continue;
|
|
85
119
|
}
|
|
86
120
|
if (/^\s+remains? unchanged\b/.test(after)) {
|
|
@@ -102,7 +136,8 @@ function isDirectPhraseMatch(normCriterion, normScope) {
|
|
|
102
136
|
function clauseMatchesScope(clause, outOfScopeItem) {
|
|
103
137
|
const normalizedClause = normalize(clause);
|
|
104
138
|
const normalizedScope = normalize(outOfScopeItem);
|
|
105
|
-
if (normalizedClause
|
|
139
|
+
if (containsKeyword(normalizedClause, normalizedScope) ||
|
|
140
|
+
containsKeyword(normalizedScope, normalizedClause)) {
|
|
106
141
|
return true;
|
|
107
142
|
}
|
|
108
143
|
const scopeKeywords = keywords(outOfScopeItem);
|
|
@@ -116,6 +151,33 @@ function clauseMentionsScope(clause, outOfScopeItem) {
|
|
|
116
151
|
const mentionCandidates = scopeKeywords.length > 0 ? scopeKeywords : [normalize(outOfScopeItem)];
|
|
117
152
|
return mentionCandidates.some((keyword) => containsKeyword(normalizedClause, keyword));
|
|
118
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Bind the affirmative verb to the excluded object — an action verb only
|
|
156
|
+
* counts when a scope keyword sits within ACTION_SCOPE_WINDOW tokens of it.
|
|
157
|
+
*/
|
|
158
|
+
const ACTION_SCOPE_WINDOW = 4;
|
|
159
|
+
function assertsActionNearScope(clause, outOfScopeItem) {
|
|
160
|
+
const tokens = normalize(clause).split(' ');
|
|
161
|
+
const scopeKeywords = keywords(outOfScopeItem);
|
|
162
|
+
const mentionCandidates = scopeKeywords.length > 0 ? scopeKeywords : [normalize(outOfScopeItem)];
|
|
163
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
164
|
+
const token = tokens[i] ?? '';
|
|
165
|
+
if (!AFFIRMATIVE_ACTION_VERBS.test(token)) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const before = tokens.slice(Math.max(0, i - 6), i).join(' ');
|
|
169
|
+
if (endsWithNegation(before)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const lo = Math.max(0, i - ACTION_SCOPE_WINDOW);
|
|
173
|
+
const hi = Math.min(tokens.length, i + ACTION_SCOPE_WINDOW + 1);
|
|
174
|
+
const window = tokens.slice(lo, hi).join(' ');
|
|
175
|
+
if (mentionCandidates.some((keyword) => containsKeyword(window, keyword))) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
119
181
|
/**
|
|
120
182
|
* Determine whether a criterion text contradicts an out-of-scope item.
|
|
121
183
|
* Uses substring match first, then keyword overlap as fuzzy fallback.
|
|
@@ -139,7 +201,10 @@ function contradicts(criterionText, outOfScopeItem) {
|
|
|
139
201
|
}
|
|
140
202
|
const scopeAssertsForbiddenAction = assertsForbiddenAction(outOfScopeItem);
|
|
141
203
|
return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem) &&
|
|
142
|
-
(!scopeAssertsForbiddenAction ||
|
|
204
|
+
(!scopeAssertsForbiddenAction ||
|
|
205
|
+
(hasConditionalException
|
|
206
|
+
? assertsForbiddenAction(clause)
|
|
207
|
+
: assertsActionNearScope(clause, outOfScopeItem))));
|
|
143
208
|
}
|
|
144
209
|
/**
|
|
145
210
|
* Check acceptance criteria against the spec's outOfScope declarations.
|
|
@@ -144,6 +144,16 @@ 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
|
+
}
|
|
147
157
|
/** Pure pre-persistence validation for the complete unified spec candidate. */
|
|
148
158
|
// eslint-disable-next-line max-lines-per-function -- validation order mirrors the public issue contract
|
|
149
159
|
export function validateUnifiedSpecCandidate(candidate, options = {}) {
|
|
@@ -188,7 +198,7 @@ export function validateUnifiedSpecCandidate(candidate, options = {}) {
|
|
|
188
198
|
message: `Required section heading is embedded in quoted, fenced, or nested content: ${section}`,
|
|
189
199
|
});
|
|
190
200
|
}
|
|
191
|
-
if (
|
|
201
|
+
if (hasUnresolvedContractPlaceholderLine(scannable)) {
|
|
192
202
|
issues.push({
|
|
193
203
|
code: 'UNRESOLVED_CONTRACT_PLACEHOLDER',
|
|
194
204
|
message: 'Unified candidate contains an unresolved contract placeholder.',
|
|
@@ -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
|
}
|
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.33",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|