@planu/cli 5.3.30 → 5.3.32

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,18 @@
1
+ ## [5.3.32] - 2026-08-21
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1585): harden scope-boundary contradiction checker against topical false positives
5
+
6
+
7
+ ## [5.3.31] - 2026-08-21
8
+
9
+ ### Bug Fixes
10
+ - fix(spec-1584): exclude .claude/worktrees from Stryker sandbox to survive residual worktrees
11
+
12
+ ### Chores
13
+ - chore(planu): file SPEC-1584 (Stryker worktree dangling-symlink) draft
14
+
15
+
1
16
  ## [5.3.30] - 2026-08-20
2
17
 
3
18
  ### 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.30",
3
+ "version": "5.3.32",
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.30",
5
+ "version": "5.3.32",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",