@planu/cli 4.4.2 → 4.5.0
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 +17 -1
- package/dist/engine/elicitation/answer-extractor.js +53 -1
- package/dist/engine/elicitation/decision-gap-detector.d.ts +3 -0
- package/dist/engine/elicitation/decision-gap-detector.js +162 -0
- package/dist/engine/elicitation/question-grounding-gate.d.ts +3 -0
- package/dist/engine/elicitation/question-grounding-gate.js +54 -0
- package/dist/engine/implementation-contract/common.d.ts +5 -0
- package/dist/engine/implementation-contract/common.js +30 -0
- package/dist/engine/implementation-contract/evaluator.d.ts +3 -0
- package/dist/engine/implementation-contract/evaluator.js +105 -0
- package/dist/engine/implementation-contract/index.d.ts +4 -0
- package/dist/engine/implementation-contract/index.js +4 -0
- package/dist/engine/implementation-contract/renderer.d.ts +4 -0
- package/dist/engine/implementation-contract/renderer.js +98 -0
- package/dist/engine/readiness-checker.js +18 -6
- package/dist/engine/skill-registry/index.d.ts +1 -0
- package/dist/engine/skill-registry/index.js +1 -0
- package/dist/engine/skill-registry/installer.d.ts +2 -2
- package/dist/engine/skill-registry/installer.js +69 -37
- package/dist/engine/skill-registry/skill-security-scanner.d.ts +12 -0
- package/dist/engine/skill-registry/skill-security-scanner.js +87 -0
- package/dist/engine/spec-format/bdd-parser.js +9 -0
- package/dist/engine/spec-format/lean-spec-generator.js +10 -2
- package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.d.ts +3 -0
- package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.js +51 -0
- package/dist/tools/challenge-spec.js +4 -0
- package/dist/tools/create-spec/question-generator.d.ts +1 -1
- package/dist/tools/create-spec/question-generator.js +20 -96
- package/dist/tools/create-spec.js +19 -2
- package/dist/tools/skill-registry/install.js +9 -0
- package/dist/types/clarification.d.ts +8 -0
- package/dist/types/elicitation.d.ts +21 -0
- package/dist/types/skill-registry.d.ts +59 -0
- package/dist/types/spec-format.d.ts +21 -0
- package/package.json +18 -18
- package/planu-native.json +29 -8
- package/planu-plugin.json +35 -7
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// engine/skill-registry/index.ts — Barrel for skill registry engine (SPEC-150)
|
|
2
2
|
export { searchAllRegistries } from './unified-search.js';
|
|
3
3
|
export { installSkill, isSkillInstalled, readSkillManifest } from './installer.js';
|
|
4
|
+
export { scanSkillSecurity } from './skill-security-scanner.js';
|
|
4
5
|
export { searchAnthropicSkills, fetchAnthropicSkillContent, clearAnthropicCache, } from './anthropic-adapter.js';
|
|
5
6
|
export { searchSkillsSh } from './skillssh-adapter.js';
|
|
6
7
|
export { searchAgentSkills } from './agentskill-adapter.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SkillInstallResult, SkillManifest } from '../../types/index.js';
|
|
1
|
+
import type { SkillInstallOptions, SkillInstallResult, SkillManifest } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Read the skill manifest from disk.
|
|
4
4
|
* Returns an empty manifest if the file does not exist or cannot be parsed.
|
|
@@ -18,5 +18,5 @@ export declare function isSkillInstalled(projectPath: string, skillName: string)
|
|
|
18
18
|
* 4. Update the local manifest.
|
|
19
19
|
* 5. Return the install result with security flags.
|
|
20
20
|
*/
|
|
21
|
-
export declare function installSkill(skillName: string, source: string, projectPath: string): Promise<SkillInstallResult>;
|
|
21
|
+
export declare function installSkill(skillName: string, source: string, projectPath: string, options?: SkillInstallOptions): Promise<SkillInstallResult>;
|
|
22
22
|
//# sourceMappingURL=installer.d.ts.map
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// Manages the local manifest (manifest.json) tracking all installed skills.
|
|
4
4
|
import * as fs from 'node:fs/promises';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
6
7
|
import { fetchAnthropicSkillContent } from './anthropic-adapter.js';
|
|
7
8
|
import { getBuiltInSkillEntry } from './builtin-catalog-adapter.js';
|
|
9
|
+
import { scanSkillSecurity } from './skill-security-scanner.js';
|
|
8
10
|
const SKILLS_DIR = '.claude/skills';
|
|
9
11
|
const MANIFEST_FILE = 'manifest.json';
|
|
12
|
+
const BLOCKING_SEVERITIES = new Set(['HIGH', 'CRITICAL']);
|
|
10
13
|
/** Return the absolute path to the project's skills directory. */
|
|
11
14
|
function skillsRoot(projectPath) {
|
|
12
15
|
return path.join(projectPath, SKILLS_DIR);
|
|
@@ -48,18 +51,15 @@ function upsertManifestEntry(manifest, entry) {
|
|
|
48
51
|
: [...manifest.skills, entry];
|
|
49
52
|
return { ...manifest, skills };
|
|
50
53
|
}
|
|
51
|
-
/**
|
|
52
|
-
async function
|
|
54
|
+
/** Prepare a skill from the Anthropic (GitHub) source. */
|
|
55
|
+
async function prepareFromAnthropic(skillName) {
|
|
53
56
|
const content = await fetchAnthropicSkillContent(skillName);
|
|
54
57
|
if (!content) {
|
|
55
58
|
throw new Error(`Skill "${skillName}" not found in the Anthropic skills repository. ` +
|
|
56
59
|
'Check the skill name and try again.');
|
|
57
60
|
}
|
|
58
|
-
await fs.mkdir(installDir, { recursive: true });
|
|
59
|
-
const skillMdPath = path.join(installDir, 'SKILL.md');
|
|
60
|
-
await fs.writeFile(skillMdPath, content.skillMd, 'utf-8');
|
|
61
61
|
const hasScripts = content.files.some((f) => f.startsWith('scripts/') || f === 'scripts');
|
|
62
|
-
return {
|
|
62
|
+
return { content: content.skillMd, hasScripts };
|
|
63
63
|
}
|
|
64
64
|
/** Build a YAML frontmatter block for a skill entry if model/effort are defined. */
|
|
65
65
|
function buildFrontmatter(entry) {
|
|
@@ -76,10 +76,9 @@ function buildFrontmatter(entry) {
|
|
|
76
76
|
lines.push('---', '');
|
|
77
77
|
return lines.join('\n');
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
79
|
+
/** Prepare a built-in skill from the local catalog, generating a SKILL.md. */
|
|
80
|
+
function prepareFromBuiltIn(skillName) {
|
|
81
81
|
const entry = getBuiltInSkillEntry(skillName);
|
|
82
|
-
await fs.mkdir(installDir, { recursive: true });
|
|
83
82
|
let content;
|
|
84
83
|
if (entry) {
|
|
85
84
|
const frontmatter = buildFrontmatter(entry);
|
|
@@ -109,13 +108,10 @@ async function installFromBuiltIn(skillName, installDir) {
|
|
|
109
108
|
`Consult the Planu documentation.`,
|
|
110
109
|
].join('\n');
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
await fs.writeFile(skillMdPath, content, 'utf-8');
|
|
114
|
-
return { filesWritten: [skillMdPath], hasScripts: false };
|
|
111
|
+
return { content, hasScripts: false };
|
|
115
112
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
await fs.mkdir(installDir, { recursive: true });
|
|
113
|
+
/** Prepare a skill placeholder for skillssh/agentskill sources (no direct file API yet). */
|
|
114
|
+
function prepareFromExternal(skillName, source) {
|
|
119
115
|
const placeholder = [
|
|
120
116
|
`# ${skillName}`,
|
|
121
117
|
'',
|
|
@@ -124,9 +120,55 @@ async function installFromExternal(skillName, source, installDir) {
|
|
|
124
120
|
'',
|
|
125
121
|
'Run `planu registry install` again to refresh this skill.',
|
|
126
122
|
].join('\n');
|
|
123
|
+
return { content: placeholder, hasScripts: false };
|
|
124
|
+
}
|
|
125
|
+
function shouldScanSource(source) {
|
|
126
|
+
return source !== 'builtin';
|
|
127
|
+
}
|
|
128
|
+
async function writePreparedSkill(prepared, installDir) {
|
|
129
|
+
await fs.mkdir(installDir, { recursive: true });
|
|
127
130
|
const skillMdPath = path.join(installDir, 'SKILL.md');
|
|
128
|
-
await fs.writeFile(skillMdPath,
|
|
129
|
-
return {
|
|
131
|
+
await fs.writeFile(skillMdPath, prepared.content, 'utf-8');
|
|
132
|
+
return {
|
|
133
|
+
filesWritten: [skillMdPath],
|
|
134
|
+
hasScripts: prepared.hasScripts,
|
|
135
|
+
version: prepared.version,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async function scanPreparedSkill(prepared, skillName, source, options) {
|
|
139
|
+
if (!shouldScanSource(source)) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'planu-skill-scan-'));
|
|
143
|
+
try {
|
|
144
|
+
await fs.writeFile(path.join(tempDir, 'SKILL.md'), prepared.content, 'utf-8');
|
|
145
|
+
const scan = await scanSkillSecurity(tempDir, skillName);
|
|
146
|
+
if (BLOCKING_SEVERITIES.has(scan.severity) || scan.recommendation === 'DO_NOT_INSTALL') {
|
|
147
|
+
throw new Error(`Skill "${skillName}" blocked by security scan: ${scan.severity} risk, recommendation ${scan.recommendation}.`);
|
|
148
|
+
}
|
|
149
|
+
return scan;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (options.scannerUnavailablePolicy === 'allow' &&
|
|
153
|
+
err instanceof Error &&
|
|
154
|
+
err.name === 'SkillSecurityScannerUnavailableError') {
|
|
155
|
+
return {
|
|
156
|
+
scanner: 'skillspector',
|
|
157
|
+
scannedAt: new Date().toISOString(),
|
|
158
|
+
score: 100,
|
|
159
|
+
severity: 'UNKNOWN',
|
|
160
|
+
recommendation: 'UNKNOWN',
|
|
161
|
+
issuesCount: 0,
|
|
162
|
+
command: 'skillspector scan <skillDir> --no-llm --format json',
|
|
163
|
+
available: false,
|
|
164
|
+
error: err.message,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
171
|
+
}
|
|
130
172
|
}
|
|
131
173
|
/**
|
|
132
174
|
* Install a skill from the specified source into the project's .claude/skills/ directory.
|
|
@@ -138,27 +180,15 @@ async function installFromExternal(skillName, source, installDir) {
|
|
|
138
180
|
* 4. Update the local manifest.
|
|
139
181
|
* 5. Return the install result with security flags.
|
|
140
182
|
*/
|
|
141
|
-
export async function installSkill(skillName, source, projectPath) {
|
|
183
|
+
export async function installSkill(skillName, source, projectPath, options = {}) {
|
|
142
184
|
const installDir = path.join(skillsRoot(projectPath), skillName);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
version = result.version;
|
|
151
|
-
}
|
|
152
|
-
else if (source === 'builtin') {
|
|
153
|
-
const result = await installFromBuiltIn(skillName, installDir);
|
|
154
|
-
filesWritten = result.filesWritten;
|
|
155
|
-
hasScripts = result.hasScripts;
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
const result = await installFromExternal(skillName, source, installDir);
|
|
159
|
-
filesWritten = result.filesWritten;
|
|
160
|
-
hasScripts = result.hasScripts;
|
|
161
|
-
}
|
|
185
|
+
const prepared = source === 'anthropic'
|
|
186
|
+
? await prepareFromAnthropic(skillName)
|
|
187
|
+
: source === 'builtin'
|
|
188
|
+
? prepareFromBuiltIn(skillName)
|
|
189
|
+
: prepareFromExternal(skillName, source);
|
|
190
|
+
const securityScan = await scanPreparedSkill(prepared, skillName, source, options);
|
|
191
|
+
const { filesWritten, hasScripts, version } = await writePreparedSkill(prepared, installDir);
|
|
162
192
|
const manifest = await readSkillManifest(projectPath);
|
|
163
193
|
const entry = {
|
|
164
194
|
name: skillName,
|
|
@@ -166,6 +196,7 @@ export async function installSkill(skillName, source, projectPath) {
|
|
|
166
196
|
version,
|
|
167
197
|
installedAt: new Date().toISOString(),
|
|
168
198
|
path: installDir,
|
|
199
|
+
securityScan,
|
|
169
200
|
};
|
|
170
201
|
await writeSkillManifest(projectPath, upsertManifestEntry(manifest, entry));
|
|
171
202
|
return {
|
|
@@ -175,6 +206,7 @@ export async function installSkill(skillName, source, projectPath) {
|
|
|
175
206
|
filesWritten,
|
|
176
207
|
hasScripts,
|
|
177
208
|
version,
|
|
209
|
+
securityScan,
|
|
178
210
|
};
|
|
179
211
|
}
|
|
180
212
|
//# sourceMappingURL=installer.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SkillSecurityScanResult } from '../../types/index.js';
|
|
2
|
+
export declare class SkillSecurityScannerUnavailableError extends Error {
|
|
3
|
+
constructor(skillName: string, cause: unknown);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Scan a prepared skill directory using SkillSpector static analysis only.
|
|
7
|
+
*
|
|
8
|
+
* SkillSpector returns exit code 1 for high-risk reports. Planu still parses stdout
|
|
9
|
+
* in that case because the report is valid and should drive the gate decision.
|
|
10
|
+
*/
|
|
11
|
+
export declare function scanSkillSecurity(skillDir: string, skillName: string): Promise<SkillSecurityScanResult>;
|
|
12
|
+
//# sourceMappingURL=skill-security-scanner.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// engine/skill-registry/skill-security-scanner.ts — SPEC-1081
|
|
2
|
+
// Optional SkillSpector adapter for scanning external AI agent skills.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
const SCANNER = 'skillspector';
|
|
5
|
+
const COMMAND = 'skillspector scan <skillDir> --no-llm --format json';
|
|
6
|
+
export class SkillSecurityScannerUnavailableError extends Error {
|
|
7
|
+
constructor(skillName, cause) {
|
|
8
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
9
|
+
super(`Skill security scanner unavailable for "${skillName}". Command: ${COMMAND}. ${detail}`);
|
|
10
|
+
this.name = 'SkillSecurityScannerUnavailableError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function normalizeSeverity(value) {
|
|
14
|
+
if (value === 'LOW' || value === 'MEDIUM' || value === 'HIGH' || value === 'CRITICAL') {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
return 'UNKNOWN';
|
|
18
|
+
}
|
|
19
|
+
function normalizeRecommendation(value) {
|
|
20
|
+
if (value === 'SAFE' || value === 'CAUTION' || value === 'DO_NOT_INSTALL') {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return 'UNKNOWN';
|
|
24
|
+
}
|
|
25
|
+
function normalizeScore(value) {
|
|
26
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
27
|
+
return 100;
|
|
28
|
+
}
|
|
29
|
+
return Math.max(0, Math.min(100, value));
|
|
30
|
+
}
|
|
31
|
+
function parseReport(stdout, skillName) {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(stdout);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new SkillSecurityScannerUnavailableError(skillName, err);
|
|
38
|
+
}
|
|
39
|
+
const risk = parsed.risk_assessment ?? {};
|
|
40
|
+
const version = parsed.metadata?.skillspector_version;
|
|
41
|
+
return {
|
|
42
|
+
scanner: SCANNER,
|
|
43
|
+
scannerVersion: typeof version === 'string' ? version : undefined,
|
|
44
|
+
scannedAt: new Date().toISOString(),
|
|
45
|
+
score: normalizeScore(risk.score),
|
|
46
|
+
severity: normalizeSeverity(risk.severity),
|
|
47
|
+
recommendation: normalizeRecommendation(risk.recommendation),
|
|
48
|
+
issuesCount: Array.isArray(parsed.issues) ? parsed.issues.length : 0,
|
|
49
|
+
command: COMMAND,
|
|
50
|
+
available: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function runSkillSpector(skillDir) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
execFile(SCANNER, ['scan', skillDir, '--no-llm', '--format', 'json'], (err, stdout) => {
|
|
56
|
+
if (err) {
|
|
57
|
+
const error = err instanceof Error ? err : new Error('Unknown execFile error');
|
|
58
|
+
reject(Object.assign(error, { stdout }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
resolve(stdout);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Scan a prepared skill directory using SkillSpector static analysis only.
|
|
67
|
+
*
|
|
68
|
+
* SkillSpector returns exit code 1 for high-risk reports. Planu still parses stdout
|
|
69
|
+
* in that case because the report is valid and should drive the gate decision.
|
|
70
|
+
*/
|
|
71
|
+
export async function scanSkillSecurity(skillDir, skillName) {
|
|
72
|
+
try {
|
|
73
|
+
const stdout = await runSkillSpector(skillDir);
|
|
74
|
+
return parseReport(stdout, skillName);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (typeof err === 'object' &&
|
|
78
|
+
err !== null &&
|
|
79
|
+
'stdout' in err &&
|
|
80
|
+
typeof err.stdout === 'string' &&
|
|
81
|
+
err.stdout.trim().length > 0) {
|
|
82
|
+
return parseReport(err.stdout, skillName);
|
|
83
|
+
}
|
|
84
|
+
throw new SkillSecurityScannerUnavailableError(skillName, err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=skill-security-scanner.js.map
|
|
@@ -83,6 +83,15 @@ export function renderBddScenariosYaml(scenarios) {
|
|
|
83
83
|
for (const scenario of scenarios) {
|
|
84
84
|
lines.push(` - title: "${escapeYaml(scenario.title)}"`);
|
|
85
85
|
lines.push(` done: ${String(scenario.done)}`);
|
|
86
|
+
if (scenario.tests && scenario.tests.length > 0) {
|
|
87
|
+
lines.push(` tests:`);
|
|
88
|
+
for (const test of scenario.tests) {
|
|
89
|
+
lines.push(` - path: ${test.path}`);
|
|
90
|
+
if (test.line !== undefined) {
|
|
91
|
+
lines.push(` line: ${String(test.line)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
86
95
|
lines.push(` steps:`);
|
|
87
96
|
for (const step of scenario.steps) {
|
|
88
97
|
lines.push(` - keyword: ${step.keyword}`);
|
|
@@ -55,7 +55,7 @@ export function generateLeanSpecContent(input) {
|
|
|
55
55
|
];
|
|
56
56
|
let acLines;
|
|
57
57
|
if (acFormat === 'bdd') {
|
|
58
|
-
acLines = buildBddLines(description, extraCriteria, input.criteriaOverride);
|
|
58
|
+
acLines = buildBddLines(description, extraCriteria, input.criteriaOverride, input.scenarioTestPaths ?? []);
|
|
59
59
|
}
|
|
60
60
|
else {
|
|
61
61
|
acLines = buildCheckboxLines(description, extraCriteria, input.criteriaOverride);
|
|
@@ -88,7 +88,7 @@ function buildCheckboxLines(description, extraCriteria, criteriaOverride) {
|
|
|
88
88
|
];
|
|
89
89
|
}
|
|
90
90
|
/** Build BDD scenario lines. */
|
|
91
|
-
function buildBddLines(description, extraCriteria, criteriaOverride) {
|
|
91
|
+
function buildBddLines(description, extraCriteria, criteriaOverride, scenarioTestPaths) {
|
|
92
92
|
let scenarios = parseBddScenarios(description);
|
|
93
93
|
// Fallback: convert checkbox criteria to BDD scenarios
|
|
94
94
|
if (scenarios.length === 0) {
|
|
@@ -108,6 +108,14 @@ function buildBddLines(description, extraCriteria, criteriaOverride) {
|
|
|
108
108
|
});
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
if (scenarioTestPaths.length > 0) {
|
|
112
|
+
scenarios = scenarios.map((scenario) => ({
|
|
113
|
+
...scenario,
|
|
114
|
+
tests: scenario.tests && scenario.tests.length > 0
|
|
115
|
+
? scenario.tests
|
|
116
|
+
: scenarioTestPaths.map((path) => ({ path })),
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
111
119
|
return renderBddScenariosYaml(scenarios);
|
|
112
120
|
}
|
|
113
121
|
/** Extract acceptance criteria from description. Looks for checkbox patterns or generates a default.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { evaluateImplementationContract } from '../../engine/implementation-contract/index.js';
|
|
2
|
+
import { parseFrontmatterScenarios } from '../../engine/validator/spec-compliance-runner.js';
|
|
3
|
+
export function generateImplementationContractChallengeScenarios(spec, specContent) {
|
|
4
|
+
const criteria = parseFrontmatterScenarios(specContent).map((scenario) => scenario.title);
|
|
5
|
+
const issues = evaluateImplementationContract(stripFrontmatter(specContent), criteria);
|
|
6
|
+
if (issues.length === 0) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const missingEdges = !/\b(edge cases?|failure modes?)\b/i.test(specContent);
|
|
10
|
+
const missingBoundaries = !/\b(no-touch|forbidden|non-goals?|out of scope)\b/i.test(specContent);
|
|
11
|
+
const scenarios = [];
|
|
12
|
+
scenarios.push({
|
|
13
|
+
scenario: `[${spec.id}] Implementation Contract Gap: spec leaves the implementer to infer required behavior, file scope, verification, or missing decisions.`,
|
|
14
|
+
probability: 'high',
|
|
15
|
+
impact: 'high',
|
|
16
|
+
currentHandling: issues
|
|
17
|
+
.map((issue) => issue.message)
|
|
18
|
+
.slice(0, 3)
|
|
19
|
+
.join('; '),
|
|
20
|
+
requiredHandling: 'Add or repair ## Implementation Contract before coding: user outcome, behavior contract, file-level work plan, acceptance-to-verification map, edge cases, non-goals, and verification commands.',
|
|
21
|
+
dataConsistency: 'Do not start implementation until the spec states what persistent files or state may change.',
|
|
22
|
+
userExperience: 'The planner should ask for concrete missing decisions instead of sending an ambiguous spec to the implementer.',
|
|
23
|
+
});
|
|
24
|
+
if (missingEdges) {
|
|
25
|
+
scenarios.push({
|
|
26
|
+
scenario: `[${spec.id}] Missing Edge Cases: implementation may pass happy-path criteria while failing required failure modes.`,
|
|
27
|
+
probability: 'medium',
|
|
28
|
+
impact: 'high',
|
|
29
|
+
currentHandling: 'Edge cases and failure modes are not explicit in the spec.',
|
|
30
|
+
requiredHandling: 'Add concrete edge cases and failure modes to ## Implementation Contract before approval.',
|
|
31
|
+
dataConsistency: 'State-changing behavior must define rollback or no-write behavior for failures.',
|
|
32
|
+
userExperience: 'Users should not discover undefined behavior during implementation review.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (missingBoundaries) {
|
|
36
|
+
scenarios.push({
|
|
37
|
+
scenario: `[${spec.id}] Missing No-Touch Boundaries: implementer may refactor unrelated code or satisfy the wording with an architecture-violating shortcut.`,
|
|
38
|
+
probability: 'medium',
|
|
39
|
+
impact: 'high',
|
|
40
|
+
currentHandling: 'No non-goals, forbidden approaches, or no-touch areas are explicit.',
|
|
41
|
+
requiredHandling: 'Add non-goals, forbidden approaches, and no-touch areas to the implementation contract.',
|
|
42
|
+
dataConsistency: 'Unrelated persistent formats and stores must not change without explicit scope.',
|
|
43
|
+
userExperience: 'The implementer receives bounded work instead of open-ended refactoring permission.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return scenarios;
|
|
47
|
+
}
|
|
48
|
+
function stripFrontmatter(raw) {
|
|
49
|
+
return raw.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=implementation-contract-challenge-scenarios.js.map
|
|
@@ -14,6 +14,7 @@ import { prioritizeScenarios, buildPrioritizedSummary } from '../engine/challeng
|
|
|
14
14
|
import { checkContradictions as checkScopeContradictions } from '../engine/scope-boundaries/index.js';
|
|
15
15
|
import { buildChallengeSpecSummary } from '../engine/human-summary.js';
|
|
16
16
|
import { generateAgentChallengeScenarios, isAgentSpec, } from './challenge-spec/agent-challenge-scenarios.js';
|
|
17
|
+
import { generateImplementationContractChallengeScenarios } from './challenge-spec/implementation-contract-challenge-scenarios.js';
|
|
17
18
|
import { getPlatformChallenges } from './challenge-spec/platform-challenge-scenarios.js';
|
|
18
19
|
import { generateSecurityChallengeScenarios } from './challenge-spec/security-challenge-scenarios.js';
|
|
19
20
|
import { generatePrivacyChallengeScenarios } from './challenge-spec/privacy-challenge-scenarios.js';
|
|
@@ -150,6 +151,9 @@ export async function handleChallengeSpec(args, server) {
|
|
|
150
151
|
if (isAgentSpec(spec, specContent)) {
|
|
151
152
|
failureScenarios.push(...generateAgentChallengeScenarios(spec, specContent, knowledge));
|
|
152
153
|
}
|
|
154
|
+
if (focusAreas.includes('failures')) {
|
|
155
|
+
failureScenarios.push(...generateImplementationContractChallengeScenarios(spec, specContent));
|
|
156
|
+
}
|
|
153
157
|
// 5h. SPEC-015a: Smart contract, bot, and IoT challenges
|
|
154
158
|
failureScenarios.push(...getPlatformChallenges(spec, specContent, knowledge));
|
|
155
159
|
// 5i. SPEC-029: Security authorization and STRIDE challenges
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { InteractiveQuestion, ProjectKnowledge } from '../../types/index.js';
|
|
2
|
-
/** Generate clarification questions based on
|
|
2
|
+
/** Generate clarification questions based on request-specific missing decisions. */
|
|
3
3
|
export declare function generateInteractiveQuestions(description: string, knowledge: ProjectKnowledge | null): InteractiveQuestion[];
|
|
4
4
|
//# sourceMappingURL=question-generator.d.ts.map
|
|
@@ -1,101 +1,25 @@
|
|
|
1
|
-
// tools/create-spec/question-generator.ts — SPEC-
|
|
2
|
-
//
|
|
3
|
-
import { t } from '../../i18n/index.js';
|
|
1
|
+
// tools/create-spec/question-generator.ts — SPEC-1083
|
|
2
|
+
// Dynamic clarification questions grounded in the user's requested work.
|
|
4
3
|
import { extractSignals } from '../../engine/elicitation/answer-extractor.js';
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
const DIMENSIONS = dimensionsConfig;
|
|
9
|
-
/** Generate clarification questions based on description gaps and project context. */
|
|
4
|
+
import { detectDecisionGaps } from '../../engine/elicitation/decision-gap-detector.js';
|
|
5
|
+
import { validateQuestionGrounding } from '../../engine/elicitation/question-grounding-gate.js';
|
|
6
|
+
/** Generate clarification questions based on request-specific missing decisions. */
|
|
10
7
|
export function generateInteractiveQuestions(description, knowledge) {
|
|
11
8
|
const signals = extractSignals(description);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
return questions;
|
|
28
|
-
}
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Helpers
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
function isSuppressed(key, signals) {
|
|
33
|
-
if (key === 'hasTarget') {
|
|
34
|
-
return signals.hasTarget;
|
|
35
|
-
}
|
|
36
|
-
if (key === 'hasScope') {
|
|
37
|
-
return signals.hasScope;
|
|
38
|
-
}
|
|
39
|
-
if (key === 'namedProvider') {
|
|
40
|
-
return signals.namedProvider !== null;
|
|
41
|
-
}
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
function hasRequiredSignal(key, signals) {
|
|
45
|
-
if (key === 'hasBilling') {
|
|
46
|
-
return signals.hasBilling;
|
|
47
|
-
}
|
|
48
|
-
if (key === 'hasDatabase') {
|
|
49
|
-
return signals.hasDatabase;
|
|
50
|
-
}
|
|
51
|
-
if (key === 'hasUi') {
|
|
52
|
-
return signals.hasUi;
|
|
53
|
-
}
|
|
54
|
-
if (key === 'hasTarget') {
|
|
55
|
-
return signals.hasTarget;
|
|
56
|
-
}
|
|
57
|
-
if (key === 'hasScope') {
|
|
58
|
-
return signals.hasScope;
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
function matchesDNA(required, knowledge) {
|
|
63
|
-
if (!knowledge) {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
const database = knowledge.database;
|
|
67
|
-
const stack = knowledge.stack;
|
|
68
|
-
const framework = knowledge.framework ?? '';
|
|
69
|
-
return required.some((r) => database === r || framework === r || stack.includes(r));
|
|
70
|
-
}
|
|
71
|
-
function shouldInclude(dim, signals, knowledge) {
|
|
72
|
-
if (dim.suppressWhen !== undefined && isSuppressed(dim.suppressWhen, signals)) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
if (dim.requiresSignal !== undefined && !hasRequiredSignal(dim.requiresSignal, signals)) {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
if (dim.requiresDNA !== undefined) {
|
|
79
|
-
return matchesDNA(dim.requiresDNA, knowledge);
|
|
80
|
-
}
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
83
|
-
function buildOptions(optionSet, knowledge) {
|
|
84
|
-
switch (optionSet) {
|
|
85
|
-
case 'target':
|
|
86
|
-
return buildTargetOptions();
|
|
87
|
-
case 'payment_provider':
|
|
88
|
-
return buildPaymentProviderOptions(knowledge);
|
|
89
|
-
case 'billing_model':
|
|
90
|
-
return buildBillingModelOptions();
|
|
91
|
-
case 'scope':
|
|
92
|
-
return buildScopeOptions();
|
|
93
|
-
case 'database':
|
|
94
|
-
return buildDatabaseOptions();
|
|
95
|
-
case 'uiType':
|
|
96
|
-
return buildUiTypeOptions();
|
|
97
|
-
default:
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
9
|
+
const gaps = detectDecisionGaps(signals, knowledge);
|
|
10
|
+
return gaps.flatMap((gap) => {
|
|
11
|
+
const question = {
|
|
12
|
+
question: gap.question,
|
|
13
|
+
header: gap.header,
|
|
14
|
+
options: gap.options,
|
|
15
|
+
multiSelect: gap.multiSelect,
|
|
16
|
+
};
|
|
17
|
+
return validateQuestionGrounding(question, {
|
|
18
|
+
gap,
|
|
19
|
+
requestText: description,
|
|
20
|
+
}).passed
|
|
21
|
+
? [question]
|
|
22
|
+
: [];
|
|
23
|
+
});
|
|
100
24
|
}
|
|
101
25
|
//# sourceMappingURL=question-generator.js.map
|
|
@@ -20,6 +20,7 @@ import { extractCriteria, generateLeanSpecContent, } from '../engine/spec-format
|
|
|
20
20
|
import { generateLeanTechnicalContent, } from '../engine/spec-format/lean-technical-generator.js';
|
|
21
21
|
import { extractFilesFromSpecBody } from '../engine/spec-format/technical-md-populator.js';
|
|
22
22
|
import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
|
|
23
|
+
import { appendImplementationContractIfMissing } from '../engine/implementation-contract/index.js';
|
|
23
24
|
import { validateEnglishOnlySpecText } from '../engine/spec-language/english-only.js';
|
|
24
25
|
import { ApiKeyResolver, FallbackGenerator, OpusGenerator, } from '../engine/spec-generator/index.js';
|
|
25
26
|
import { analyzeProjectForSpec, getEmptyAutopilotResult, } from './create-spec/autopilot-analyzer.js';
|
|
@@ -666,6 +667,8 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
666
667
|
userInput: description,
|
|
667
668
|
autopilot,
|
|
668
669
|
}));
|
|
670
|
+
const outOfScopeResolved = resolveOutOfScope(description, params.outOfScope);
|
|
671
|
+
const scenarioTestPaths = groundedTechnical.files.test.map((file) => file.path);
|
|
669
672
|
const leanSpec = generateLeanSpecContent({
|
|
670
673
|
spec,
|
|
671
674
|
description: generatedSpec.specBody,
|
|
@@ -677,6 +680,7 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
677
680
|
groundingCriteria,
|
|
678
681
|
groundingTechnicalReferences: groundedTechnical.records,
|
|
679
682
|
acFormat: params.acFormat,
|
|
683
|
+
scenarioTestPaths,
|
|
680
684
|
});
|
|
681
685
|
const leanTechnical = generateLeanTechnicalContent({
|
|
682
686
|
specId: spec.id,
|
|
@@ -687,7 +691,21 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
687
691
|
// SPEC-709: write unified spec.md from origin — no separate technical.md.
|
|
688
692
|
// The legacy two-file output is preserved by appending the technical body
|
|
689
693
|
// as a `## Technical` section inside spec.md.
|
|
690
|
-
const
|
|
694
|
+
const unifiedWithoutContract = buildUnifiedSpecContent(leanSpec, leanTechnical);
|
|
695
|
+
const unifiedSpec = appendImplementationContractIfMissing(unifiedWithoutContract, {
|
|
696
|
+
description: generatedSpec.specBody,
|
|
697
|
+
criteria: contractCriteria.map((record) => ({ text: record.text, done: false })),
|
|
698
|
+
files: groundedTechnical.files,
|
|
699
|
+
outOfScope: outOfScopeResolved.items,
|
|
700
|
+
verificationCommands: [
|
|
701
|
+
...(scenarioTestPaths.length > 0
|
|
702
|
+
? [`pnpm vitest run ${scenarioTestPaths.join(' ')}`]
|
|
703
|
+
: []),
|
|
704
|
+
'pnpm typecheck',
|
|
705
|
+
'pnpm lint',
|
|
706
|
+
'pnpm test',
|
|
707
|
+
],
|
|
708
|
+
});
|
|
691
709
|
const genericOutputGate = checkGenericSpecOutput(unifiedSpec);
|
|
692
710
|
if (!genericOutputGate.passed) {
|
|
693
711
|
return {
|
|
@@ -725,7 +743,6 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
725
743
|
throw writeErr;
|
|
726
744
|
}
|
|
727
745
|
// SPEC-612: Auto-suggest outOfScope when user did not provide any
|
|
728
|
-
const outOfScopeResolved = resolveOutOfScope(description, params.outOfScope);
|
|
729
746
|
if (outOfScopeResolved.items.length > 0) {
|
|
730
747
|
spec.outOfScope = outOfScopeResolved.items;
|
|
731
748
|
}
|
|
@@ -36,6 +36,15 @@ export async function handleSkillInstall(params) {
|
|
|
36
36
|
if (result.version) {
|
|
37
37
|
lines.push(`Version: ${result.version}`);
|
|
38
38
|
}
|
|
39
|
+
if (result.securityScan) {
|
|
40
|
+
lines.push('');
|
|
41
|
+
if (result.securityScan.available) {
|
|
42
|
+
lines.push(`Security scan: ${result.securityScan.severity} (${result.securityScan.recommendation}) — score ${String(result.securityScan.score)}/100`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
lines.push(`Security scan: unavailable (${result.securityScan.recommendation})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
39
48
|
if (splitInfo.splitApplied) {
|
|
40
49
|
lines.push('');
|
|
41
50
|
lines.push(`Note: Skill was split into ${String(splitInfo.referenceFiles.length + 1)} files for context efficiency (progressive disclosure).`);
|
|
@@ -13,6 +13,14 @@ export interface DescriptionSignals {
|
|
|
13
13
|
hasUi: boolean;
|
|
14
14
|
namedProvider: string | null;
|
|
15
15
|
hasScope: boolean;
|
|
16
|
+
action: string | null;
|
|
17
|
+
domainObject: string | null;
|
|
18
|
+
actors: string[];
|
|
19
|
+
integrations: string[];
|
|
20
|
+
dataObjects: string[];
|
|
21
|
+
hasPermissionRisk: boolean;
|
|
22
|
+
hasDestructiveAction: boolean;
|
|
23
|
+
riskTerms: string[];
|
|
16
24
|
}
|
|
17
25
|
/** Which host mechanism the LLM should use to relay interactive questions. */
|
|
18
26
|
export type HostHint = 'claude-code' | 'cursor' | 'codex' | 'gemini' | 'universal';
|