@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 - 24), 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 (/\b(?:not|without|no)\s*$/.test(before)) {
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.includes(normalizedScope) || normalizedScope.includes(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 || assertsForbiddenAction(clause)));
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 (/\b(?:Needs decision|TBD|TODO)\b/i.test(scannable)) {
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 (lower.includes(termA) && lower.includes(termB)) {
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.includes(term)) {
292
+ if (hasWordBoundaryMatch(lower, term)) {
269
293
  undefinedCount++;
270
294
  issues.push(`Undefined placeholder found: "${term}"`);
271
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.31",
3
+ "version": "5.3.33",
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.31",
5
+ "version": "5.3.33",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",