@planu/cli 5.3.43 → 5.3.45

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,22 @@
1
+ ## [5.3.45] - 2026-08-24
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1278): derive coherent challenge pass semantics from unresolved critical findings
5
+ - fix(spec-1326): prevent registry release language from activating web-auth challenge families
6
+
7
+ ### Chores
8
+ - chore(planu): record SPEC-1278 done state and file SPEC-1597 dogfood spec
9
+
10
+
11
+ ## [5.3.44] - 2026-08-24
12
+
13
+ ### Bug Fixes
14
+ - fix(spec-1596): align check-readiness validator test double with getSpecFresh
15
+ - fix(spec-1594): gate example-only PII identifiers behind boundary-aware collection cue
16
+ - fix(spec-1593): gate suppress-family verbs so no-op consequence criteria are not misflagged
17
+ - fix(spec-1595): make strict readiness report reflect edited spec body content
18
+
19
+
1
20
  ## [5.3.43] - 2026-08-24
2
21
 
3
22
  ### 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,
@@ -1,13 +1,18 @@
1
+ import type { ChallengeGateStatus } from '../types/index.js';
1
2
  /** create_spec — plan was created successfully */
2
3
  export declare function buildCreateSpecSummary(title: string, devHours: number): string;
3
4
  /** update_status — status transition completed */
4
5
  export declare function buildUpdateStatusSummary(title: string, newStatus: string): string;
5
6
  /** check_readiness — readiness score evaluated */
6
7
  export declare function buildCheckReadinessSummary(score: number, blockerCount: number): string;
7
- /** challenge_spec — risks and scenarios identified */
8
- export declare function buildChallengeSpecSummary(topScenarios: {
8
+ /**
9
+ * challenge_spec risks and scenarios identified.
10
+ * Language is derived from the evidence-based gate state, never from overallRisk alone,
11
+ * so a blocked gate can never read as passed and a passed gate is never called coding-ready.
12
+ */
13
+ export declare function buildChallengeSpecSummary(blockingScenarios: {
9
14
  scenario: string;
10
- }[], overallRisk: string): string;
15
+ }[], gateStatus: ChallengeGateStatus, advisoryCount: number): string;
11
16
  /** validate — criteria checked against implementation */
12
17
  export declare function buildValidateSummary(passing: number, total: number): string;
13
18
  /** list_specs — specs listed for a project */
@@ -38,19 +38,38 @@ export function buildCheckReadinessSummary(score, blockerCount) {
38
38
  }
39
39
  return `This plan scores ${String(score)}/100 and needs more detail before it can be approved. Check the recommendations and fill in the gaps.`;
40
40
  }
41
- /** challenge_spec — risks and scenarios identified */
42
- export function buildChallengeSpecSummary(topScenarios, overallRisk) {
43
- const count = topScenarios.length;
44
- if (count === 0) {
45
- return `No critical issues found — this plan looks solid. Approve it when you're ready to build.`;
46
- }
47
- const noun = count === 1 ? 'thing' : 'things';
48
- const items = topScenarios.map((s) => s.scenario);
49
- const list = count <= 3
41
+ /**
42
+ * challenge_spec risks and scenarios identified.
43
+ * Language is derived from the evidence-based gate state, never from overallRisk alone,
44
+ * so a blocked gate can never read as passed and a passed gate is never called coding-ready.
45
+ */
46
+ export function buildChallengeSpecSummary(blockingScenarios, gateStatus, advisoryCount) {
47
+ const list = describeBlockingScenarios(blockingScenarios);
48
+ if (gateStatus === 'blocked-critical') {
49
+ return `BLOCKED — ${String(blockingScenarios.length)} unresolved critical finding(s) must be resolved before this passes the challenge gate: ${list}. Record mitigation or accepted-risk evidence, then run challenge_spec again.`;
50
+ }
51
+ if (gateStatus === 'blocked-evidence' || gateStatus === 'refresh-required') {
52
+ if (gateStatus === 'refresh-required') {
53
+ return `BLOCKED — the stored report predates impact tracking and cannot rule out unresolved critical findings. Run challenge_spec again to refresh it.`;
54
+ }
55
+ return `${String(blockingScenarios.length)} thing(s) you must handle before coding: ${list}. Fix these, then run challenge_spec again to confirm.`;
56
+ }
57
+ if (gateStatus === 'passed-with-advisories') {
58
+ const advisoryNote = advisoryCount > 0
59
+ ? ` ${String(advisoryCount)} non-critical advisory finding(s) remain — review them, but they do not block this gate.`
60
+ : '';
61
+ return `Challenge gate threshold satisfied — this is not coding readiness.${advisoryNote} Run check_readiness for implementation readiness.`;
62
+ }
63
+ return `Every discovered finding is resolved and the challenge gate passed — this is not coding readiness by itself. Run check_readiness for implementation readiness.`;
64
+ }
65
+ function describeBlockingScenarios(scenarios) {
66
+ if (scenarios.length === 0) {
67
+ return 'no remaining blockers';
68
+ }
69
+ const items = scenarios.map((s) => s.scenario);
70
+ return scenarios.length <= 3
50
71
  ? items.join(', ')
51
- : `${items.slice(0, 3).join(', ')}, and ${String(count - 3)} more`;
52
- const urgency = overallRisk === 'critical' || overallRisk === 'high' ? ' before coding' : '';
53
- return `${String(count)} ${noun} you must handle${urgency}: ${list}. Fix these, then run challenge_spec again to confirm.`;
72
+ : `${items.slice(0, 3).join(', ')}, and ${String(scenarios.length - 3)} more`;
54
73
  }
55
74
  /** validate — criteria checked against implementation */
56
75
  export function buildValidateSummary(passing, total) {
@@ -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,10 +1,21 @@
1
- import type { ChallengeReport, ChallengeResolutionEvidence, FailureScenario } from '../../types/index.js';
1
+ import type { ChallengeFinding, ChallengeGateState, ChallengeReport, ChallengeResolutionEvidence, FailureScenario } from '../../types/index.js';
2
2
  export declare const MINIMUM_RESOLVED_CHALLENGES = 3;
3
3
  export declare function requiredResolvedChallenges(totalScenarios: number): number;
4
4
  export declare function parseChallengeResolutionEvidence(specContent: string, scenarios: readonly FailureScenario[], runAt: string): ChallengeResolutionEvidence[];
5
5
  /** Count only evidence tied to findings persisted by the same challenge report. */
6
6
  export declare function getResolvedChallengeEvidence(report: ChallengeReport | undefined): ChallengeResolutionEvidence[];
7
- /** Build a report whose resolution state is derived exclusively from explicit evidence. */
7
+ /**
8
+ * The single evidence-and-impact-derived authority for challenge gate state.
9
+ * Ignores caller-supplied addressedCount/resolved/passed — resolution comes only
10
+ * from evidence that exactly matches a currently discovered scenario identity.
11
+ */
12
+ export declare function deriveChallengeGateState(input: {
13
+ findings: readonly ChallengeFinding[] | undefined;
14
+ resolutionEvidence: readonly ChallengeResolutionEvidence[] | undefined;
15
+ totalScenarios: number;
16
+ overallRisk: ChallengeReport['overallRisk'];
17
+ }): ChallengeGateState;
18
+ /** Build a report whose resolution and gate state are derived exclusively from explicit evidence. */
8
19
  export declare function buildChallengeReport(input: {
9
20
  scenarios: FailureScenario[];
10
21
  focusAreas: string[];
@@ -91,7 +91,67 @@ export function getResolvedChallengeEvidence(report) {
91
91
  .filter((scenario) => typeof scenario === 'string' && scenario.trim().length > 0));
92
92
  return uniqueMatchingEvidence(report.resolutionEvidence, scenarioNames);
93
93
  }
94
- /** Build a report whose resolution state is derived exclusively from explicit evidence. */
94
+ function dedupeFindingsByIdentity(findings) {
95
+ const byIdentity = new Map();
96
+ for (const finding of findings) {
97
+ if (typeof finding.scenario === 'string' &&
98
+ finding.scenario.trim().length > 0 &&
99
+ !byIdentity.has(finding.scenario)) {
100
+ byIdentity.set(finding.scenario, finding);
101
+ }
102
+ }
103
+ return byIdentity;
104
+ }
105
+ /**
106
+ * The single evidence-and-impact-derived authority for challenge gate state.
107
+ * Ignores caller-supplied addressedCount/resolved/passed — resolution comes only
108
+ * from evidence that exactly matches a currently discovered scenario identity.
109
+ */
110
+ export function deriveChallengeGateState(input) {
111
+ const identities = dedupeFindingsByIdentity(input.findings ?? []);
112
+ const scenarioNames = new Set(identities.keys());
113
+ const resolvedScenarios = new Set(uniqueMatchingEvidence(input.resolutionEvidence, scenarioNames).map((item) => item.scenario));
114
+ const requiredCount = requiredResolvedChallenges(scenarioNames.size);
115
+ const addressedCount = resolvedScenarios.size;
116
+ const unresolved = [...identities.values()].filter((finding) => !resolvedScenarios.has(finding.scenario));
117
+ const isAboveMediumRisk = input.overallRisk === 'high' || input.overallRisk === 'critical';
118
+ const hasUnknownImpact = unresolved.some((finding) => finding.impact === undefined);
119
+ if (isAboveMediumRisk && hasUnknownImpact && unresolved.length > 0) {
120
+ const unresolvedIdentities = unresolved.map((finding) => finding.scenario);
121
+ return {
122
+ addressedCount,
123
+ requiredCount,
124
+ unresolvedCount: unresolved.length,
125
+ unresolvedCriticalCount: 0,
126
+ unresolvedIdentities,
127
+ unresolvedCriticalIdentities: [],
128
+ unresolvedAdvisoryIdentities: unresolvedIdentities,
129
+ gateStatus: 'refresh-required',
130
+ passed: false,
131
+ };
132
+ }
133
+ const unresolvedCritical = unresolved.filter((finding) => finding.impact === 'critical');
134
+ const unresolvedAdvisory = unresolved.filter((finding) => finding.impact !== 'critical');
135
+ const gateStatus = unresolvedCritical.length > 0
136
+ ? 'blocked-critical'
137
+ : addressedCount < requiredCount
138
+ ? 'blocked-evidence'
139
+ : unresolved.length > 0
140
+ ? 'passed-with-advisories'
141
+ : 'passed-all-resolved';
142
+ return {
143
+ addressedCount,
144
+ requiredCount,
145
+ unresolvedCount: unresolved.length,
146
+ unresolvedCriticalCount: unresolvedCritical.length,
147
+ unresolvedIdentities: unresolved.map((finding) => finding.scenario),
148
+ unresolvedCriticalIdentities: unresolvedCritical.map((finding) => finding.scenario),
149
+ unresolvedAdvisoryIdentities: unresolvedAdvisory.map((finding) => finding.scenario),
150
+ gateStatus,
151
+ passed: gateStatus === 'passed-with-advisories' || gateStatus === 'passed-all-resolved',
152
+ };
153
+ }
154
+ /** Build a report whose resolution and gate state are derived exclusively from explicit evidence. */
95
155
  export function buildChallengeReport(input) {
96
156
  const scenarioNames = new Set(input.scenarios.map((scenario) => scenario.scenario));
97
157
  const resolutionEvidence = uniqueMatchingEvidence([
@@ -101,19 +161,29 @@ export function buildChallengeReport(input) {
101
161
  ...(Array.isArray(input.explicitResolutionEvidence) ? input.explicitResolutionEvidence : []),
102
162
  ], scenarioNames);
103
163
  const resolvedScenarios = new Set(resolutionEvidence.map((item) => item.scenario));
104
- const addressedCount = resolvedScenarios.size;
105
- const requiredCount = requiredResolvedChallenges(scenarioNames.size);
164
+ const findings = input.scenarios.map((scenario) => ({
165
+ scenario: scenario.scenario,
166
+ resolved: resolvedScenarios.has(scenario.scenario),
167
+ impact: scenario.impact,
168
+ }));
169
+ const gateState = deriveChallengeGateState({
170
+ findings,
171
+ resolutionEvidence,
172
+ totalScenarios: input.scenarios.length,
173
+ overallRisk: input.overallRisk,
174
+ });
106
175
  return {
107
176
  runAt: input.runAt ?? new Date().toISOString(),
108
177
  totalScenarios: input.scenarios.length,
109
- addressedCount,
178
+ addressedCount: gateState.addressedCount,
179
+ requiredCount: gateState.requiredCount,
180
+ unresolvedCount: gateState.unresolvedCount,
181
+ unresolvedCriticalCount: gateState.unresolvedCriticalCount,
182
+ gateStatus: gateState.gateStatus,
110
183
  focusAreas: input.focusAreas,
111
184
  overallRisk: input.overallRisk,
112
- passed: addressedCount >= requiredCount,
113
- findings: input.scenarios.map((scenario) => ({
114
- scenario: scenario.scenario,
115
- resolved: resolvedScenarios.has(scenario.scenario),
116
- })),
185
+ passed: gateState.passed,
186
+ findings,
117
187
  resolutionEvidence,
118
188
  };
119
189
  }
@@ -1,5 +1,5 @@
1
1
  // tools/challenge-spec/scenarios-utils.ts — Shared utilities for scenario generators
2
- import { hasAnyAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
2
+ import { hasAffirmedMatch, hasAnyAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
3
3
  const CAPABILITY_SIGNALS = {
4
4
  networkApi: [
5
5
  /(?<!compiler\s)\bapi\b(?!\s+(?:compatibility|surface|package|type))/i,
@@ -14,9 +14,7 @@ const CAPABILITY_SIGNALS = {
14
14
  /\b(?:database|persistence)\s+(?:query|schema|table|migration|transaction)\b/i,
15
15
  /\b(?:orm|prisma|drizzle|typeorm|sequelize)\b/i,
16
16
  ],
17
- authentication: [
18
- /\b(?:auth(?:entication|enticate)?|login|sign[ -]?in|password|jwt|oauth2?|session\s+auth)\b/i,
19
- ],
17
+ authentication: [/\b(?:login|sign[ -]?in|password|jwt|oauth2?|session\s+auth)\b/i],
20
18
  authorization: [
21
19
  /\b(?:authori[sz]ation|permission|rbac|role-based|access\s+control|resource\s+ownership|tenant)\b/i,
22
20
  ],
@@ -92,6 +90,20 @@ const CAPABILITY_SIGNALS = {
92
90
  /\b(?:smart\s+contract|solidity|discord\s+bot|telegram\s+bot|iot\s+device|firmware|infrastructure\s+as\s+code|terraform|machine\s+learning\s+model)\b/i,
93
91
  ],
94
92
  };
93
+ const AUTHENTICATION_BARE_TOKEN_RE = /\bauth(?:entication|enticate)?\b/i;
94
+ // A bare "auth" token next to registry/package-manager vocabulary describes a
95
+ // dependency-registry failure, not a product authentication contract, in either
96
+ // word order ("npm registry authentication failure" / "auth failure in the package registry").
97
+ // Deliberately narrow to registry/npm/package/dependency — generic security words like
98
+ // "token" or "credential" stay affirmative so genuine CSRF/session-token prose is untouched.
99
+ const REGISTRY_AUTH_CONTEXT_RE = /\b(?:registry|npm|npmjs|package|dependency)\b(?:\W+\w+){0,6}?\W+auth(?:entication|enticate)?\b|\bauth(?:entication|enticate)?\b(?:\W+\w+){0,6}?\W+(?:registry|npm|npmjs|package|dependency)\b/i;
100
+ function hasProductAuthenticationEvidence(contract) {
101
+ if (hasAnyAffirmedMatch(contract, CAPABILITY_SIGNALS.authentication)) {
102
+ return true;
103
+ }
104
+ return (hasAffirmedMatch(contract, AUTHENTICATION_BARE_TOKEN_RE) &&
105
+ !REGISTRY_AUTH_CONTEXT_RE.test(contract));
106
+ }
95
107
  function keywordPattern(keyword) {
96
108
  const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
97
109
  const suffix = /^[a-z0-9]+$/i.test(keyword) && keyword.length > 3 ? '[a-z0-9_-]*' : '';
@@ -122,10 +134,12 @@ export function detectChallengeCapabilities(spec, specContent) {
122
134
  const contractSource = stripReaderPrefix(spec, specContent);
123
135
  const source = contractSource.trim().length > 0 ? contractSource : spec.title;
124
136
  const contract = stripMetaAnalysisText(stripNonContractText(source));
125
- return Object.fromEntries(Object.entries(CAPABILITY_SIGNALS).map(([capability, patterns]) => [
137
+ const capabilities = Object.fromEntries(Object.entries(CAPABILITY_SIGNALS).map(([capability, patterns]) => [
126
138
  capability,
127
139
  hasAnyAffirmedMatch(contract, patterns),
128
140
  ]));
141
+ capabilities.authentication = hasProductAuthenticationEvidence(contract);
142
+ return capabilities;
129
143
  }
130
144
  export function hasAnyChallengeCapability(capabilities, names) {
131
145
  return names.some((name) => capabilities[name]);
@@ -19,7 +19,7 @@ import { calculateTokenBudget, injectBudgetIntoPrompt } from '../engine/token-bu
19
19
  import { analyzeMinimalImplementation, loadMinimalImplementationPolicy, } from '../engine/minimality/index.js';
20
20
  import { detectChallengeCapabilities, } from './challenge-spec/scenarios-utils.js';
21
21
  import { collectCapabilityScenarios } from './challenge-spec/scenario-collector.js';
22
- import { buildChallengeReport, parseChallengeResolutionEvidence, } from './challenge-spec/challenge-report.js';
22
+ import { buildChallengeReport, deriveChallengeGateState, parseChallengeResolutionEvidence, } from './challenge-spec/challenge-report.js';
23
23
  import { extractListItems, findMarkdownSectionRange, } from '../engine/spec-format/markdown-sections.js';
24
24
  import { extractSectionCriteria } from '../engine/spec-format/acceptance-criteria.js';
25
25
  const ALL_FOCUS_AREAS = [
@@ -262,9 +262,9 @@ export async function handleChallengeSpec(args, server) {
262
262
  const scalabilityAssessment = buildScalabilityAssessment(spec, knowledge, actionableFailureScenarios);
263
263
  // 8. Calculate overall risk from grounded scenarios only.
264
264
  const overallRisk = calculateOverallRisk(actionableFailureScenarios, concurrencyAnalysis);
265
- // 9. Compute relevance scores and select top-3 (SPEC-338)
265
+ // 9. Compute relevance scores and select top-3 diagnostics (SPEC-338) — retained as-is
266
+ // regardless of gate state; blocker/advisory summaries are derived separately below.
266
267
  const prioritized = prioritizeScenarios(actionableFailureScenarios, spec);
267
- const prioritizedSummary = buildPrioritizedSummary(prioritized);
268
268
  const failureScenariosScored = actionableFailureScenarios.map((s) => {
269
269
  const match = prioritized.find((p) => p.scenario === s.scenario);
270
270
  return match !== undefined ? { ...s, relevanceScore: match.relevanceScore } : s;
@@ -285,7 +285,20 @@ export async function handleChallengeSpec(args, server) {
285
285
  explicitResolutionEvidence,
286
286
  runAt: challengeRunAt,
287
287
  });
288
- const rawHumanSummary = buildChallengeSpecSummary(prioritized, overallRisk);
288
+ const gateState = deriveChallengeGateState({
289
+ findings: challengeReport.findings,
290
+ resolutionEvidence: challengeReport.resolutionEvidence,
291
+ totalScenarios: challengeReport.totalScenarios,
292
+ overallRisk: challengeReport.overallRisk,
293
+ });
294
+ const scenarioByIdentity = new Map(failureScenariosScored.map((s) => [s.scenario, s]));
295
+ const { blocking: blockingIdentities, advisory: advisoryIdentities } = splitBlockingAndAdvisoryIdentities(gateState);
296
+ const blockingScenarios = blockingIdentities
297
+ .map((id) => scenarioByIdentity.get(id))
298
+ .filter((s) => s !== undefined);
299
+ const prioritizedBlocking = prioritizeScenarios(blockingScenarios, spec);
300
+ const prioritizedSummary = buildPrioritizedSummary(prioritizedBlocking);
301
+ const rawHumanSummary = buildChallengeSpecSummary(prioritizedBlocking, gateState.gateStatus, advisoryIdentities.length);
289
302
  // SPEC-620: Inject token budget tag into the LLM-facing summary prompt
290
303
  const specDevHours = spec.estimation.devHours;
291
304
  const challengeBudget = calculateTokenBudget(specDevHours > 0 ? specDevHours : 2, 'architecture');
@@ -303,19 +316,14 @@ export async function handleChallengeSpec(args, server) {
303
316
  suppressedLowRelevanceScenarios: suppressedScenarioCount + concurrencySuppressedCount,
304
317
  shownByDefault: 3,
305
318
  mustAddressBeforeCoding: prioritizedSummary,
319
+ remainingAdvisories: advisoryIdentities,
320
+ gateStatus: gateState.gateStatus,
306
321
  criticalImpact: actionableFailureScenarios.filter((s) => s.impact === 'critical').length,
307
322
  highImpact: actionableFailureScenarios.filter((s) => s.impact === 'high').length,
308
323
  raceConditions: concurrencyAnalysis.raceConditions.length,
309
324
  overallRisk,
310
325
  focusAreas: focusAreas,
311
- warnings: overallRisk === 'critical' || overallRisk === 'high'
312
- ? [
313
- `⚠️ HIGH RISK spec: overall risk is "${overallRisk}". Review all critical/high scenarios before implementation.`,
314
- overallRisk === 'critical'
315
- ? 'BLOCK: Resolve critical scenarios before merging to main branch.'
316
- : 'CAUTION: Add extra code review and integration tests for high-risk paths.',
317
- ]
318
- : [],
326
+ warnings: buildChallengeWarnings(overallRisk, gateState.unresolvedCriticalCount),
319
327
  },
320
328
  constitutionCompliance: constitution
321
329
  ? {
@@ -335,8 +343,8 @@ export async function handleChallengeSpec(args, server) {
335
343
  catch {
336
344
  // Best-effort: don't fail the challenge if persistence fails
337
345
  }
338
- // SPEC-595: Elicit how user wants to apply suggestions when critical issues exist
339
- const criticalCount = actionableFailureScenarios.filter((s) => s.impact === 'critical').length;
346
+ // SPEC-595: Elicit how user wants to apply suggestions when unresolved critical findings exist
347
+ const criticalCount = gateState.unresolvedCriticalCount;
340
348
  if (server !== undefined && criticalCount > 0) {
341
349
  const { field, property } = buildEnumSchema('apply', ['all', 'critical', 'none'], ['Apply all suggestions', 'Apply critical only (Recommended)', 'Review manually'], 'Apply suggestions', 'critical');
342
350
  const schema = { type: 'object', properties: { [field]: property } };
@@ -394,6 +402,47 @@ export async function handleChallengeSpec(args, server) {
394
402
  structuredContent: analysisPayload,
395
403
  };
396
404
  }
405
+ /**
406
+ * Splits unresolved finding identities into blockers (critical, or non-critical
407
+ * findings still needed to reach the resolution threshold) and advisories (every
408
+ * other unresolved finding). Resolved findings never appear in either list.
409
+ */
410
+ function splitBlockingAndAdvisoryIdentities(gateState) {
411
+ if (gateState.gateStatus === 'refresh-required') {
412
+ return { blocking: gateState.unresolvedIdentities, advisory: [] };
413
+ }
414
+ if (gateState.gateStatus === 'blocked-critical') {
415
+ const blockingSet = new Set(gateState.unresolvedCriticalIdentities);
416
+ return {
417
+ blocking: gateState.unresolvedCriticalIdentities,
418
+ advisory: gateState.unresolvedIdentities.filter((id) => !blockingSet.has(id)),
419
+ };
420
+ }
421
+ if (gateState.gateStatus === 'blocked-evidence') {
422
+ const neededCount = Math.max(gateState.requiredCount - gateState.addressedCount, 0);
423
+ const blocking = gateState.unresolvedIdentities.slice(0, neededCount);
424
+ const blockingSet = new Set(blocking);
425
+ return {
426
+ blocking,
427
+ advisory: gateState.unresolvedIdentities.filter((id) => !blockingSet.has(id)),
428
+ };
429
+ }
430
+ return { blocking: [], advisory: gateState.unresolvedIdentities };
431
+ }
432
+ /** Warnings never claim a blocked gate as passed and never fire on a resolved critical. */
433
+ function buildChallengeWarnings(overallRisk, unresolvedCriticalCount) {
434
+ const warnings = [];
435
+ if (overallRisk === 'critical' || overallRisk === 'high') {
436
+ warnings.push(`⚠️ HIGH RISK spec: overall risk is "${overallRisk}". Review all critical/high scenarios before implementation.`);
437
+ }
438
+ if (unresolvedCriticalCount > 0) {
439
+ warnings.push('BLOCK: Resolve critical scenarios before merging to main branch.');
440
+ }
441
+ else if (overallRisk === 'high') {
442
+ warnings.push('CAUTION: Add extra code review and integration tests for high-risk paths.');
443
+ }
444
+ return warnings;
445
+ }
397
446
  function isScenarioSupportedByCapabilities(scenario, capabilities) {
398
447
  const haystack = `${scenario.scenario} ${scenario.currentHandling} ${scenario.requiredHandling}`
399
448
  .toLowerCase()
@@ -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
@@ -9,7 +9,7 @@ import { validateEnglishOnlySpecText } from '../../engine/spec-language/english-
9
9
  import { checkGroundedSpecContract } from '../../engine/spec-grounding/contract.js';
10
10
  import { checkGenericSpecOutput, STRUCTURAL_INTERPOLATION_KIND, } from '../../engine/spec-quality/generic-output-gate.js';
11
11
  import { formatKeyValue } from '../output-formatter.js';
12
- import { getResolvedChallengeEvidence, requiredResolvedChallenges, } from '../challenge-spec/challenge-report.js';
12
+ import { deriveChallengeGateState } from '../challenge-spec/challenge-report.js';
13
13
  import { evaluateSpecDependencies } from '../../engine/dependency-evaluator.js';
14
14
  import { reportClassifiedDegradation } from '../../errors/classified-degradation.js';
15
15
  /**
@@ -556,9 +556,49 @@ export function checkChallengeGate(spec, newStatus) {
556
556
  },
557
557
  };
558
558
  }
559
- const resolutionEvidence = getResolvedChallengeEvidence(report);
560
- const addressedCount = resolutionEvidence.length;
561
- const requiredCount = requiredResolvedChallenges(new Set(report.findings.map((finding) => finding.scenario)).size);
559
+ const gateState = deriveChallengeGateState({
560
+ findings: report.findings,
561
+ resolutionEvidence: report.resolutionEvidence,
562
+ totalScenarios: report.totalScenarios,
563
+ overallRisk: report.overallRisk,
564
+ });
565
+ const { addressedCount, requiredCount, unresolvedCount, unresolvedCriticalCount } = gateState;
566
+ if (gateState.gateStatus === 'refresh-required') {
567
+ return {
568
+ content: [
569
+ {
570
+ type: 'text',
571
+ text: `Challenge gate blocked: the stored ${report.overallRisk}-risk report has unresolved findings without persisted impact classification, so unresolved critical findings cannot be ruled out. Run challenge_spec(specId="${spec.id}") to regenerate it before transitioning to review.`,
572
+ },
573
+ ],
574
+ isError: true,
575
+ structuredContent: {
576
+ error: 'CHALLENGE_GATE_BLOCKED',
577
+ code: 'CHALLENGE_REPORT_REFRESH_REQUIRED',
578
+ fixHint: `Run challenge_spec(specId="${spec.id}") to regenerate the report with impact metadata, then retry update_status(specId="${spec.id}", status="review").`,
579
+ },
580
+ };
581
+ }
582
+ if (unresolvedCriticalCount > 0) {
583
+ return {
584
+ content: [
585
+ {
586
+ type: 'text',
587
+ text: `Challenge gate blocked: ${String(unresolvedCriticalCount)} unresolved critical finding(s) remain (addressed ${String(addressedCount)}/${String(requiredCount)} required). Record mitigation or accepted-risk evidence for every critical finding before retrying.`,
588
+ },
589
+ ],
590
+ isError: true,
591
+ structuredContent: {
592
+ error: 'CHALLENGE_GATE_BLOCKED',
593
+ code: 'UNRESOLVED_CRITICAL_CHALLENGES',
594
+ addressedCount,
595
+ requiredCount,
596
+ unresolvedCount,
597
+ unresolvedCriticalCount,
598
+ fixHint: `Resolve every critical challenge_spec finding with concrete evidence, then retry update_status(specId="${spec.id}", status="review").`,
599
+ },
600
+ };
601
+ }
562
602
  if (addressedCount < requiredCount) {
563
603
  return {
564
604
  content: [
@@ -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.
@@ -4,6 +4,7 @@ import type { SpecFormatVersion, SpecHistoryEntry } from './versioning.js';
4
4
  import type { Estimation, Actuals, ImpactAnalysis } from '../estimation.js';
5
5
  import type { ConstitutionViolation } from '../project/core.js';
6
6
  import type { AgentTeamPlan } from '../agent-team.js';
7
+ import type { FailureScenario } from '../analysis.js';
7
8
  /** A single entry in the status transition history of a spec. */
8
9
  export interface StatusHistoryEntry {
9
10
  readonly status: SpecStatus;
@@ -157,12 +158,36 @@ export interface ChallengeReport {
157
158
  findings?: ChallengeFinding[];
158
159
  /** Explicit mitigation or risk-acceptance evidence for resolved findings. */
159
160
  resolutionEvidence?: ChallengeResolutionEvidence[];
161
+ /** Minimum unique resolution evidence required to satisfy the gate. */
162
+ requiredCount?: number;
163
+ /** Distinct findings without matching resolution evidence. */
164
+ unresolvedCount?: number;
165
+ /** Unresolved findings whose persisted impact is critical. */
166
+ unresolvedCriticalCount?: number;
167
+ /** Machine-readable explanation of why the challenge gate passed or failed. */
168
+ gateStatus?: ChallengeGateStatus;
160
169
  }
161
170
  export interface ChallengeFinding {
162
171
  /** Exact scenario text emitted by challenge_spec. */
163
172
  scenario: string;
164
173
  /** Derived from matching explicit resolution evidence; discovery alone is never resolution. */
165
174
  resolved: boolean;
175
+ /** Impact classification generated with the scenario; persisted so the lifecycle gate can recompute gate state without regenerating scenarios. */
176
+ impact?: FailureScenario['impact'];
177
+ }
178
+ /** Outcome of deriveChallengeGateState(): why the challenge gate passed or failed. */
179
+ export type ChallengeGateStatus = 'blocked-evidence' | 'blocked-critical' | 'refresh-required' | 'passed-with-advisories' | 'passed-all-resolved';
180
+ /** Evidence-and-impact-derived gate state shared by report construction and the transition guard. */
181
+ export interface ChallengeGateState {
182
+ addressedCount: number;
183
+ requiredCount: number;
184
+ unresolvedCount: number;
185
+ unresolvedCriticalCount: number;
186
+ unresolvedIdentities: string[];
187
+ unresolvedCriticalIdentities: string[];
188
+ unresolvedAdvisoryIdentities: string[];
189
+ gateStatus: ChallengeGateStatus;
190
+ passed: boolean;
166
191
  }
167
192
  export interface ChallengeResolutionEvidence {
168
193
  /** Exact scenario text being resolved. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "5.3.43",
3
+ "version": "5.3.45",
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.45",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",