@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 +19 -0
- package/dist/config/pii-patterns.json +31 -0
- package/dist/engine/human-summary.d.ts +8 -3
- package/dist/engine/human-summary.js +31 -12
- package/dist/engine/pii-detector/core.js +47 -4
- package/dist/engine/readiness-checker.js +21 -7
- package/dist/engine/scope-boundaries/contradiction-checker.js +1 -1
- package/dist/engine/spec-quality-scorer.js +7 -4
- 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/check-readiness.js +36 -2
- package/dist/tools/update-status/transition-guard.js +44 -4
- package/dist/types/privacy.d.ts +9 -0
- 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,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
|
-
/**
|
|
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) {
|
|
@@ -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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
/\
|
|
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
|
|
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
|
-
/**
|
|
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()
|
|
@@ -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.
|
|
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(
|
|
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 {
|
|
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: [
|
package/dist/types/privacy.d.ts
CHANGED
|
@@ -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
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",
|