@planu/cli 5.3.43 → 5.3.44

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,12 @@
1
+ ## [5.3.44] - 2026-08-24
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1596): align check-readiness validator test double with getSpecFresh
5
+ - fix(spec-1594): gate example-only PII identifiers behind boundary-aware collection cue
6
+ - fix(spec-1593): gate suppress-family verbs so no-op consequence criteria are not misflagged
7
+ - fix(spec-1595): make strict readiness report reflect edited spec body content
8
+
9
+
1
10
  ## [5.3.43] - 2026-08-24
2
11
 
3
12
  ### Bug Fixes
@@ -170,6 +170,37 @@
170
170
  "Tenant",
171
171
  "Visitor"
172
172
  ],
173
+ "collectionCueVerbs": [
174
+ "collect",
175
+ "store",
176
+ "process",
177
+ "persist",
178
+ "save",
179
+ "record",
180
+ "provide",
181
+ "must have",
182
+ "includ",
183
+ "contain",
184
+ "track",
185
+ "log",
186
+ "updat",
187
+ "requir",
188
+ "verif",
189
+ "authenticat",
190
+ "scan"
191
+ ],
192
+ "provenance": {
193
+ "nonAffirmingSectionKeywords": [
194
+ "example",
195
+ "examples",
196
+ "reproducer",
197
+ "test fixture",
198
+ "test fixtures",
199
+ "vocabulary",
200
+ "out of scope",
201
+ "out-of-scope"
202
+ ]
203
+ },
173
204
  "frameworks": {
174
205
  "GDPR": {
175
206
  "defaultRetentionDays": 730,
@@ -59,8 +59,49 @@ function isWhitelistedContext(modelContext) {
59
59
  function hasFieldCue(contextWindow) {
60
60
  return /\b(field|fields|column|columns|property|properties|attribute|schema|model|entity|table|class|interface)\b/i.test(contextWindow);
61
61
  }
62
+ const COLLECTION_CUE_SUFFIXES = '(?:e|es|ed|ing|ion|ies|ied|ying|y|s)?';
63
+ function escapeRegexLiteral(value) {
64
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
65
+ }
66
+ function toCollectionCueBase(verb) {
67
+ return verb.endsWith('e') ? verb.slice(0, -1) : verb;
68
+ }
69
+ function buildCollectionCueRegex(verbs) {
70
+ const alternatives = verbs.flatMap((verb) => {
71
+ if (verb.includes(' ')) {
72
+ return [escapeRegexLiteral(verb)];
73
+ }
74
+ const base = toCollectionCueBase(verb);
75
+ const alts = [escapeRegexLiteral(base) + COLLECTION_CUE_SUFFIXES];
76
+ if (base === verb) {
77
+ const lastChar = base.slice(-1);
78
+ alts.push(escapeRegexLiteral(base + lastChar) + '(?:ed|ing)');
79
+ }
80
+ return alts;
81
+ });
82
+ return new RegExp(`\\b(?:${alternatives.join('|')})\\b`, 'i');
83
+ }
84
+ const collectionCueRegex = buildCollectionCueRegex(patterns.collectionCueVerbs);
62
85
  function hasCollectionCue(contextWindow) {
63
- return /\b(collect|store|process|persist|save|record|provide|must have|includes?|contains?)\b/i.test(contextWindow);
86
+ return collectionCueRegex.test(contextWindow);
87
+ }
88
+ const HEADING_LINE_RE = /^#{1,6}\s+.+$/gm;
89
+ function isNonAffirmingSection(text, index) {
90
+ const keywords = patterns.provenance?.nonAffirmingSectionKeywords ?? [];
91
+ if (keywords.length === 0) {
92
+ return false;
93
+ }
94
+ HEADING_LINE_RE.lastIndex = 0;
95
+ let lastHeading = '';
96
+ let match;
97
+ while ((match = HEADING_LINE_RE.exec(text)) !== null) {
98
+ if (match.index > index) {
99
+ break;
100
+ }
101
+ lastHeading = match[0];
102
+ }
103
+ const lowerHeading = lastHeading.toLowerCase();
104
+ return keywords.some((keyword) => lowerHeading.includes(keyword.toLowerCase()));
64
105
  }
65
106
  /**
66
107
  * Determines if a detected PII field is a GDPR Art. 9 special category.
@@ -121,9 +162,11 @@ function extractCandidates(text) {
121
162
  const wordIndex = lower.indexOf(word.toLowerCase());
122
163
  const contextWindow = cleaned.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
123
164
  const context = inferModelContext(contextWindow);
124
- if (isWhitelistedContext(context) ||
125
- (hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
126
- (hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow))) {
165
+ const isBoundToCollection = hasCollectionCue(contextWindow) && !isNonAffirmingSection(cleaned, wordIndex);
166
+ if (isBoundToCollection &&
167
+ (isWhitelistedContext(context) ||
168
+ (hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
169
+ (hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow)))) {
127
170
  candidates.push({ field: word, context });
128
171
  }
129
172
  }
@@ -86,12 +86,24 @@ export function extractCriteriaLines(huContent) {
86
86
  const lines = huContent.split('\n');
87
87
  const criteria = [];
88
88
  let inCriteriaSection = false;
89
+ let criteriaHeadingLevel = 0;
89
90
  for (const raw of lines) {
90
91
  const line = raw.trim();
91
- // Detect entering / leaving an Acceptance Criteria section
92
- if (/^#{1,6}\s+/.test(line)) {
93
- inCriteriaSection =
94
- /^#{1,6}\s+(acceptance\s+criteria|criteria|criterios\s+de\s+aceptaci[oó]n|criterios)\b/i.test(line);
92
+ // Detect entering / leaving an Acceptance Criteria section. A heading
93
+ // deeper than the criteria heading (e.g. `### Scenario` nested under
94
+ // `## Acceptance Criteria`) stays inside the section — only a heading
95
+ // at the same or shallower level closes it.
96
+ const headingMatch = /^(#{1,6})\s+/.exec(line);
97
+ if (headingMatch?.[1] !== undefined) {
98
+ const level = headingMatch[1].length;
99
+ const isCriteriaHeading = /^#{1,6}\s+(acceptance\s+criteria|criteria|criterios\s+de\s+aceptaci[oó]n|criterios)\b/i.test(line);
100
+ if (isCriteriaHeading) {
101
+ inCriteriaSection = true;
102
+ criteriaHeadingLevel = level;
103
+ }
104
+ else if (level <= criteriaHeadingLevel) {
105
+ inCriteriaSection = false;
106
+ }
95
107
  continue;
96
108
  }
97
109
  // Form 1 — checkbox (always counts, regardless of section)
@@ -349,10 +361,12 @@ function checkSpecificityGate(spec, criteriaLines, fichaContent, anticipatedTest
349
361
  if (!hasFunctionName && !hasTypeName) {
350
362
  blockers.push(`Difficulty ${String(spec.difficulty)} spec requires at least 1 criterion with an exact function name (e.g. createFoo()) or type name (e.g. FooBar).`);
351
363
  }
352
- // Detect anticipated test breaks: a test file path + "line N" (with optional ~) or "change ... from ... to"
364
+ const anticipatedTestBreaksHasTestPathWithNumber = /\d/.test(anticipatedTestBreaksContent) &&
365
+ [...extractFilePaths(anticipatedTestBreaksContent)].some((path) => /\.test\.[a-z0-9]+$/i.test(path));
353
366
  const hasTestBreak = /\btests?\/[a-zA-Z0-9/_.-]+\.test\.[a-z]+.*\bline\s+~?\d+/i.test(allText) ||
354
- /\bchange\b.*\bfrom\b.*\bto\b/i.test(allText) ||
355
- /\btests\/[a-zA-Z0-9/_.-]+\.test\.[a-z0-9]+(?![a-zA-Z0-9_.\\/-])[^\n]*\bline\s+~?\d+/i.test(anticipatedTestBreaksContent);
367
+ /\bchanges?\b.*\bfrom\b.*\bto\b/i.test(allText) ||
368
+ /\btests\/[a-zA-Z0-9/_.-]+\.test\.[a-z0-9]+(?![a-zA-Z0-9_.\\/-])[^\n]*\bline\s+~?\d+/i.test(anticipatedTestBreaksContent) ||
369
+ anticipatedTestBreaksHasTestPathWithNumber;
356
370
  if (!hasTestBreak) {
357
371
  blockers.push(`Difficulty ${String(spec.difficulty)} spec requires at least 1 anticipated test break ` +
358
372
  `(e.g. 'tests/api/foo.test.ts line 47: change count from 477 to 478').`);
@@ -100,7 +100,7 @@ function splitClauses(text) {
100
100
  .map((clause) => clause.trim())
101
101
  .filter(Boolean);
102
102
  }
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)|relax(?:es|ing|ed)?|forc(?:e|es|ing))\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)|relax(?:es|ing|ed)?|forc(?:e|es|ing)|suppress(?:es|ing|ed)?)\b/;
104
104
  function assertsForbiddenAction(text) {
105
105
  return AFFIRMATIVE_ACTION_VERBS.test(normalize(text));
106
106
  }
@@ -1,6 +1,6 @@
1
1
  // engine/spec-quality-scorer.ts — Spec quality scoring logic (SPEC-314)
2
2
  import { readFile } from 'node:fs/promises';
3
- import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
3
+ import { extractSectionBody, readSpecTechnicalSection, } from './spec-format/read-technical-section.js';
4
4
  import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
5
5
  import { stripFrontmatter } from './frontmatter-parser.js';
6
6
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -327,7 +327,7 @@ function scoreAmbiguity(content, criteriaLines) {
327
327
  recommendations,
328
328
  };
329
329
  }
330
- function scoreRisk(spec, technicalContent) {
330
+ function scoreRisk(spec, technicalContent, specBody) {
331
331
  const issues = [];
332
332
  const recommendations = [];
333
333
  let score = MAX_DIMENSION_SCORE;
@@ -364,8 +364,11 @@ function scoreRisk(spec, technicalContent) {
364
364
  'throughput',
365
365
  'sla',
366
366
  'nfr',
367
+ 'non-functional',
367
368
  ];
368
- const hasNfrs = nfrKeywords.some((k) => lowerTech.includes(k));
369
+ const hasNfrKeyword = nfrKeywords.some((k) => lowerTech.includes(k));
370
+ const hasNfrSubsection = extractSectionBody(specBody, 'Non-Functional Requirements').trim().length > 0;
371
+ const hasNfrs = hasNfrKeyword || hasNfrSubsection;
369
372
  if (!hasNfrs) {
370
373
  score -= 5;
371
374
  issues.push('No non-functional requirements found in technical spec');
@@ -401,7 +404,7 @@ export async function scoreSpecQuality(spec) {
401
404
  const completenessDetail = scoreCompleteness(spec, specContent);
402
405
  const testabilityDetail = scoreTestability(criteriaLines);
403
406
  const ambiguityDetail = scoreAmbiguity(specContent, criteriaLines);
404
- const riskDetail = scoreRisk(spec, technicalContent);
407
+ const riskDetail = scoreRisk(spec, technicalContent, specContent);
405
408
  const total = completenessDetail.score + testabilityDetail.score + ambiguityDetail.score + riskDetail.score;
406
409
  const qualityScore = {
407
410
  total,
@@ -1,8 +1,36 @@
1
1
  // tools/check-readiness.ts — Completeness checkpoint tool (SPEC-039, SPEC-314, SPEC-716)
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { specStore } from '../storage/index.js';
3
4
  import { buildCheckReadinessSummary } from '../engine/human-summary.js';
4
5
  import { validateSpecFormat } from '../core/spec-validator.js';
6
+ import { parseFrontmatter } from '../engine/frontmatter-parser.js';
5
7
  import { resolveProjectId } from './resolve-project-id.js';
8
+ const VALID_DIFFICULTIES = [1, 2, 3, 4, 5];
9
+ const VALID_SCOPES = ['trivial', 'feature', 'cross-module', 'architectural'];
10
+ function asDifficulty(value) {
11
+ return VALID_DIFFICULTIES.find((candidate) => candidate === value);
12
+ }
13
+ function asSpecScope(value) {
14
+ return VALID_SCOPES.find((candidate) => candidate === value);
15
+ }
16
+ /**
17
+ * Re-derive difficulty/scope from the freshly-read spec.md frontmatter so the
18
+ * readiness gate honors on-disk edits instead of the specs.json snapshot that
19
+ * `specStore` caches in memory.
20
+ */
21
+ async function readFreshDifficultyAndScope(specPath) {
22
+ try {
23
+ const raw = await readFile(specPath, 'utf-8');
24
+ const { metadata } = parseFrontmatter(raw);
25
+ return {
26
+ difficulty: asDifficulty(metadata.difficulty),
27
+ scope: asSpecScope(metadata.scope),
28
+ };
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
6
34
  // ── Formatting helpers ───────────────────────────────────────────────────────
7
35
  const MAX_VISIBLE_BLOCKERS = 8;
8
36
  const MAX_VISIBLE_WARNINGS = 8;
@@ -120,7 +148,7 @@ export async function handleCheckReadiness(args) {
120
148
  };
121
149
  }
122
150
  const { specId, mode = 'strict' } = args;
123
- const spec = await specStore.getSpec(projectId, specId);
151
+ const spec = await specStore.getSpecFresh(projectId, specId);
124
152
  if (!spec) {
125
153
  return {
126
154
  content: [
@@ -132,9 +160,15 @@ export async function handleCheckReadiness(args) {
132
160
  isError: true,
133
161
  };
134
162
  }
163
+ const fresh = spec.specPath ? await readFreshDifficultyAndScope(spec.specPath) : {};
164
+ const effectiveSpec = {
165
+ ...spec,
166
+ difficulty: fresh.difficulty ?? spec.difficulty,
167
+ scope: fresh.scope ?? spec.scope,
168
+ };
135
169
  // validateSpecFormat owns the readiness and quality evaluation so the response
136
170
  // cannot combine separately computed evidence from different reads of spec.md.
137
- const validationResult = await validateSpecFormat(spec, { readinessMode: mode });
171
+ const validationResult = await validateSpecFormat(effectiveSpec, { readinessMode: mode });
138
172
  // SPEC-716: Ground ready flag in unified SpecValidationResult.
139
173
  // Only non-file-access errors from validateSpecFormat are surfaced in the
140
174
  // report — MISSING_FRONTMATTER due to file-not-found is best-effort since
@@ -119,6 +119,13 @@ export interface PiiFrameworkConfig {
119
119
  dataPortability: boolean;
120
120
  regions: string[];
121
121
  }
122
+ /**
123
+ * Provenance guards that keep PII detection anchored to affirmative,
124
+ * data-collecting spec content rather than example enumerations.
125
+ */
126
+ export interface PiiProvenanceConfig {
127
+ nonAffirmingSectionKeywords: string[];
128
+ }
122
129
  /**
123
130
  * Full structure of pii-patterns.json.
124
131
  */
@@ -126,8 +133,10 @@ export interface PiiPatterns {
126
133
  vocabulary: Record<string, string[]>;
127
134
  modelContextBlacklist: string[];
128
135
  modelContextWhitelist: string[];
136
+ collectionCueVerbs: string[];
129
137
  frameworks: Record<string, PiiFrameworkConfig>;
130
138
  thirdPartySignatures: Record<string, string[]>;
139
+ provenance?: PiiProvenanceConfig;
131
140
  }
132
141
  /**
133
142
  * Input for the data_governance MCP tool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.43",
3
+ "version": "5.3.44",
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.43",
5
+ "version": "5.3.44",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",