@planu/cli 5.3.44 → 5.3.46

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,23 @@
1
+ ## [5.3.46] - 2026-08-24
2
+
3
+ ### Bug Fixes
4
+ - fix(spec-1285): meta filter sees backticked tool token; harden AC1 coverage
5
+ - fix(spec-1285): stop local privacy specs activating auth/meta challenge families
6
+
7
+ ### Chores
8
+ - chore(planu): record SPEC-1285 done and SPEC-1598 discarded (merged into SPEC-1285)
9
+
10
+
11
+ ## [5.3.45] - 2026-08-24
12
+
13
+ ### Bug Fixes
14
+ - fix(spec-1278): derive coherent challenge pass semantics from unresolved critical findings
15
+ - fix(spec-1326): prevent registry release language from activating web-auth challenge families
16
+
17
+ ### Chores
18
+ - chore(planu): record SPEC-1278 done state and file SPEC-1597 dogfood spec
19
+
20
+
1
21
  ## [5.3.44] - 2026-08-24
2
22
 
3
23
  ### Bug Fixes
@@ -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) {
@@ -25,6 +25,7 @@ const EXPECTED_META_ANALYSIS_RE = /^\s*expected\s*:(?=[^.!?。!?]{0,240}\b(?
25
25
  const QUALIFIED_EVIDENCE_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|when|then|and)\s+)?(?=[^.!?。!?]{0,240}\b(?:positive|negative|affirmative|meta-analysis(?:-only)?|regression|table-driven)\b)(?=[^.!?。!?]{0,240}\b(?:fixtures?|controls?|tests?|examples?|assertions?|findings?)\b)[^.!?。!?]{1,240}$/iu;
26
26
  const CONTRACT_ANALYSIS_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|when|then|and)\s+)?(?:(?:the|this|that|a|an|any|each|our|existing|current)\s+)?(?:[\p{L}\p{N}_-]+\s+){0,2}contract\s+(?:evidence|inference|requirements?)\b/iu;
27
27
  const CONTRACT_ANALYSIS_PREDICATE_RE = /\b(?:(?:is|are)\s+(?:inferred|expected|selected|classified|matched|detected|discussed|reported|recorded)|expects?|records?|describes?)\b|\b(?:detector|classifier|matcher)\s+evidence\b|\b(?:positive[ -]?control|expected\s+absence|meta-analysis(?:-only)?)\b/iu;
28
+ const META_ANALYSIS_TOOL_OUTPUT_RE = /\b(?:challenge[_\s]spec|challenge\s+tool)\b[^.!?\n]{0,120}?\bcall\b[^.!?\n]{0,120}?\b(?:emitted|returned|produced|reported|surfaced)\b[^.!?\n]{0,120}?\bscenarios?\b/iu;
28
29
  function stripExcludedMarkdownSections(text) {
29
30
  const lines = text.split('\n');
30
31
  const kept = [];
@@ -71,7 +72,8 @@ export function stripMetaAnalysisText(text) {
71
72
  !META_ANALYSIS_EVIDENCE_RE.test(clause) &&
72
73
  !EXPECTED_META_ANALYSIS_RE.test(clause) &&
73
74
  !QUALIFIED_EVIDENCE_SUBJECT_RE.test(clause) &&
74
- !(CONTRACT_ANALYSIS_SUBJECT_RE.test(clause) && CONTRACT_ANALYSIS_PREDICATE_RE.test(clause)))
75
+ !(CONTRACT_ANALYSIS_SUBJECT_RE.test(clause) && CONTRACT_ANALYSIS_PREDICATE_RE.test(clause)) &&
76
+ !META_ANALYSIS_TOOL_OUTPUT_RE.test(clause))
75
77
  .join('\n');
76
78
  }
77
79
  function clauseBefore(text, index) {
@@ -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,11 +14,9 @@ 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
- /\b(?:authori[sz]ation|permission|rbac|role-based|access\s+control|resource\s+ownership|tenant)\b/i,
19
+ /\b(?:authori[sz]ation|permission\s+(?:check|model|grant|denied|scope|boundary|set|policy)|access\s+permission|role\s+permission|rbac|role-based|access\s+control|resource\s+ownership|tenant)\b/i,
22
20
  ],
23
21
  userInput: [
24
22
  /\b(?:user|client)\s+input\b/i,
@@ -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_-]*' : '';
@@ -121,11 +133,13 @@ export function contentMentions(content, keywords) {
121
133
  export function detectChallengeCapabilities(spec, specContent) {
122
134
  const contractSource = stripReaderPrefix(spec, specContent);
123
135
  const source = contractSource.trim().length > 0 ? contractSource : spec.title;
124
- const contract = stripMetaAnalysisText(stripNonContractText(source));
125
- return Object.fromEntries(Object.entries(CAPABILITY_SIGNALS).map(([capability, patterns]) => [
136
+ const contract = stripNonContractText(stripMetaAnalysisText(source));
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()
@@ -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: [
@@ -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.44",
3
+ "version": "5.3.46",
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.44",
5
+ "version": "5.3.46",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",