@planu/cli 4.3.17 → 4.3.19
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 +6 -0
- package/dist/engine/ai-assurance/index.d.ts +4 -0
- package/dist/engine/ai-assurance/index.js +3 -0
- package/dist/engine/ai-assurance/new-code-gate.d.ts +3 -0
- package/dist/engine/ai-assurance/new-code-gate.js +39 -0
- package/dist/engine/ai-assurance/spec-assurance.d.ts +3 -0
- package/dist/engine/ai-assurance/spec-assurance.js +138 -0
- package/dist/engine/pii-detector/core.js +23 -4
- package/dist/engine/quality-gates/gate-catalog.js +24 -0
- package/dist/engine/text-signal-boundaries.d.ts +6 -0
- package/dist/engine/text-signal-boundaries.js +62 -0
- package/dist/tools/challenge-spec/scenarios-data.js +25 -19
- package/dist/tools/challenge-spec/scenarios-security.js +29 -23
- package/dist/tools/challenge-spec/scenarios-utils.d.ts +1 -0
- package/dist/tools/challenge-spec/scenarios-utils.js +1 -0
- package/dist/tools/create-spec/spec-builder.js +16 -7
- package/dist/tools/create-spec.js +158 -36
- package/dist/tools/validate.js +43 -0
- package/dist/types/ai-assurance.d.ts +31 -0
- package/dist/types/ai-assurance.js +2 -0
- package/package.json +9 -9
- package/planu-native.json +29 -8
- package/planu-plugin.json +35 -7
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const SPEC_LOGIC_PATH_RE = /(?:src\/tools\/create-spec\/|src\/engine\/.*(?:assurance|validator|quality|elicitation)\/|src\/tools\/validate\.ts$)/;
|
|
2
|
+
const INCIDENTAL_DOMAIN_INFERENCE_RE = /\.(?:includes|match|test)\(\s*['"`](?:billing|payment|payments|auth|database|supabase|ui|frontend)['"`]\s*\)/i;
|
|
3
|
+
const STACK_ONLY_TRIGGER_RE = /\b(?:framework|stack|language|projectDna|dna|detectedStack)\b[^;\n]*(?:billing|payment|payments|auth|database|supabase|ui|frontend)/i;
|
|
4
|
+
export function evaluateNewCodeGate(files) {
|
|
5
|
+
const findings = files.flatMap((file) => scanFile(file));
|
|
6
|
+
return {
|
|
7
|
+
passed: findings.length === 0,
|
|
8
|
+
findings,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function scanFile(file) {
|
|
12
|
+
if (!SPEC_LOGIC_PATH_RE.test(file.path)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const findings = [];
|
|
16
|
+
const lines = file.content.split(/\r?\n/);
|
|
17
|
+
lines.forEach((line, index) => {
|
|
18
|
+
if (STACK_ONLY_TRIGGER_RE.test(line)) {
|
|
19
|
+
findings.push({
|
|
20
|
+
rule: 'new-code-stack-only-domain-trigger',
|
|
21
|
+
file: file.path,
|
|
22
|
+
line: index + 1,
|
|
23
|
+
message: 'New code appears to trigger product-domain requirements from detected stack metadata alone.',
|
|
24
|
+
evidence: line.trim(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
if (INCIDENTAL_DOMAIN_INFERENCE_RE.test(line)) {
|
|
28
|
+
findings.push({
|
|
29
|
+
rule: 'new-code-incidental-domain-inference',
|
|
30
|
+
file: file.path,
|
|
31
|
+
line: index + 1,
|
|
32
|
+
message: 'New code appears to infer product-domain requirements from incidental strings such as folder names.',
|
|
33
|
+
evidence: line.trim(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return findings;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=new-code-gate.js.map
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const GUARDED_DOMAINS = [
|
|
2
|
+
{
|
|
3
|
+
name: 'billing',
|
|
4
|
+
requestPattern: /\b(billing|payment|payments|paid|checkout|stripe|subscription|pricing)\b/i,
|
|
5
|
+
specPattern: /\b(billing|payment|payments|checkout|stripe|subscription|pricing)\b/i,
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
name: 'authentication',
|
|
9
|
+
requestPattern: /\b(auth|authentication|authorization|login|jwt|oauth|session)\b/i,
|
|
10
|
+
specPattern: /\b(auth|authentication|authorization|login|jwt|oauth|session|401|403)\b/i,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'database',
|
|
14
|
+
requestPattern: /\b(database|schema|rls|rpc|persistence|persist|query|migration)\b/i,
|
|
15
|
+
specPattern: /\b(database|schema|rls|rpc|persistence|persist|query|migration)\b/i,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'ui',
|
|
19
|
+
requestPattern: /\b(ui|frontend|component|screen|page|form|layout|style|styling)\b/i,
|
|
20
|
+
specPattern: /\b(ui|frontend|component|screen|page|form|layout|style|styling)\b/i,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const PATH_LIKE_DOMAIN_RE = /\b(?:stores|store|modules|features|apps|packages|src)\/([a-z][\w-]*)/gi;
|
|
24
|
+
const INVENTED_STATUS_RE = /\b(?:(?:endpoints?|api|routes?|response|status)\b[^.\n]*(?:401|403|404|500)|(?:401|403|404|500)\b[^.\n]*(?:endpoints?|api|routes?|response|status))\b/i;
|
|
25
|
+
const GENERIC_COVERAGE_RE = /\b(?:coverage|test coverage)\b/i;
|
|
26
|
+
export function evaluateSpecAssurance(input) {
|
|
27
|
+
const findings = [];
|
|
28
|
+
const request = input.originalRequest;
|
|
29
|
+
const spec = input.generatedSpec;
|
|
30
|
+
findings.push(...findUnrequestedDomains(request, spec));
|
|
31
|
+
findings.push(...findInventedEndpointRequirements(request, spec));
|
|
32
|
+
findings.push(...findGenericCoverageRequirement(request, spec));
|
|
33
|
+
findings.push(...findPathDerivedAssumptions(request, spec));
|
|
34
|
+
return {
|
|
35
|
+
passed: findings.every((finding) => finding.severity !== 'error'),
|
|
36
|
+
findings,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function findUnrequestedDomains(request, spec) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const domain of GUARDED_DOMAINS) {
|
|
42
|
+
if (isDomainRequested(domain, request) || !domain.specPattern.test(spec)) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
findings.push({
|
|
46
|
+
rule: `ai-spec-unrequested-${domain.name}`,
|
|
47
|
+
severity: 'error',
|
|
48
|
+
message: `Generated spec introduced ${domain.name} requirements that are not present in the user request.`,
|
|
49
|
+
evidence: firstMatch(spec, domain.specPattern),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
function findInventedEndpointRequirements(request, spec) {
|
|
55
|
+
if (/\b(api|endpoint|route|http|status|401|403|404|500)\b/i.test(request)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
if (!INVENTED_STATUS_RE.test(spec)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
rule: 'ai-spec-invented-endpoint-status',
|
|
64
|
+
severity: 'error',
|
|
65
|
+
message: 'Generated spec introduced endpoint/status-code behavior not present in the user request.',
|
|
66
|
+
evidence: firstMatch(spec, INVENTED_STATUS_RE),
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
function findGenericCoverageRequirement(request, spec) {
|
|
71
|
+
if (GENERIC_COVERAGE_RE.test(request) || !GENERIC_COVERAGE_RE.test(spec)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return [
|
|
75
|
+
{
|
|
76
|
+
rule: 'ai-spec-generic-coverage',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
message: 'Generated spec introduced a generic coverage requirement not tied to the requested behavior.',
|
|
79
|
+
evidence: firstMatch(spec, GENERIC_COVERAGE_RE),
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
function findPathDerivedAssumptions(request, spec) {
|
|
84
|
+
const findings = [];
|
|
85
|
+
const pathTokens = new Set();
|
|
86
|
+
for (const match of request.matchAll(PATH_LIKE_DOMAIN_RE)) {
|
|
87
|
+
const token = match[1]?.toLowerCase();
|
|
88
|
+
if (token) {
|
|
89
|
+
pathTokens.add(token);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (pathTokens.size === 0) {
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
for (const token of pathTokens) {
|
|
96
|
+
const domain = GUARDED_DOMAINS.find((candidate) => candidate.name === token);
|
|
97
|
+
if (!domain || isDomainRequested(domain, stripPathLikeSegments(request))) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!domain.specPattern.test(spec)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
findings.push({
|
|
104
|
+
rule: 'ai-spec-path-derived-assumption',
|
|
105
|
+
severity: 'error',
|
|
106
|
+
message: `Generated spec appears to derive ${token} requirements from an incidental repository path.`,
|
|
107
|
+
evidence: firstMatch(request, PATH_LIKE_DOMAIN_RE),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
function isDomainRequested(domain, request) {
|
|
113
|
+
const requestWithoutPaths = stripPathLikeSegments(request);
|
|
114
|
+
if (!domain.requestPattern.test(requestWithoutPaths)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const domainTerms = domain.specPattern.source
|
|
118
|
+
.replaceAll('\\b', '')
|
|
119
|
+
.replace(/[()?:]/g, '')
|
|
120
|
+
.split('|')
|
|
121
|
+
.filter((term) => /^[a-z]+$/i.test(term));
|
|
122
|
+
for (const term of domainTerms) {
|
|
123
|
+
const negated = new RegExp(`\\b${term}\\b[^.\\n]*(?:not in scope|out of scope|not required|not part of scope|negative example)`, 'i');
|
|
124
|
+
if (negated.test(requestWithoutPaths)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function stripPathLikeSegments(value) {
|
|
131
|
+
return value.replace(PATH_LIKE_DOMAIN_RE, ' ');
|
|
132
|
+
}
|
|
133
|
+
function firstMatch(value, pattern) {
|
|
134
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
135
|
+
const match = new RegExp(pattern.source, flags).exec(value);
|
|
136
|
+
return match?.[0]?.trim() ?? '';
|
|
137
|
+
}
|
|
138
|
+
//# sourceMappingURL=spec-assurance.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// pii-detector/core.ts — PII detection in spec text (SPEC-030)
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { stripNonContractText } from '../text-signal-boundaries.js';
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
export const patterns = require('../../config/pii-patterns.json');
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -33,6 +34,16 @@ function isBlacklistedContext(modelContext) {
|
|
|
33
34
|
const lower = modelContext.toLowerCase();
|
|
34
35
|
return patterns.modelContextBlacklist.some((b) => lower.includes(b.toLowerCase()));
|
|
35
36
|
}
|
|
37
|
+
function isWhitelistedContext(modelContext) {
|
|
38
|
+
const lower = modelContext.toLowerCase();
|
|
39
|
+
return patterns.modelContextWhitelist.some((w) => lower.includes(w.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
function hasFieldCue(contextWindow) {
|
|
42
|
+
return /\b(field|fields|column|columns|property|properties|attribute|schema|model|entity|table|class|interface)\b/i.test(contextWindow);
|
|
43
|
+
}
|
|
44
|
+
function hasCollectionCue(contextWindow) {
|
|
45
|
+
return /\b(collect|store|process|persist|save|record|provide|must have|includes?|contains?)\b/i.test(contextWindow);
|
|
46
|
+
}
|
|
36
47
|
/**
|
|
37
48
|
* Determines if a detected PII field is a GDPR Art. 9 special category.
|
|
38
49
|
*/
|
|
@@ -77,18 +88,26 @@ function inferModelContext(contextWindow) {
|
|
|
77
88
|
*/
|
|
78
89
|
function extractCandidates(text) {
|
|
79
90
|
const candidates = [];
|
|
80
|
-
const
|
|
91
|
+
const cleaned = stripNonContractText(text);
|
|
92
|
+
const lower = cleaned.toLowerCase();
|
|
81
93
|
/* v8 ignore start */
|
|
82
|
-
const words =
|
|
94
|
+
const words = cleaned.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) ?? [];
|
|
83
95
|
/* v8 ignore stop */
|
|
84
96
|
for (const word of words) {
|
|
85
97
|
if (word.length < 3) {
|
|
86
98
|
continue;
|
|
87
99
|
}
|
|
100
|
+
if (matchVocabulary(normalizeField(word)) === null) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
88
103
|
const wordIndex = lower.indexOf(word.toLowerCase());
|
|
89
|
-
const contextWindow =
|
|
104
|
+
const contextWindow = cleaned.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
|
|
90
105
|
const context = inferModelContext(contextWindow);
|
|
91
|
-
|
|
106
|
+
if (isWhitelistedContext(context) ||
|
|
107
|
+
(hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
|
|
108
|
+
(hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow))) {
|
|
109
|
+
candidates.push({ field: word, context });
|
|
110
|
+
}
|
|
92
111
|
}
|
|
93
112
|
return candidates;
|
|
94
113
|
}
|
|
@@ -210,6 +210,30 @@ export const GATE_CATALOG = [
|
|
|
210
210
|
riskLevels: ['low', 'medium', 'high'],
|
|
211
211
|
specTypes: ['all'],
|
|
212
212
|
},
|
|
213
|
+
{
|
|
214
|
+
id: 'arch-ai-spec-assurance',
|
|
215
|
+
category: 'architecture',
|
|
216
|
+
title: 'AI-generated specs are checked against original user intent before implementation',
|
|
217
|
+
stacks: ['typescript', 'generic'],
|
|
218
|
+
riskLevels: ['low', 'medium', 'high'],
|
|
219
|
+
specTypes: ['feature', 'bugfix', 'infra', 'all'],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: 'arch-new-code-domain-inference',
|
|
223
|
+
category: 'architecture',
|
|
224
|
+
title: 'New code does not infer product-domain requirements from incidental paths or stack presence',
|
|
225
|
+
stacks: ['typescript', 'generic'],
|
|
226
|
+
riskLevels: ['low', 'medium', 'high'],
|
|
227
|
+
specTypes: ['feature', 'bugfix', 'infra', 'all'],
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: 'arch-mcp-startup-assurance',
|
|
231
|
+
category: 'architecture',
|
|
232
|
+
title: 'MCP startup verifies official tools in first tools/list and excludes bootstrap-only tools',
|
|
233
|
+
stacks: ['typescript', 'generic'],
|
|
234
|
+
riskLevels: ['low', 'medium', 'high'],
|
|
235
|
+
specTypes: ['feature', 'bugfix', 'infra', 'all'],
|
|
236
|
+
},
|
|
213
237
|
{
|
|
214
238
|
id: 'arch-single-responsibility',
|
|
215
239
|
category: 'architecture',
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Remove fenced code and quoted examples before keyword-driven inference. */
|
|
2
|
+
export declare function stripNonContractText(text: string): string;
|
|
3
|
+
/** True only when a regex match appears in contract prose and is not negated/excluded. */
|
|
4
|
+
export declare function hasAffirmedMatch(text: string, pattern: RegExp): boolean;
|
|
5
|
+
export declare function hasAnyAffirmedMatch(text: string, patterns: RegExp[]): boolean;
|
|
6
|
+
//# sourceMappingURL=text-signal-boundaries.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// engine/text-signal-boundaries.ts — Boundary-aware keyword matching for advisory signals.
|
|
2
|
+
const NEGATION_WINDOW_WORDS = 5;
|
|
3
|
+
const NEGATION_TERMS = new Set([
|
|
4
|
+
'no',
|
|
5
|
+
'not',
|
|
6
|
+
'without',
|
|
7
|
+
'avoid',
|
|
8
|
+
'exclude',
|
|
9
|
+
'excluding',
|
|
10
|
+
'except',
|
|
11
|
+
'skip',
|
|
12
|
+
'remove',
|
|
13
|
+
'disable',
|
|
14
|
+
'disallow',
|
|
15
|
+
'never',
|
|
16
|
+
]);
|
|
17
|
+
const OUT_OF_SCOPE_RE = /\b(?:out\s+of\s+scope|not\s+in\s+scope|no\s+scope|do\s+not|don't)\b/i;
|
|
18
|
+
/** Remove fenced code and quoted examples before keyword-driven inference. */
|
|
19
|
+
export function stripNonContractText(text) {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
22
|
+
.replace(/`[^`\n]+`/g, ' ')
|
|
23
|
+
.replace(/"[^"\n]+"/g, ' ')
|
|
24
|
+
.replace(/'[^'\n]+'/g, ' ');
|
|
25
|
+
}
|
|
26
|
+
function wordTokensBefore(text, index) {
|
|
27
|
+
return text
|
|
28
|
+
.slice(Math.max(0, index - 160), index)
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.split(/[^a-z0-9]+/i)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
function isNegatedAt(text, index) {
|
|
34
|
+
const before = wordTokensBefore(text, index);
|
|
35
|
+
const recent = before.slice(-NEGATION_WINDOW_WORDS);
|
|
36
|
+
return recent.some((word) => NEGATION_TERMS.has(word));
|
|
37
|
+
}
|
|
38
|
+
function isOutOfScopeLine(line, indexInLine) {
|
|
39
|
+
return OUT_OF_SCOPE_RE.test(line.slice(0, Math.max(indexInLine, 0) + 80));
|
|
40
|
+
}
|
|
41
|
+
/** True only when a regex match appears in contract prose and is not negated/excluded. */
|
|
42
|
+
export function hasAffirmedMatch(text, pattern) {
|
|
43
|
+
const cleaned = stripNonContractText(text);
|
|
44
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
45
|
+
const re = new RegExp(pattern.source, flags);
|
|
46
|
+
let match = re.exec(cleaned);
|
|
47
|
+
while (match) {
|
|
48
|
+
const index = match.index;
|
|
49
|
+
const lineStart = cleaned.lastIndexOf('\n', index) + 1;
|
|
50
|
+
const lineEnd = cleaned.indexOf('\n', index);
|
|
51
|
+
const line = cleaned.slice(lineStart, lineEnd === -1 ? cleaned.length : lineEnd);
|
|
52
|
+
if (!isNegatedAt(cleaned, index) && !isOutOfScopeLine(line, index - lineStart)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
match = re.exec(cleaned);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
export function hasAnyAffirmedMatch(text, patterns) {
|
|
60
|
+
return patterns.some((pattern) => hasAffirmedMatch(text, pattern));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=text-signal-boundaries.js.map
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// tools/challenge-spec/scenarios-data.ts — Data consistency and partial write scenarios
|
|
2
|
-
import { contentMentions } from './scenarios-utils.js';
|
|
2
|
+
import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
|
|
3
|
+
const WRITE_SIGNALS = [
|
|
4
|
+
/\b(transaction|persist|database write|save record|create record|update record|delete record)\b/i,
|
|
5
|
+
/\b(order|checkout|submit form|mutation|multi-step write)\b/i,
|
|
6
|
+
];
|
|
7
|
+
const CACHE_SIGNALS = [/\b(cache|real-time|realtime|concurrent|websocket|stale data)\b/i];
|
|
8
|
+
const DUPLICATE_OPERATION_SIGNALS = [
|
|
9
|
+
/\b(submit|form|checkout|order|payment|create record|mutation|button click)\b/i,
|
|
10
|
+
];
|
|
3
11
|
export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
4
12
|
const scenarios = [];
|
|
5
13
|
const lower = content.toLowerCase();
|
|
6
14
|
// Partial writes
|
|
7
|
-
if (
|
|
8
|
-
lower.includes('update') ||
|
|
9
|
-
lower.includes('create') ||
|
|
10
|
-
lower.includes('modify') ||
|
|
11
|
-
lower.includes('save')) {
|
|
15
|
+
if (hasAnyAffirmedMatch(content, WRITE_SIGNALS)) {
|
|
12
16
|
scenarios.push({
|
|
13
17
|
scenario: 'Partial write: server crashes mid-transaction',
|
|
14
18
|
probability: 'low',
|
|
@@ -22,7 +26,7 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
|
22
26
|
});
|
|
23
27
|
}
|
|
24
28
|
// Stale data
|
|
25
|
-
if (
|
|
29
|
+
if (hasAnyAffirmedMatch(content, CACHE_SIGNALS)) {
|
|
26
30
|
scenarios.push({
|
|
27
31
|
scenario: 'Stale data served from cache after underlying data changes',
|
|
28
32
|
probability: 'high',
|
|
@@ -35,18 +39,20 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
|
35
39
|
userExperience: 'Show "last updated" timestamp. Offer manual refresh option.',
|
|
36
40
|
});
|
|
37
41
|
}
|
|
38
|
-
// Duplicate operations
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
// Duplicate operations only apply to explicit user-triggered mutations.
|
|
43
|
+
if (hasAnyAffirmedMatch(content, DUPLICATE_OPERATION_SIGNALS)) {
|
|
44
|
+
scenarios.push({
|
|
45
|
+
scenario: 'User double-clicks submit, causing duplicate records',
|
|
46
|
+
probability: 'high',
|
|
47
|
+
impact: 'medium',
|
|
48
|
+
currentHandling: contentMentions(lower, ['idempoten', 'debounce', 'disable', 'lock'])
|
|
49
|
+
? 'Some prevention mentioned'
|
|
50
|
+
: 'Not addressed',
|
|
51
|
+
requiredHandling: 'Implement idempotency keys for mutations. Disable submit button on click. Use unique constraints in DB.',
|
|
52
|
+
dataConsistency: 'No duplicate records from repeated submissions.',
|
|
53
|
+
userExperience: 'Button disabled during submission. Clear success/error feedback.',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
50
56
|
return scenarios;
|
|
51
57
|
}
|
|
52
58
|
//# sourceMappingURL=scenarios-data.js.map
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// tools/challenge-spec/scenarios-security.ts — Authentication, injection, and CSRF scenarios
|
|
2
|
-
import { contentMentions } from './scenarios-utils.js';
|
|
2
|
+
import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
|
|
3
|
+
const AUTH_SIGNALS = [
|
|
4
|
+
/\b(auth|authentication|login|session|token|jwt|oauth|permission|role|rbac)\b/i,
|
|
5
|
+
];
|
|
6
|
+
const INPUT_SIGNALS = [
|
|
7
|
+
/\b(user input|form|query param|search input|request body|payload|html input)\b/i,
|
|
8
|
+
];
|
|
9
|
+
const STATE_CHANGING_WEB_SIGNALS = [
|
|
10
|
+
/\b(form submit|post request|put request|delete request|mutation|state-changing|cookie auth)\b/i,
|
|
11
|
+
];
|
|
3
12
|
export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
4
13
|
const scenarios = [];
|
|
5
14
|
const lower = content.toLowerCase();
|
|
15
|
+
const text = content;
|
|
6
16
|
// Authentication bypass
|
|
7
|
-
if (
|
|
8
|
-
lower.includes('login') ||
|
|
9
|
-
lower.includes('user') ||
|
|
10
|
-
lower.includes('permission') ||
|
|
11
|
-
lower.includes('role')) {
|
|
17
|
+
if (hasAnyAffirmedMatch(text, AUTH_SIGNALS)) {
|
|
12
18
|
scenarios.push({
|
|
13
19
|
scenario: 'Unauthorized access attempt / authentication bypass',
|
|
14
20
|
probability: 'high',
|
|
@@ -22,11 +28,7 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
|
22
28
|
});
|
|
23
29
|
}
|
|
24
30
|
// Injection attacks
|
|
25
|
-
if (
|
|
26
|
-
lower.includes('form') ||
|
|
27
|
-
lower.includes('query') ||
|
|
28
|
-
lower.includes('search') ||
|
|
29
|
-
lower.includes('user')) {
|
|
31
|
+
if (hasAnyAffirmedMatch(text, INPUT_SIGNALS)) {
|
|
30
32
|
scenarios.push({
|
|
31
33
|
scenario: 'SQL injection / XSS / command injection via user input',
|
|
32
34
|
probability: 'high',
|
|
@@ -39,18 +41,22 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
|
39
41
|
userExperience: 'Transparent to user. Invalid input is silently sanitized.',
|
|
40
42
|
});
|
|
41
43
|
}
|
|
42
|
-
// CSRF / rate limiting
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
: '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
// CSRF / rate limiting only applies when the spec explicitly involves auth or
|
|
45
|
+
// state-changing web input. Tooling/spec-cleanup tasks should not get this as noise.
|
|
46
|
+
if (hasAnyAffirmedMatch(text, AUTH_SIGNALS) ||
|
|
47
|
+
hasAnyAffirmedMatch(text, STATE_CHANGING_WEB_SIGNALS)) {
|
|
48
|
+
scenarios.push({
|
|
49
|
+
scenario: 'CSRF attack or brute-force attempt',
|
|
50
|
+
probability: 'medium',
|
|
51
|
+
impact: 'high',
|
|
52
|
+
currentHandling: contentMentions(lower, ['csrf', 'rate limit', 'throttle', 'captcha'])
|
|
53
|
+
? 'Some protection mentioned'
|
|
54
|
+
: 'Not addressed',
|
|
55
|
+
requiredHandling: 'Implement CSRF tokens for state-changing operations. Rate-limit sensitive endpoints. Add CAPTCHA for auth endpoints.',
|
|
56
|
+
dataConsistency: 'Prevent mass data modifications from automated attacks.',
|
|
57
|
+
userExperience: 'Show rate limit message. Require CAPTCHA after N failed attempts.',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
54
60
|
return scenarios;
|
|
55
61
|
}
|
|
56
62
|
//# sourceMappingURL=scenarios-security.js.map
|
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
* lowerContent is expected to already be lowercased by the caller.
|
|
4
4
|
*/
|
|
5
5
|
export declare function contentMentions(lowerContent: string, keywords: string[]): boolean;
|
|
6
|
+
export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
|
|
6
7
|
//# sourceMappingURL=scenarios-utils.d.ts.map
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
export function contentMentions(lowerContent, keywords) {
|
|
7
7
|
return keywords.some((kw) => lowerContent.includes(kw.toLowerCase()));
|
|
8
8
|
}
|
|
9
|
+
export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
|
|
9
10
|
//# sourceMappingURL=scenarios-utils.js.map
|
|
@@ -182,6 +182,15 @@ const EN_STOPWORDS = new Set([
|
|
|
182
182
|
'it',
|
|
183
183
|
'be',
|
|
184
184
|
'as',
|
|
185
|
+
'spec',
|
|
186
|
+
'create',
|
|
187
|
+
'workflow',
|
|
188
|
+
'files',
|
|
189
|
+
'file',
|
|
190
|
+
'example',
|
|
191
|
+
'avoid',
|
|
192
|
+
'planu',
|
|
193
|
+
'feature',
|
|
185
194
|
]);
|
|
186
195
|
/** Infer meaningful tags from title and description when no tags were provided. */
|
|
187
196
|
export function inferTagsFromContent(title, description, type) {
|
|
@@ -213,13 +222,13 @@ export function inferTagsFromContent(title, description, type) {
|
|
|
213
222
|
}
|
|
214
223
|
return tags;
|
|
215
224
|
}
|
|
216
|
-
/** Build the final tags array
|
|
217
|
-
function buildTags(tags, feature,
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return result;
|
|
225
|
+
/** Build the final tags array from explicit user/tool input only. */
|
|
226
|
+
function buildTags(tags, feature, _title, _description, _type) {
|
|
227
|
+
const explicitTags = tags
|
|
228
|
+
.map((tag) => slugify(tag))
|
|
229
|
+
.filter((tag) => tag.length > 0 && !EN_STOPWORDS.has(tag));
|
|
230
|
+
const result = feature ? [...new Set([...explicitTags, slugify(feature)])] : explicitTags;
|
|
231
|
+
return [...new Set(result)];
|
|
223
232
|
}
|
|
224
233
|
/* v8 ignore start -- filesystem scanning is best-effort, tested via integration */
|
|
225
234
|
/** Scan filesystem for SPEC-XXX directories to find max ID globally. */
|
|
@@ -14,7 +14,7 @@ import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostC
|
|
|
14
14
|
import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
|
|
15
15
|
import { compactObj } from '../engine/compact-obj.js';
|
|
16
16
|
import { buildCreateSpecSummary } from '../engine/human-summary.js';
|
|
17
|
-
import { runAutoPostCreatePipeline
|
|
17
|
+
import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
|
|
18
18
|
import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
|
|
19
19
|
import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
|
|
20
20
|
import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
|
|
@@ -133,6 +133,16 @@ function runSimplicityCheck(text, hours) {
|
|
|
133
133
|
return null;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
function makeAdvisorySignal(args) {
|
|
137
|
+
return { ...args, persistable: false };
|
|
138
|
+
}
|
|
139
|
+
function buildAdvisoryCompat() {
|
|
140
|
+
return {
|
|
141
|
+
legacyTopLevelAdvisoryFields: true,
|
|
142
|
+
deprecatedAfter: 'vNext+2',
|
|
143
|
+
canonicalField: 'advisorySignals',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
136
146
|
/** SPEC-612: resolve outOfScope items (provided by user, or auto-suggested from description). */
|
|
137
147
|
function resolveOutOfScope(description, provided) {
|
|
138
148
|
if (provided !== undefined && provided.length > 0) {
|
|
@@ -181,18 +191,6 @@ async function runComplexityAdvice(projectId, currentSpecId, tags, targetCriteri
|
|
|
181
191
|
return null;
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
|
-
function formatSimplicityLines(r) {
|
|
185
|
-
const icon = r.recommendation === 'simplify' ? '🔴' : r.recommendation === 'review' ? '🟡' : '🟢';
|
|
186
|
-
const signalNames = r.signals.map((s) => s.type).join(', ');
|
|
187
|
-
const lines = [
|
|
188
|
-
'',
|
|
189
|
-
`${icon} **Simplicity**: score ${String(r.score)}/100 — ${signalNames !== '' ? signalNames : 'no signals'}`,
|
|
190
|
-
];
|
|
191
|
-
if (r.recommendation !== 'approve' && r.simpleAlternative !== null) {
|
|
192
|
-
lines.push(` Suggestion: ${r.simpleAlternative}`);
|
|
193
|
-
}
|
|
194
|
-
return lines;
|
|
195
|
-
}
|
|
196
194
|
/** SPEC-783: Synthesize agent team findings into unified spec.md ## Technical section. */
|
|
197
195
|
async function handleAgentTeamSynthesis(specId, projectPath, findings) {
|
|
198
196
|
try {
|
|
@@ -723,9 +721,21 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
723
721
|
/* v8 ignore next -- requires real git repo */
|
|
724
722
|
...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
|
|
725
723
|
};
|
|
724
|
+
const advisorySignals = [];
|
|
726
725
|
const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
|
|
727
726
|
if (splitResult) {
|
|
728
727
|
result.splitSuggestion = splitResult;
|
|
728
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
729
|
+
key: 'split-suggestion',
|
|
730
|
+
kind: 'complexity',
|
|
731
|
+
message: 'Spec may benefit from splitting.',
|
|
732
|
+
source: 'heuristic',
|
|
733
|
+
evidence: ['spec-splitter heuristic'],
|
|
734
|
+
confidence: 0.5,
|
|
735
|
+
surface: 'structuredContent',
|
|
736
|
+
value: splitResult,
|
|
737
|
+
deprecatedAlias: 'splitSuggestion',
|
|
738
|
+
}));
|
|
729
739
|
}
|
|
730
740
|
// Dispatch hook event (fire-and-forget)
|
|
731
741
|
fireSpecCreatedHook(projectId, spec, params.projectPath ?? '');
|
|
@@ -780,31 +790,97 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
780
790
|
const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
|
|
781
791
|
if (qualityValue !== null) {
|
|
782
792
|
result.qualityScore = qualityValue;
|
|
793
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
794
|
+
key: 'quality-score',
|
|
795
|
+
kind: 'quality',
|
|
796
|
+
message: `Quality score ${String(qualityValue.total)}/100 (${qualityValue.grade}).`,
|
|
797
|
+
source: 'validator',
|
|
798
|
+
evidence: ['spec-quality-scorer'],
|
|
799
|
+
confidence: 0.7,
|
|
800
|
+
surface: 'structuredContent',
|
|
801
|
+
value: qualityValue,
|
|
802
|
+
deprecatedAlias: 'qualityScore',
|
|
803
|
+
}));
|
|
783
804
|
}
|
|
784
805
|
// SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
|
|
785
806
|
const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
|
|
786
807
|
if (simplicityResult) {
|
|
787
808
|
result.simplicityCheck = simplicityResult;
|
|
809
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
810
|
+
key: 'simplicity-check',
|
|
811
|
+
kind: 'simplicity',
|
|
812
|
+
message: `Simplicity recommendation: ${simplicityResult.recommendation}.`,
|
|
813
|
+
source: 'heuristic',
|
|
814
|
+
evidence: simplicityResult.signals.map((signal) => signal.type),
|
|
815
|
+
confidence: 0.55,
|
|
816
|
+
surface: 'structuredContent',
|
|
817
|
+
value: simplicityResult,
|
|
818
|
+
deprecatedAlias: 'simplicityCheck',
|
|
819
|
+
}));
|
|
788
820
|
}
|
|
789
821
|
// SPEC-514: duplicate results
|
|
790
822
|
const possibleDuplicates = unwrapBudget(duplicatesResult, [], budgetWarnings);
|
|
791
823
|
if (possibleDuplicates.length > 0) {
|
|
792
824
|
result.possibleDuplicates = possibleDuplicates;
|
|
825
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
826
|
+
key: 'possible-duplicates',
|
|
827
|
+
kind: 'duplicate',
|
|
828
|
+
message: `${String(possibleDuplicates.length)} possible duplicate spec(s) detected.`,
|
|
829
|
+
source: 'heuristic',
|
|
830
|
+
evidence: possibleDuplicates.map((dup) => dup.specId),
|
|
831
|
+
confidence: 0.5,
|
|
832
|
+
surface: 'structuredContent',
|
|
833
|
+
value: possibleDuplicates,
|
|
834
|
+
deprecatedAlias: 'possibleDuplicates',
|
|
835
|
+
}));
|
|
793
836
|
}
|
|
794
837
|
// SPEC-222 Trigger 2: Auto-challenge hint for high-risk specs
|
|
795
838
|
if (spec.risk === 'high' || spec.difficulty >= 4) {
|
|
796
839
|
result.riskWarning = HIGH_RISK_WARNING;
|
|
840
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
841
|
+
key: 'risk-warning',
|
|
842
|
+
kind: 'challenge',
|
|
843
|
+
message: 'High-risk heuristic warning generated.',
|
|
844
|
+
source: 'heuristic',
|
|
845
|
+
evidence: [`risk:${spec.risk}`, `difficulty:${String(spec.difficulty)}`],
|
|
846
|
+
confidence: 0.6,
|
|
847
|
+
surface: 'structuredContent',
|
|
848
|
+
value: HIGH_RISK_WARNING,
|
|
849
|
+
deprecatedAlias: 'riskWarning',
|
|
850
|
+
}));
|
|
797
851
|
}
|
|
798
852
|
// SPEC-614 AC4: complexity advice
|
|
799
853
|
const cAdvice = unwrapBudget(complexityResult, null, budgetWarnings);
|
|
800
854
|
if (cAdvice) {
|
|
801
855
|
result.complexityAdvice = cAdvice;
|
|
856
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
857
|
+
key: 'complexity-advice',
|
|
858
|
+
kind: 'complexity',
|
|
859
|
+
message: cAdvice.reasoning,
|
|
860
|
+
source: 'history',
|
|
861
|
+
evidence: [`similarSpecs:${String(cAdvice.similarSpecsCount)}`],
|
|
862
|
+
confidence: 0.6,
|
|
863
|
+
surface: 'structuredContent',
|
|
864
|
+
value: cAdvice,
|
|
865
|
+
deprecatedAlias: 'complexityAdvice',
|
|
866
|
+
}));
|
|
802
867
|
}
|
|
803
868
|
// SPEC-615: prior decisions
|
|
804
869
|
const priorLinks = unwrapBudget(priorLinksResult, [], budgetWarnings);
|
|
805
870
|
if (priorLinks.length > 0) {
|
|
806
871
|
result.priorDecisions = priorLinks.map((l) => l.decisionId);
|
|
807
872
|
spec.priorDecisions = result.priorDecisions;
|
|
873
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
874
|
+
key: 'prior-decisions',
|
|
875
|
+
kind: 'prior-decision',
|
|
876
|
+
message: `${String(priorLinks.length)} prior decision link(s) found.`,
|
|
877
|
+
source: 'history',
|
|
878
|
+
evidence: priorLinks.map((link) => link.decisionId),
|
|
879
|
+
confidence: 0.65,
|
|
880
|
+
surface: 'structuredContent',
|
|
881
|
+
value: result.priorDecisions,
|
|
882
|
+
deprecatedAlias: 'priorDecisions',
|
|
883
|
+
}));
|
|
808
884
|
}
|
|
809
885
|
// Post-creation suggestions
|
|
810
886
|
const nextSteps = unwrapBudget(nextStepsResult, [], budgetWarnings);
|
|
@@ -871,30 +947,13 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
871
947
|
if (contradictionHint) {
|
|
872
948
|
lines.push('', `⚠️ ${contradictionHint}`);
|
|
873
949
|
}
|
|
874
|
-
if (possibleDuplicates.length > 0
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
if (result.riskWarning) {
|
|
882
|
-
lines.push('', `⚠️ ${result.riskWarning}`);
|
|
883
|
-
}
|
|
884
|
-
if (splitResult) {
|
|
885
|
-
lines.push('', `💡 ${splitResult.formatted ?? 'Consider splitting this spec.'}`);
|
|
886
|
-
}
|
|
887
|
-
// SPEC-492: Quality score output
|
|
888
|
-
if (result.qualityScore) {
|
|
889
|
-
const qs = result.qualityScore;
|
|
890
|
-
lines.push('', `📊 **Quality score**: ${String(qs.total)}/100 (${qs.grade})`);
|
|
891
|
-
}
|
|
892
|
-
// SPEC-485: Simplicity autopilot output
|
|
893
|
-
if (simplicityResult) {
|
|
894
|
-
lines.push(...formatSimplicityLines(simplicityResult));
|
|
950
|
+
if (possibleDuplicates.length > 0 ||
|
|
951
|
+
result.riskWarning ||
|
|
952
|
+
splitResult ||
|
|
953
|
+
result.qualityScore ||
|
|
954
|
+
simplicityResult) {
|
|
955
|
+
lines.push('', 'Advisory signals were computed and are available in structuredContent.advisorySignals.');
|
|
895
956
|
}
|
|
896
|
-
// Append auto-pipeline output (SPEC-445)
|
|
897
|
-
lines.push(...formatPipelineLines(pipelineResult));
|
|
898
957
|
const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
|
|
899
958
|
const markdownText = allNextSteps.length > 0
|
|
900
959
|
? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
|
|
@@ -904,15 +963,54 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
904
963
|
// SPEC-612: Record outOfScope auto-suggestion in autopilot summary
|
|
905
964
|
if (outOfScopeSuggestionMsg !== null) {
|
|
906
965
|
collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
|
|
966
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
967
|
+
key: 'out-of-scope-suggestions',
|
|
968
|
+
kind: 'out-of-scope',
|
|
969
|
+
message: outOfScopeSuggestionMsg,
|
|
970
|
+
source: 'heuristic',
|
|
971
|
+
evidence: ['scope-boundaries suggester'],
|
|
972
|
+
confidence: 0.45,
|
|
973
|
+
surface: 'structuredContent',
|
|
974
|
+
value: spec.outOfScope ?? [],
|
|
975
|
+
}));
|
|
907
976
|
}
|
|
908
977
|
if (autopilot.detectedPatterns.length > 0) {
|
|
909
978
|
collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
|
|
979
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
980
|
+
key: 'detected-patterns',
|
|
981
|
+
kind: 'domain',
|
|
982
|
+
message: `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`,
|
|
983
|
+
source: 'heuristic',
|
|
984
|
+
evidence: autopilot.detectedPatterns,
|
|
985
|
+
confidence: 0.5,
|
|
986
|
+
surface: 'structuredContent',
|
|
987
|
+
value: autopilot.detectedPatterns,
|
|
988
|
+
}));
|
|
910
989
|
}
|
|
911
990
|
const totalSuggestedFiles = autopilot.suggestedFiles.create.length +
|
|
912
991
|
autopilot.suggestedFiles.modify.length +
|
|
913
992
|
autopilot.suggestedFiles.test.length;
|
|
914
993
|
if (totalSuggestedFiles > 0) {
|
|
915
994
|
collector.pushOk('file-analysis', `Suggested ${String(totalSuggestedFiles)} files (${String(autopilot.suggestedFiles.create.length)} create, ${String(autopilot.suggestedFiles.modify.length)} modify, ${String(autopilot.suggestedFiles.test.length)} test)`);
|
|
995
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
996
|
+
key: 'suggested-files',
|
|
997
|
+
kind: 'file',
|
|
998
|
+
message: `${String(totalSuggestedFiles)} possible related file(s) detected.`,
|
|
999
|
+
source: 'heuristic',
|
|
1000
|
+
evidence: [
|
|
1001
|
+
...autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1002
|
+
...autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1003
|
+
...autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1004
|
+
],
|
|
1005
|
+
confidence: 0.45,
|
|
1006
|
+
surface: 'structuredContent',
|
|
1007
|
+
value: {
|
|
1008
|
+
create: autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1009
|
+
modify: autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1010
|
+
test: autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1011
|
+
},
|
|
1012
|
+
deprecatedAlias: 'autopilotSummary.suggestedFiles',
|
|
1013
|
+
}));
|
|
916
1014
|
}
|
|
917
1015
|
if (filteredCriteria.length > 0) {
|
|
918
1016
|
collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
|
|
@@ -926,15 +1024,37 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
926
1024
|
? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
|
|
927
1025
|
: '';
|
|
928
1026
|
collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
|
|
1027
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1028
|
+
key: 'readiness-score',
|
|
1029
|
+
kind: 'readiness',
|
|
1030
|
+
message: `Readiness score: ${String(pipelineResult.readinessScore)}/100.`,
|
|
1031
|
+
source: 'validator',
|
|
1032
|
+
evidence: ['auto post-creation pipeline'],
|
|
1033
|
+
confidence: 0.6,
|
|
1034
|
+
surface: 'structuredContent',
|
|
1035
|
+
value: pipelineResult.readinessScore,
|
|
1036
|
+
}));
|
|
929
1037
|
}
|
|
930
1038
|
else if (spec.difficulty >= 3) {
|
|
931
1039
|
// SPEC-629: always surface the Opus hint for high-difficulty specs
|
|
932
1040
|
collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
|
|
1041
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1042
|
+
key: 'model-recommendation',
|
|
1043
|
+
kind: 'model',
|
|
1044
|
+
message: 'High-difficulty spec may benefit from a stronger model for review.',
|
|
1045
|
+
source: 'heuristic',
|
|
1046
|
+
evidence: [`difficulty:${String(spec.difficulty)}`],
|
|
1047
|
+
confidence: 0.4,
|
|
1048
|
+
surface: 'structuredContent',
|
|
1049
|
+
value: { recommendedTier: 'max' },
|
|
1050
|
+
}));
|
|
933
1051
|
}
|
|
934
1052
|
if (calibrationEnrichment.calibrationNote) {
|
|
935
1053
|
collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
|
|
936
1054
|
}
|
|
937
1055
|
const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
|
|
1056
|
+
result.advisorySignals = advisorySignals;
|
|
1057
|
+
result.compat = buildAdvisoryCompat();
|
|
938
1058
|
const compactResult = compactObj(result);
|
|
939
1059
|
// SPEC-722: Issue a planner token for this spec creation.
|
|
940
1060
|
// Best-effort — token failure never blocks spec creation.
|
|
@@ -965,6 +1085,8 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
965
1085
|
: undefined;
|
|
966
1086
|
const baseResult = toolResult(markdownText, {
|
|
967
1087
|
...compactResult,
|
|
1088
|
+
advisorySignals,
|
|
1089
|
+
compat: buildAdvisoryCompat(),
|
|
968
1090
|
humanSummary,
|
|
969
1091
|
...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
|
|
970
1092
|
...(suggestedFilesPayload !== undefined
|
package/dist/tools/validate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// tools/validate.ts — Compare spec vs implementation (SPEC-595: elicitation on failures)
|
|
2
2
|
// Uses the validator engine to check coverage, missing items, quality issues.
|
|
3
3
|
import { execSync, execFileSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
4
5
|
import { elicitOrFallback, buildEnumSchema } from '../engine/elicitation/elicit-helper.js';
|
|
5
6
|
import { compactObj } from '../engine/compact-obj.js';
|
|
6
7
|
import { resolveProjectId, missingProjectIdError } from './resolve-project-id.js';
|
|
@@ -15,6 +16,7 @@ import { parseConventions, scanConventions } from '../engine/convention-scanner/
|
|
|
15
16
|
import { validateScopeCompliance } from '../engine/scope-boundaries/index.js';
|
|
16
17
|
import { compareWithBaseline } from '../storage/convention-baseline.js';
|
|
17
18
|
import { writeImplementationReviewReport } from '../engine/validator/validation-report-writer.js';
|
|
19
|
+
import { evaluateNewCodeGate } from '../engine/ai-assurance/index.js';
|
|
18
20
|
// Re-export for external use (SPEC-018)
|
|
19
21
|
export { validateContractCompliance };
|
|
20
22
|
/** Build the fallback interactiveQuestion for validation failures. */
|
|
@@ -131,6 +133,7 @@ export async function handleValidate(args, server) {
|
|
|
131
133
|
}
|
|
132
134
|
// 7. Lint check (best-effort — non-blocking)
|
|
133
135
|
const lintCheck = runLintCheck(projectPath, knowledge.lintCommand ?? null);
|
|
136
|
+
const assuranceGates = runAssuranceGates(projectPath);
|
|
134
137
|
// SPEC-1050: Generate a mandatory implementation-review artifact.
|
|
135
138
|
const validationReport = await writeImplementationReviewReport({
|
|
136
139
|
projectId,
|
|
@@ -215,6 +218,7 @@ export async function handleValidate(args, server) {
|
|
|
215
218
|
})),
|
|
216
219
|
},
|
|
217
220
|
lintCheck,
|
|
221
|
+
assuranceGates,
|
|
218
222
|
validationReport,
|
|
219
223
|
};
|
|
220
224
|
// SPEC-612: Scope boundary validation — warn if impl files match outOfScope items
|
|
@@ -259,6 +263,9 @@ export async function handleValidate(args, server) {
|
|
|
259
263
|
if (!lintCheck.passed) {
|
|
260
264
|
suggestions.push(`Lint check failed: ${lintCheck.issueCount} issue(s) found via \`${lintCheck.command}\`. Fix before marking as done.`);
|
|
261
265
|
}
|
|
266
|
+
if (!assuranceGates.newCode.passed) {
|
|
267
|
+
suggestions.push(`Assurance gate failed: ${String(assuranceGates.newCode.findings.length)} new-code domain inference issue(s) found.`);
|
|
268
|
+
}
|
|
262
269
|
const outputWithSuggestions = compactObj({
|
|
263
270
|
...output,
|
|
264
271
|
suggestions: suggestions.length > 0 ? suggestions : undefined,
|
|
@@ -417,6 +424,42 @@ function commandOutput(err) {
|
|
|
417
424
|
.join('\n')
|
|
418
425
|
.trim();
|
|
419
426
|
}
|
|
427
|
+
function runAssuranceGates(projectPath) {
|
|
428
|
+
return {
|
|
429
|
+
newCode: evaluateNewCodeGate(readChangedSpecLogicFiles(projectPath)),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function readChangedSpecLogicFiles(projectPath) {
|
|
433
|
+
let changedFiles;
|
|
434
|
+
try {
|
|
435
|
+
const output = execFileSync('git', [
|
|
436
|
+
'-C',
|
|
437
|
+
projectPath,
|
|
438
|
+
'diff',
|
|
439
|
+
'--name-only',
|
|
440
|
+
'HEAD',
|
|
441
|
+
'--',
|
|
442
|
+
'src/tools/create-spec',
|
|
443
|
+
'src/tools/validate.ts',
|
|
444
|
+
'src/engine',
|
|
445
|
+
], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
446
|
+
changedFiles = output
|
|
447
|
+
.split('\n')
|
|
448
|
+
.map((line) => line.trim())
|
|
449
|
+
.filter((line) => line.endsWith('.ts'));
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
return changedFiles.flatMap((path) => {
|
|
455
|
+
try {
|
|
456
|
+
return [{ path, content: readFileSync(`${projectPath}/${path}`, 'utf-8') }];
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
420
463
|
function runLintCheck(projectPath, lintCommand) {
|
|
421
464
|
if (lintCommand !== null && !isSafeCommand(lintCommand)) {
|
|
422
465
|
console.warn(`[Planu] validate: lintCommand contains unsafe characters — skipping execution`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type SpecAssuranceSeverity = 'error' | 'warning';
|
|
2
|
+
export interface SpecAssuranceFinding {
|
|
3
|
+
rule: string;
|
|
4
|
+
severity: SpecAssuranceSeverity;
|
|
5
|
+
message: string;
|
|
6
|
+
evidence: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SpecAssuranceResult {
|
|
9
|
+
passed: boolean;
|
|
10
|
+
findings: SpecAssuranceFinding[];
|
|
11
|
+
}
|
|
12
|
+
export interface SpecAssuranceInput {
|
|
13
|
+
originalRequest: string;
|
|
14
|
+
generatedSpec: string;
|
|
15
|
+
}
|
|
16
|
+
export interface NewCodeFile {
|
|
17
|
+
path: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
export interface NewCodeGateFinding {
|
|
21
|
+
rule: string;
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
message: string;
|
|
25
|
+
evidence: string;
|
|
26
|
+
}
|
|
27
|
+
export interface NewCodeGateResult {
|
|
28
|
+
passed: boolean;
|
|
29
|
+
findings: NewCodeGateFinding[];
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=ai-assurance.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.19",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
"packageName": "@planu/core"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
|
-
"@planu/core-darwin-arm64": "4.3.
|
|
38
|
-
"@planu/core-darwin-x64": "4.3.
|
|
39
|
-
"@planu/core-linux-arm64-gnu": "4.3.
|
|
40
|
-
"@planu/core-linux-arm64-musl": "4.3.
|
|
41
|
-
"@planu/core-linux-x64-gnu": "4.3.
|
|
42
|
-
"@planu/core-linux-x64-musl": "4.3.
|
|
43
|
-
"@planu/core-win32-arm64-msvc": "4.3.
|
|
44
|
-
"@planu/core-win32-x64-msvc": "4.3.
|
|
37
|
+
"@planu/core-darwin-arm64": "4.3.19",
|
|
38
|
+
"@planu/core-darwin-x64": "4.3.19",
|
|
39
|
+
"@planu/core-linux-arm64-gnu": "4.3.19",
|
|
40
|
+
"@planu/core-linux-arm64-musl": "4.3.19",
|
|
41
|
+
"@planu/core-linux-x64-gnu": "4.3.19",
|
|
42
|
+
"@planu/core-linux-x64-musl": "4.3.19",
|
|
43
|
+
"@planu/core-win32-arm64-msvc": "4.3.19",
|
|
44
|
+
"@planu/core-win32-x64-msvc": "4.3.19"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.0.0"
|
package/planu-native.json
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dev.planu.native",
|
|
3
3
|
"displayName": "Planu Native Lightweight Surface",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.19",
|
|
5
5
|
"packageName": "@planu/cli",
|
|
6
6
|
"modes": {
|
|
7
7
|
"lightweight": {
|
|
8
8
|
"requiresMcp": false,
|
|
9
9
|
"requiresDaemon": false,
|
|
10
|
-
"hosts": [
|
|
10
|
+
"hosts": [
|
|
11
|
+
"codex",
|
|
12
|
+
"claude-code"
|
|
13
|
+
],
|
|
11
14
|
"commands": [
|
|
12
15
|
{
|
|
13
16
|
"id": "planu.status",
|
|
14
17
|
"title": "Project status",
|
|
15
18
|
"description": "Show the compact Planu project snapshot without loading the MCP tool graph.",
|
|
16
19
|
"invocation": "planu status",
|
|
17
|
-
"hosts": [
|
|
20
|
+
"hosts": [
|
|
21
|
+
"codex",
|
|
22
|
+
"claude-code"
|
|
23
|
+
],
|
|
18
24
|
"requiresMcp": false,
|
|
19
25
|
"requiresDaemon": false,
|
|
20
26
|
"mapsTo": "handlePlanStatus"
|
|
@@ -24,7 +30,10 @@
|
|
|
24
30
|
"title": "Create spec",
|
|
25
31
|
"description": "Create a new spec through the CLI-backed SDD contract.",
|
|
26
32
|
"invocation": "planu spec create \"<title>\"",
|
|
27
|
-
"hosts": [
|
|
33
|
+
"hosts": [
|
|
34
|
+
"codex",
|
|
35
|
+
"claude-code"
|
|
36
|
+
],
|
|
28
37
|
"requiresMcp": false,
|
|
29
38
|
"requiresDaemon": false,
|
|
30
39
|
"mapsTo": "handleCreateSpec"
|
|
@@ -34,7 +43,10 @@
|
|
|
34
43
|
"title": "List specs",
|
|
35
44
|
"description": "List specs in the current project with optional status/type filters.",
|
|
36
45
|
"invocation": "planu spec list",
|
|
37
|
-
"hosts": [
|
|
46
|
+
"hosts": [
|
|
47
|
+
"codex",
|
|
48
|
+
"claude-code"
|
|
49
|
+
],
|
|
38
50
|
"requiresMcp": false,
|
|
39
51
|
"requiresDaemon": false,
|
|
40
52
|
"mapsTo": "handleListSpecs"
|
|
@@ -44,7 +56,10 @@
|
|
|
44
56
|
"title": "Validate spec",
|
|
45
57
|
"description": "Validate a spec against the current codebase from the native CLI surface.",
|
|
46
58
|
"invocation": "planu spec validate SPEC-001",
|
|
47
|
-
"hosts": [
|
|
59
|
+
"hosts": [
|
|
60
|
+
"codex",
|
|
61
|
+
"claude-code"
|
|
62
|
+
],
|
|
48
63
|
"requiresMcp": false,
|
|
49
64
|
"requiresDaemon": false,
|
|
50
65
|
"mapsTo": "handleValidate"
|
|
@@ -54,7 +69,10 @@
|
|
|
54
69
|
"title": "Audit technical debt",
|
|
55
70
|
"description": "Run the read-only project audit path for lightweight debt checks.",
|
|
56
71
|
"invocation": "planu audit debt",
|
|
57
|
-
"hosts": [
|
|
72
|
+
"hosts": [
|
|
73
|
+
"codex",
|
|
74
|
+
"claude-code"
|
|
75
|
+
],
|
|
58
76
|
"requiresMcp": false,
|
|
59
77
|
"requiresDaemon": false,
|
|
60
78
|
"mapsTo": "handleAudit"
|
|
@@ -64,7 +82,10 @@
|
|
|
64
82
|
"title": "Check release readiness",
|
|
65
83
|
"description": "Check local branch cleanliness and main/develop/release sync readiness.",
|
|
66
84
|
"invocation": "planu release check",
|
|
67
|
-
"hosts": [
|
|
85
|
+
"hosts": [
|
|
86
|
+
"codex",
|
|
87
|
+
"claude-code"
|
|
88
|
+
],
|
|
68
89
|
"requiresMcp": false,
|
|
69
90
|
"requiresDaemon": false,
|
|
70
91
|
"mapsTo": "releaseCommand"
|
package/planu-plugin.json
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
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": "4.3.
|
|
5
|
+
"version": "4.3.19",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
|
-
"command": [
|
|
7
|
+
"command": [
|
|
8
|
+
"npx",
|
|
9
|
+
"@planu/cli@latest"
|
|
10
|
+
],
|
|
8
11
|
"packageName": "@planu/cli",
|
|
9
12
|
"capabilities": {
|
|
10
13
|
"tools": [
|
|
@@ -23,17 +26,42 @@
|
|
|
23
26
|
"create_skill",
|
|
24
27
|
"skill_search"
|
|
25
28
|
],
|
|
26
|
-
"resources": [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
"resources": [
|
|
30
|
+
"planu://specs/list",
|
|
31
|
+
"planu://specs/{id}",
|
|
32
|
+
"planu://project/status",
|
|
33
|
+
"planu://roadmap"
|
|
34
|
+
],
|
|
35
|
+
"prompts": [
|
|
36
|
+
"create-spec-from-idea",
|
|
37
|
+
"review-spec-readiness",
|
|
38
|
+
"generate-implementation-plan"
|
|
39
|
+
],
|
|
40
|
+
"subagents": [
|
|
41
|
+
"sdd-orchestrator",
|
|
42
|
+
"spec-challenger",
|
|
43
|
+
"test-generator"
|
|
44
|
+
]
|
|
29
45
|
},
|
|
30
46
|
"compatibility": {
|
|
31
47
|
"minimumHostVersion": "1.0.0",
|
|
32
|
-
"requiredFeatures": [
|
|
48
|
+
"requiredFeatures": [
|
|
49
|
+
"mcp-tools",
|
|
50
|
+
"file-editing"
|
|
51
|
+
]
|
|
33
52
|
},
|
|
34
53
|
"repository": "https://github.com/planu-dev/planu",
|
|
35
54
|
"author": "Planu",
|
|
36
55
|
"license": "MIT",
|
|
37
56
|
"homepage": "https://planu.dev",
|
|
38
|
-
"keywords": [
|
|
57
|
+
"keywords": [
|
|
58
|
+
"sdd",
|
|
59
|
+
"spec-driven-development",
|
|
60
|
+
"mcp",
|
|
61
|
+
"specs",
|
|
62
|
+
"planning",
|
|
63
|
+
"ai",
|
|
64
|
+
"bdd",
|
|
65
|
+
"tdd"
|
|
66
|
+
]
|
|
39
67
|
}
|