@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/dist/engine/elicitation/answer-extractor.js +53 -1
  3. package/dist/engine/elicitation/decision-gap-detector.d.ts +3 -0
  4. package/dist/engine/elicitation/decision-gap-detector.js +162 -0
  5. package/dist/engine/elicitation/question-grounding-gate.d.ts +3 -0
  6. package/dist/engine/elicitation/question-grounding-gate.js +54 -0
  7. package/dist/engine/implementation-contract/common.d.ts +5 -0
  8. package/dist/engine/implementation-contract/common.js +30 -0
  9. package/dist/engine/implementation-contract/evaluator.d.ts +3 -0
  10. package/dist/engine/implementation-contract/evaluator.js +105 -0
  11. package/dist/engine/implementation-contract/index.d.ts +4 -0
  12. package/dist/engine/implementation-contract/index.js +4 -0
  13. package/dist/engine/implementation-contract/renderer.d.ts +4 -0
  14. package/dist/engine/implementation-contract/renderer.js +98 -0
  15. package/dist/engine/readiness-checker.js +18 -6
  16. package/dist/engine/skill-registry/index.d.ts +1 -0
  17. package/dist/engine/skill-registry/index.js +1 -0
  18. package/dist/engine/skill-registry/installer.d.ts +2 -2
  19. package/dist/engine/skill-registry/installer.js +69 -37
  20. package/dist/engine/skill-registry/skill-security-scanner.d.ts +12 -0
  21. package/dist/engine/skill-registry/skill-security-scanner.js +87 -0
  22. package/dist/engine/spec-format/bdd-parser.js +9 -0
  23. package/dist/engine/spec-format/lean-spec-generator.js +10 -2
  24. package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.d.ts +3 -0
  25. package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.js +51 -0
  26. package/dist/tools/challenge-spec.js +4 -0
  27. package/dist/tools/create-spec/question-generator.d.ts +1 -1
  28. package/dist/tools/create-spec/question-generator.js +20 -96
  29. package/dist/tools/create-spec.js +19 -2
  30. package/dist/tools/skill-registry/install.js +9 -0
  31. package/dist/types/clarification.d.ts +8 -0
  32. package/dist/types/elicitation.d.ts +21 -0
  33. package/dist/types/skill-registry.d.ts +59 -0
  34. package/dist/types/spec-format.d.ts +21 -0
  35. package/package.json +18 -18
  36. package/planu-native.json +29 -8
  37. 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
- /** Install a skill from the Anthropic (GitHub) source. */
52
- async function installFromAnthropic(skillName, installDir) {
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 { filesWritten: [skillMdPath], hasScripts };
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
- /** Install a built-in skill from the local catalog, generating a SKILL.md. */
80
- async function installFromBuiltIn(skillName, installDir) {
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
- const skillMdPath = path.join(installDir, 'SKILL.md');
113
- await fs.writeFile(skillMdPath, content, 'utf-8');
114
- return { filesWritten: [skillMdPath], hasScripts: false };
111
+ return { content, hasScripts: false };
115
112
  }
116
- /** Install a skill placeholder for skillssh/agentskill sources (no direct file API yet). */
117
- async function installFromExternal(skillName, source, installDir) {
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, placeholder, 'utf-8');
129
- return { filesWritten: [skillMdPath], hasScripts: false };
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
- let filesWritten;
144
- let hasScripts;
145
- let version;
146
- if (source === 'anthropic') {
147
- const result = await installFromAnthropic(skillName, installDir);
148
- filesWritten = result.filesWritten;
149
- hasScripts = result.hasScripts;
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,3 @@
1
+ import type { FailureScenario, Spec } from '../../types/index.js';
2
+ export declare function generateImplementationContractChallengeScenarios(spec: Spec, specContent: string): FailureScenario[];
3
+ //# sourceMappingURL=implementation-contract-challenge-scenarios.d.ts.map
@@ -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 description gaps and project context. */
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-463 / SPEC-619 / SPEC-624
2
- // Config-driven interactive question generation with answer pre-extraction.
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 { buildTargetOptions, buildPaymentProviderOptions, buildBillingModelOptions, buildScopeOptions, buildDatabaseOptions, buildUiTypeOptions, } from '../../engine/elicitation/option-builder.js';
6
- import dimensionsConfig from '../../config/elicitation-dimensions.json' with { type: 'json' };
7
- const MAX_QUESTIONS = 4;
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 questions = [];
13
- for (const dim of DIMENSIONS) {
14
- if (questions.length >= MAX_QUESTIONS) {
15
- break;
16
- }
17
- if (!shouldInclude(dim, signals, knowledge)) {
18
- continue;
19
- }
20
- questions.push({
21
- question: t(dim.questionKey),
22
- header: t(dim.headerKey),
23
- options: buildOptions(dim.optionSet, knowledge),
24
- multiSelect: dim.multiSelect,
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 unifiedSpec = buildUnifiedSpecContent(leanSpec, leanTechnical);
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';