@planu/cli 5.3.44 → 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 +10 -0
- package/dist/engine/human-summary.d.ts +8 -3
- package/dist/engine/human-summary.js +31 -12
- package/dist/tools/challenge-spec/challenge-report.d.ts +13 -2
- package/dist/tools/challenge-spec/challenge-report.js +79 -9
- package/dist/tools/challenge-spec/scenarios-utils.js +19 -5
- package/dist/tools/challenge-spec.js +63 -14
- package/dist/tools/update-status/transition-guard.js +44 -4
- package/dist/types/spec/core.d.ts +25 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
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
|
+
|
|
1
11
|
## [5.3.44] - 2026-08-24
|
|
2
12
|
|
|
3
13
|
### 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
|
-
/**
|
|
8
|
-
|
|
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
|
-
}[],
|
|
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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
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(
|
|
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) {
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
105
|
-
|
|
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:
|
|
113
|
-
findings
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
339
|
-
const criticalCount =
|
|
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 {
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
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
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.
|
|
5
|
+
"version": "5.3.45",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|