@planu/cli 4.4.2 → 4.4.3

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 (28) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/engine/implementation-contract/common.d.ts +5 -0
  3. package/dist/engine/implementation-contract/common.js +30 -0
  4. package/dist/engine/implementation-contract/evaluator.d.ts +3 -0
  5. package/dist/engine/implementation-contract/evaluator.js +105 -0
  6. package/dist/engine/implementation-contract/index.d.ts +4 -0
  7. package/dist/engine/implementation-contract/index.js +4 -0
  8. package/dist/engine/implementation-contract/renderer.d.ts +4 -0
  9. package/dist/engine/implementation-contract/renderer.js +98 -0
  10. package/dist/engine/readiness-checker.js +18 -6
  11. package/dist/engine/skill-registry/index.d.ts +1 -0
  12. package/dist/engine/skill-registry/index.js +1 -0
  13. package/dist/engine/skill-registry/installer.d.ts +2 -2
  14. package/dist/engine/skill-registry/installer.js +69 -37
  15. package/dist/engine/skill-registry/skill-security-scanner.d.ts +12 -0
  16. package/dist/engine/skill-registry/skill-security-scanner.js +87 -0
  17. package/dist/engine/spec-format/bdd-parser.js +9 -0
  18. package/dist/engine/spec-format/lean-spec-generator.js +10 -2
  19. package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.d.ts +3 -0
  20. package/dist/tools/challenge-spec/implementation-contract-challenge-scenarios.js +51 -0
  21. package/dist/tools/challenge-spec.js +4 -0
  22. package/dist/tools/create-spec.js +19 -2
  23. package/dist/tools/skill-registry/install.js +9 -0
  24. package/dist/types/skill-registry.d.ts +59 -0
  25. package/dist/types/spec-format.d.ts +21 -0
  26. package/package.json +16 -16
  27. package/planu-native.json +1 -1
  28. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [4.4.3] - 2026-06-09
2
+
3
+ ### Features
4
+ - feat(SPEC-1081): add skill security scan gate
5
+ - feat(SPEC-1082): add implementation contract readiness
6
+
7
+
1
8
  ## [4.4.2] - 2026-06-05
2
9
 
3
10
  ### Bug Fixes
@@ -0,0 +1,5 @@
1
+ export declare const IMPLEMENTATION_CONTRACT_SECTION = "Implementation Contract";
2
+ export declare const IMPLEMENTATION_CONTRACT_SUBSECTIONS: readonly ["User Outcome", "Behavior Contract", "File-Level Work Plan", "Acceptance-To-Verification Map", "Edge Cases And Failure Modes", "Non-Goals And Forbidden Approaches", "Verification Commands"];
3
+ export declare function hasImplementationContract(specBody: string): boolean;
4
+ export declare function extractTopLevelSection(body: string, sectionName: string): string;
5
+ //# sourceMappingURL=common.d.ts.map
@@ -0,0 +1,30 @@
1
+ export const IMPLEMENTATION_CONTRACT_SECTION = 'Implementation Contract';
2
+ export const IMPLEMENTATION_CONTRACT_SUBSECTIONS = [
3
+ 'User Outcome',
4
+ 'Behavior Contract',
5
+ 'File-Level Work Plan',
6
+ 'Acceptance-To-Verification Map',
7
+ 'Edge Cases And Failure Modes',
8
+ 'Non-Goals And Forbidden Approaches',
9
+ 'Verification Commands',
10
+ ];
11
+ export function hasImplementationContract(specBody) {
12
+ return extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION).trim().length > 0;
13
+ }
14
+ export function extractTopLevelSection(body, sectionName) {
15
+ const normalized = body.replace(/\r\n/g, '\n');
16
+ const headingRe = new RegExp(`^##\\s+${escapeRegex(sectionName)}[ \\t]*$`, 'm');
17
+ const match = headingRe.exec(normalized);
18
+ if (!match) {
19
+ return '';
20
+ }
21
+ const start = match.index + match[0].length;
22
+ const nextRe = /^##\s+\S/gm;
23
+ nextRe.lastIndex = start;
24
+ const next = nextRe.exec(normalized);
25
+ return normalized.slice(start, next ? next.index : normalized.length).trim();
26
+ }
27
+ function escapeRegex(input) {
28
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
29
+ }
30
+ //# sourceMappingURL=common.js.map
@@ -0,0 +1,3 @@
1
+ import type { ImplementationContractIssue } from '../../types/index.js';
2
+ export declare function evaluateImplementationContract(specBody: string, acceptanceCriteria: string[]): ImplementationContractIssue[];
3
+ //# sourceMappingURL=evaluator.d.ts.map
@@ -0,0 +1,105 @@
1
+ import { extractTopLevelSection, IMPLEMENTATION_CONTRACT_SECTION, IMPLEMENTATION_CONTRACT_SUBSECTIONS, } from './common.js';
2
+ const FILLER_PATTERNS = [
3
+ /\bTBD\b/i,
4
+ /\bN\/A\b/i,
5
+ /\bimplement correctly\b/i,
6
+ /\bhandle errors\b/i,
7
+ /\bto be determined\b/i,
8
+ ];
9
+ export function evaluateImplementationContract(specBody, acceptanceCriteria) {
10
+ const contract = extractTopLevelSection(specBody, IMPLEMENTATION_CONTRACT_SECTION);
11
+ if (contract.trim().length === 0) {
12
+ return [
13
+ {
14
+ code: 'contract_missing',
15
+ message: 'Implementation Contract is missing — add user outcome, behavior, file plan, verification map, edge cases, non-goals, and commands.',
16
+ },
17
+ ];
18
+ }
19
+ const issues = [];
20
+ const sections = new Map(IMPLEMENTATION_CONTRACT_SUBSECTIONS.map((section) => [
21
+ section,
22
+ extractSubsection(contract, section),
23
+ ]));
24
+ for (const [section, content] of sections.entries()) {
25
+ if (content === null) {
26
+ issues.push({
27
+ code: 'contract_subsection_missing',
28
+ message: `Implementation Contract subsection "${section}" is missing.`,
29
+ });
30
+ continue;
31
+ }
32
+ const trimmed = content.trim();
33
+ if (trimmed.length === 0) {
34
+ issues.push({
35
+ code: 'contract_subsection_empty',
36
+ message: `Implementation Contract subsection "${section}" is empty.`,
37
+ });
38
+ }
39
+ for (const pattern of FILLER_PATTERNS) {
40
+ if (pattern.test(trimmed)) {
41
+ issues.push({
42
+ code: 'contract_filler',
43
+ message: `Implementation Contract subsection "${section}" contains filler text.`,
44
+ });
45
+ break;
46
+ }
47
+ }
48
+ if (/\bNeeds decision:/i.test(trimmed)) {
49
+ issues.push({
50
+ code: 'contract_needs_decision',
51
+ message: `Implementation Contract subsection "${section}" still has unresolved Needs decision items.`,
52
+ });
53
+ }
54
+ }
55
+ const map = sections.get('Acceptance-To-Verification Map') ?? '';
56
+ for (const criterion of acceptanceCriteria) {
57
+ if (!criterionHasVerification(criterion, map)) {
58
+ issues.push({
59
+ code: 'contract_unmapped_criterion',
60
+ message: `Acceptance criterion is not mapped to verification evidence: ${criterion}`,
61
+ });
62
+ }
63
+ }
64
+ if (/\bmanual verification\b/i.test(map) &&
65
+ !/\b(given|when|then|step|observe|click|run)\b/i.test(map)) {
66
+ issues.push({
67
+ code: 'contract_manual_verification_vague',
68
+ message: 'Manual verification in the Implementation Contract must include exact observable steps.',
69
+ });
70
+ }
71
+ return issues;
72
+ }
73
+ function extractSubsection(sectionBody, subsectionName) {
74
+ const headingRe = new RegExp(`^###\\s+${escapeRegex(subsectionName)}[ \\t]*$`, 'm');
75
+ const match = headingRe.exec(sectionBody);
76
+ if (!match) {
77
+ return null;
78
+ }
79
+ const start = match.index + match[0].length;
80
+ const nextRe = /^###\s+\S/gm;
81
+ nextRe.lastIndex = start;
82
+ const next = nextRe.exec(sectionBody);
83
+ return sectionBody.slice(start, next ? next.index : sectionBody.length);
84
+ }
85
+ function criterionHasVerification(criterion, map) {
86
+ const normalizedCriterion = normalize(criterion);
87
+ if (normalizedCriterion.length === 0) {
88
+ return true;
89
+ }
90
+ const short = normalizedCriterion.slice(0, 48);
91
+ const normalizedMap = normalize(map);
92
+ return (normalizedMap.includes(short) &&
93
+ /\b(unit test|integration test|cli\/tool result|typecheck\/lint command|snapshot\/golden fixture|manual verification|pnpm|vitest|test)\b/i.test(map));
94
+ }
95
+ function normalize(value) {
96
+ return value
97
+ .replace(/[`*_()[\]:]/g, ' ')
98
+ .replace(/\s+/g, ' ')
99
+ .trim()
100
+ .toLowerCase();
101
+ }
102
+ function escapeRegex(input) {
103
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ }
105
+ //# sourceMappingURL=evaluator.js.map
@@ -0,0 +1,4 @@
1
+ export * from './common.js';
2
+ export * from './renderer.js';
3
+ export * from './evaluator.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export * from './common.js';
2
+ export * from './renderer.js';
3
+ export * from './evaluator.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,4 @@
1
+ import type { ImplementationContractInput } from '../../types/index.js';
2
+ export declare function buildImplementationContractSection(input: ImplementationContractInput): string;
3
+ export declare function appendImplementationContractIfMissing(specBody: string, input: ImplementationContractInput): string;
4
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1,98 @@
1
+ import { hasImplementationContract, IMPLEMENTATION_CONTRACT_SECTION } from './common.js';
2
+ export function buildImplementationContractSection(input) {
3
+ const criteria = input.criteria.length > 0 ? input.criteria : fallbackCriteria();
4
+ const testFiles = input.files.test.map((file) => file.path);
5
+ const verificationCommands = input.verificationCommands.length > 0
6
+ ? input.verificationCommands
7
+ : ['pnpm typecheck', 'pnpm lint', 'pnpm test'];
8
+ const lines = [
9
+ `## ${IMPLEMENTATION_CONTRACT_SECTION}`,
10
+ '### User Outcome',
11
+ firstConcreteSentence(input.description) ??
12
+ 'Needs decision: define the exact user-visible outcome this spec must deliver.',
13
+ '',
14
+ '### Behavior Contract',
15
+ ...renderBehaviorContract(criteria),
16
+ '- Inputs: use the user request and existing project conventions as the source of truth.',
17
+ '- Outputs: update canonical `spec.md` behavior without creating sidecar implementation docs.',
18
+ '- State changes: persist only the files and metadata required by the spec workflow.',
19
+ '- Errors: missing implementation detail must surface as `Needs decision:` instead of generic filler.',
20
+ '',
21
+ '### File-Level Work Plan',
22
+ ...renderFilePlan(input.files),
23
+ '',
24
+ '### Acceptance-To-Verification Map',
25
+ ...criteria.map((criterion, index) => renderVerificationRow(criterion.text, index + 1, testFiles, verificationCommands)),
26
+ '',
27
+ '### Edge Cases And Failure Modes',
28
+ '- Generated specs with too little context must use `Needs decision:` placeholders.',
29
+ '- Existing specs without this section must remain readable and must not be rewritten by readiness checks.',
30
+ '- A heading with filler text must not count as a usable implementation contract.',
31
+ '- Manual verification must include exact observable steps when no automated proof is practical.',
32
+ '',
33
+ '### Non-Goals And Forbidden Approaches',
34
+ ...renderNonGoals(input.outOfScope),
35
+ '',
36
+ '### Verification Commands',
37
+ ...verificationCommands.map((command) => `- \`${command}\``),
38
+ '',
39
+ ];
40
+ return lines.join('\n');
41
+ }
42
+ export function appendImplementationContractIfMissing(specBody, input) {
43
+ if (hasImplementationContract(specBody)) {
44
+ return specBody.endsWith('\n') ? specBody : `${specBody}\n`;
45
+ }
46
+ return `${specBody.trimEnd()}\n\n${buildImplementationContractSection(input)}`;
47
+ }
48
+ function fallbackCriteria() {
49
+ return [
50
+ {
51
+ text: 'Needs decision: define at least one acceptance criterion with executable verification.',
52
+ done: false,
53
+ },
54
+ ];
55
+ }
56
+ function firstConcreteSentence(description) {
57
+ const body = description
58
+ .replace(/^---[\s\S]*?---/, '')
59
+ .replace(/^#{1,6}\s+.+$/gm, '')
60
+ .split('\n')
61
+ .map((line) => line.trim())
62
+ .find((line) => line.length >= 24 && !line.startsWith('-'));
63
+ if (!body) {
64
+ return null;
65
+ }
66
+ return body.replace(/\s+/g, ' ').slice(0, 240);
67
+ }
68
+ function renderFilePlan(files) {
69
+ const lines = [];
70
+ for (const [label, entries] of [
71
+ ['Create', files.create],
72
+ ['Modify', files.modify],
73
+ ['Test', files.test],
74
+ ]) {
75
+ for (const entry of entries) {
76
+ lines.push(`- ${label}: \`${entry.path}\` (${entry.status})`);
77
+ }
78
+ }
79
+ return lines.length > 0 ? lines : ['- Needs decision: identify expected source and test files.'];
80
+ }
81
+ function renderBehaviorContract(criteria) {
82
+ return criteria.map((criterion, index) => `- Expected behavior AC${String(index + 1)}: ${criterion.text}`);
83
+ }
84
+ function renderVerificationRow(criterion, index, testFiles, commands) {
85
+ const test = testFiles[0];
86
+ if (test) {
87
+ return `- AC${String(index)}: ${criterion} -> unit test \`${test}\` plus \`${commands[0] ?? 'pnpm test'}\`.`;
88
+ }
89
+ return `- AC${String(index)}: ${criterion} -> Needs decision: choose unit test, integration test, CLI/tool result, snapshot/golden fixture, or exact manual verification.`;
90
+ }
91
+ function renderNonGoals(outOfScope) {
92
+ const base = [
93
+ '- Do not create durable sidecar implementation docs outside canonical `spec.md`.',
94
+ '- Do not treat headings or filler text as implementation-ready detail.',
95
+ ];
96
+ return outOfScope.length > 0 ? [...base, ...outOfScope.map((item) => `- ${item}`)] : base;
97
+ }
98
+ //# sourceMappingURL=renderer.js.map
@@ -4,7 +4,8 @@ import { readSpecTechnicalSection } from './spec-format/read-technical-section.j
4
4
  import { stripFrontmatter } from './frontmatter-parser.js';
5
5
  import { loadReadinessConfig } from './readiness-config-loader.js';
6
6
  import { runEarsGate } from './ears-gate.js';
7
- import { findScenariosWithoutTests } from './validator/spec-compliance-runner.js';
7
+ import { findScenariosWithoutTests, parseFrontmatterScenarios, } from './validator/spec-compliance-runner.js';
8
+ import { evaluateImplementationContract } from './implementation-contract/index.js';
8
9
  // ── SPEC-784: Technical section quality constants ─────────────────────────────
9
10
  const TECHNICAL_MIN_CHARS = 500;
10
11
  // Detects "See technical.md", "See spec.md", or "See `<file>` technical.md" patterns
@@ -110,6 +111,14 @@ export function extractCriteriaLines(huContent) {
110
111
  }
111
112
  return criteria;
112
113
  }
114
+ function frontmatterScenarioCriteria(raw) {
115
+ return parseFrontmatterScenarios(raw).map((scenario) => {
116
+ const tests = scenario.tests
117
+ ?.map((test) => `${test.path}${test.line !== undefined ? ` line ${String(test.line)}` : ''}`)
118
+ .join(' ') ?? '';
119
+ return [scenario.title, tests].filter((part) => part.trim().length > 0).join(' ');
120
+ });
121
+ }
113
122
  function scoreCriteria(criteriaLines, vagueWords) {
114
123
  const blockers = [];
115
124
  const warnings = [];
@@ -397,10 +406,8 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
397
406
  // When a spec uses BDD format, acceptance criteria are stored as `scenarios:` in
398
407
  // the YAML frontmatter (multi-line YAML that the simple KV parser cannot handle).
399
408
  // If no criteria were found in the body, fall back to counting frontmatter scenarios.
400
- const scenarioCount = criteriaLines.length === 0 ? countFrontmatterScenarios(huRaw) : 0;
401
- const effectiveCriteriaLines = scenarioCount > 0
402
- ? Array.from({ length: scenarioCount }).fill('bdd-scenario')
403
- : criteriaLines;
409
+ const scenarioCriteria = criteriaLines.length === 0 ? frontmatterScenarioCriteria(huRaw) : [];
410
+ const effectiveCriteriaLines = scenarioCriteria.length > 0 ? scenarioCriteria : criteriaLines;
404
411
  const hu = scoreHuCompleteness(spec);
405
412
  const criteria = scoreCriteria(effectiveCriteriaLines, vagueWords);
406
413
  const files = scoreFilesIdentified(spec, fichaContent);
@@ -416,6 +423,11 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
416
423
  ...earsBlockers,
417
424
  ];
418
425
  const allWarnings = [...hu.warnings, ...criteria.warnings, ...files.warnings, ...deps.warnings];
426
+ if (mode === 'strict' && spec.scope !== 'trivial' && spec.difficulty >= 3) {
427
+ for (const issue of evaluateImplementationContract(huContent, effectiveCriteriaLines)) {
428
+ allBlockers.push(`${issue.code}: ${issue.message}`);
429
+ }
430
+ }
419
431
  // SPEC-732: Executable AC gate — block approved when any scenario lacks a `tests` array.
420
432
  // Only enforce when the spec uses frontmatter scenarios (BDD format).
421
433
  const bddScenarioCount = countFrontmatterScenarios(huRaw);
@@ -436,7 +448,7 @@ export async function checkSpecReadiness(spec, mode, projectHash) {
436
448
  }
437
449
  // SPEC-629: Specificity gate caps score at 60 for difficulty >= 3 specs that lack
438
450
  // concrete file paths, function names, or anticipated test breaks.
439
- const specificityBlockers = checkSpecificityGate(spec, criteriaLines, fichaContent);
451
+ const specificityBlockers = checkSpecificityGate(spec, effectiveCriteriaLines, fichaContent);
440
452
  allBlockers.push(...specificityBlockers);
441
453
  const effectiveScore = specificityBlockers.length > 0 && spec.difficulty >= 3 ? Math.min(totalScore, 60) : totalScore;
442
454
  const breakdown = {
@@ -1,5 +1,6 @@
1
1
  export { searchAllRegistries } from './unified-search.js';
2
2
  export { installSkill, isSkillInstalled, readSkillManifest } from './installer.js';
3
+ export { scanSkillSecurity } from './skill-security-scanner.js';
3
4
  export { searchAnthropicSkills, fetchAnthropicSkillContent, clearAnthropicCache, } from './anthropic-adapter.js';
4
5
  export { searchSkillsSh } from './skillssh-adapter.js';
5
6
  export { searchAgentSkills } from './agentskill-adapter.js';
@@ -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
@@ -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).`);
@@ -69,6 +69,63 @@ export interface SkillInstallResult {
69
69
  hasScripts: boolean;
70
70
  /** Installed version, if available. */
71
71
  version?: string;
72
+ /** Security scan summary for this install, when the skill source was scanned. */
73
+ securityScan?: SkillSecurityScanResult;
74
+ }
75
+ /** Severity labels returned by the skill security scanner. */
76
+ export type SkillSecuritySeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN';
77
+ /** Recommendation labels returned by the skill security scanner. */
78
+ export type SkillSecurityRecommendation = 'SAFE' | 'CAUTION' | 'DO_NOT_INSTALL' | 'UNKNOWN';
79
+ /** Policy to use when the external skill security scanner cannot run. */
80
+ export type SkillSecurityScannerUnavailablePolicy = 'fail-closed' | 'allow';
81
+ /** Options controlling skill security scanning during installation. */
82
+ export interface SkillInstallOptions {
83
+ /** Policy for scanner execution failures. Defaults to fail-closed for external skills. */
84
+ scannerUnavailablePolicy?: SkillSecurityScannerUnavailablePolicy;
85
+ }
86
+ /** In-memory skill content prepared before writing to host skill directories. */
87
+ export interface PreparedSkillInstallContent {
88
+ /** SKILL.md content to write or scan. */
89
+ content: string;
90
+ /** Whether the source skill advertises executable script files. */
91
+ hasScripts: boolean;
92
+ /** Installed version, if available. */
93
+ version?: string;
94
+ }
95
+ /** Normalized security scan summary persisted by Planu. */
96
+ export interface SkillSecurityScanResult {
97
+ /** Scanner name. */
98
+ scanner: 'skillspector';
99
+ /** Scanner version, if reported by the scanner. */
100
+ scannerVersion?: string;
101
+ /** ISO 8601 timestamp when Planu ran or recorded the scan. */
102
+ scannedAt: string;
103
+ /** Numeric risk score, 0-100. */
104
+ score: number;
105
+ /** Risk severity label. */
106
+ severity: SkillSecuritySeverity;
107
+ /** Scanner recommendation. */
108
+ recommendation: SkillSecurityRecommendation;
109
+ /** Number of findings/issues reported by the scanner. */
110
+ issuesCount: number;
111
+ /** Exact scanner command shape used, without secrets or environment values. */
112
+ command: string;
113
+ /** Whether the scanner completed successfully. */
114
+ available: boolean;
115
+ /** Optional failure reason when available=false. */
116
+ error?: string;
117
+ }
118
+ /** Minimal JSON shape emitted by SkillSpector in --format json mode. */
119
+ export interface SkillSpectorJsonReport {
120
+ risk_assessment?: {
121
+ score?: unknown;
122
+ severity?: unknown;
123
+ recommendation?: unknown;
124
+ };
125
+ issues?: unknown[];
126
+ metadata?: {
127
+ skillspector_version?: unknown;
128
+ };
72
129
  }
73
130
  /** Manifest tracking all skills installed in a project. */
74
131
  export interface SkillManifest {
@@ -95,6 +152,8 @@ export interface InstalledSkillEntry {
95
152
  sourceStatus?: 'active' | 'source-unavailable';
96
153
  /** Absolute path to the installed skill directory. */
97
154
  path: string;
155
+ /** Security scan summary captured during installation, when available. */
156
+ securityScan?: SkillSecurityScanResult;
98
157
  }
99
158
  /** SPEC-668: Result of a skills TTL refresh operation. */
100
159
  export interface SkillTTLRefreshResult {
@@ -22,6 +22,10 @@ export interface BddScenario {
22
22
  title: string;
23
23
  steps: BddStep[];
24
24
  done: boolean;
25
+ tests?: {
26
+ path: string;
27
+ line?: number;
28
+ }[];
25
29
  }
26
30
  /** Input for lean spec generation. */
27
31
  export interface LeanSpecInput {
@@ -38,6 +42,8 @@ export interface LeanSpecInput {
38
42
  groundingTechnicalReferences?: TechnicalReferenceGroundingRecord[];
39
43
  /** SPEC-481: Acceptance criteria format. Defaults to 'checkbox'. */
40
44
  acFormat?: 'checkbox' | 'bdd';
45
+ /** SPEC-1082: Test links to attach to generated BDD scenarios when available. */
46
+ scenarioTestPaths?: string[];
41
47
  }
42
48
  /** A file entry in the unified spec.md Technical section. */
43
49
  export interface LeanFileEntry {
@@ -121,4 +127,19 @@ export interface LeanTechnicalInput {
121
127
  filesToModify?: LeanFileEntry[];
122
128
  filesToTest?: LeanFileEntry[];
123
129
  }
130
+ export interface ImplementationContractInput {
131
+ description: string;
132
+ criteria: LeanCriterion[];
133
+ files: {
134
+ create: LeanFileEntry[];
135
+ modify: LeanFileEntry[];
136
+ test: LeanFileEntry[];
137
+ };
138
+ outOfScope: string[];
139
+ verificationCommands: string[];
140
+ }
141
+ export interface ImplementationContractIssue {
142
+ code: 'contract_missing' | 'contract_subsection_missing' | 'contract_subsection_empty' | 'contract_filler' | 'contract_needs_decision' | 'contract_unmapped_criterion' | 'contract_manual_verification_vague';
143
+ message: string;
144
+ }
124
145
  //# sourceMappingURL=spec-format.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planu/cli",
3
- "version": "4.4.2",
3
+ "version": "4.4.3",
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.4.2",
38
- "@planu/core-darwin-x64": "4.4.2",
39
- "@planu/core-linux-arm64-gnu": "4.4.2",
40
- "@planu/core-linux-arm64-musl": "4.4.2",
41
- "@planu/core-linux-x64-gnu": "4.4.2",
42
- "@planu/core-linux-x64-musl": "4.4.2",
43
- "@planu/core-win32-arm64-msvc": "4.4.2",
44
- "@planu/core-win32-x64-msvc": "4.4.2"
37
+ "@planu/core-darwin-arm64": "4.4.3",
38
+ "@planu/core-darwin-x64": "4.4.3",
39
+ "@planu/core-linux-arm64-gnu": "4.4.3",
40
+ "@planu/core-linux-arm64-musl": "4.4.3",
41
+ "@planu/core-linux-x64-gnu": "4.4.3",
42
+ "@planu/core-linux-x64-musl": "4.4.3",
43
+ "@planu/core-win32-arm64-msvc": "4.4.3",
44
+ "@planu/core-win32-x64-msvc": "4.4.3"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.0.0"
@@ -129,7 +129,7 @@
129
129
  ],
130
130
  "license": "SEE LICENSE IN LICENSE",
131
131
  "dependencies": {
132
- "@anthropic-ai/sdk": "^0.100.1",
132
+ "@anthropic-ai/sdk": "^0.104.1",
133
133
  "@modelcontextprotocol/sdk": "^1.29.0",
134
134
  "glob": "^13.0.6",
135
135
  "yaml": "^2.9.0",
@@ -181,8 +181,8 @@
181
181
  "@semantic-release/release-notes-generator": "^14.1.1",
182
182
  "@stryker-mutator/core": "^9.6.1",
183
183
  "@stryker-mutator/vitest-runner": "^9.6.1",
184
- "@supabase/supabase-js": "^2.107.0",
185
- "@types/node": "^25.9.1",
184
+ "@supabase/supabase-js": "^2.108.1",
185
+ "@types/node": "^25.9.2",
186
186
  "@vitejs/plugin-vue": "^6.0.7",
187
187
  "@vitest/coverage-v8": "^4.1.8",
188
188
  "@vue/test-utils": "^2.4.11",
@@ -190,19 +190,19 @@
190
190
  "eslint-config-prettier": "^10.1.8",
191
191
  "eslint-import-resolver-typescript": "^4.4.5",
192
192
  "eslint-plugin-import": "^2.32.0",
193
- "happy-dom": "^20.10.1",
193
+ "happy-dom": "^20.10.2",
194
194
  "husky": "^9.1.7",
195
195
  "javascript-obfuscator": "^5.4.3",
196
- "knip": "^6.15.0",
196
+ "knip": "^6.16.1",
197
197
  "lint-staged": "^17.0.7",
198
198
  "madge": "^8.0.0",
199
- "prettier": "^3.8.3",
199
+ "prettier": "^3.8.4",
200
200
  "secretlint": "^13.0.2",
201
201
  "semantic-release": "^25.0.3",
202
202
  "tsc-alias": "^1.8.17",
203
203
  "type-coverage": "^2.29.7",
204
204
  "typescript": "^6.0.3",
205
- "typescript-eslint": "^8.60.1",
205
+ "typescript-eslint": "^8.61.0",
206
206
  "vite": "^8.0.16",
207
207
  "vitest": "^4.1.8",
208
208
  "vue": "^3.5.35"
package/planu-native.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dev.planu.native",
3
3
  "displayName": "Planu Native Lightweight Surface",
4
- "version": "4.4.2",
4
+ "version": "4.4.3",
5
5
  "packageName": "@planu/cli",
6
6
  "modes": {
7
7
  "lightweight": {
package/planu-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dev.planu.cli",
3
3
  "displayName": "Planu — Spec Driven Development",
4
4
  "description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
5
- "version": "4.4.2",
5
+ "version": "4.4.3",
6
6
  "icon": "assets/plugin/icon.svg",
7
7
  "command": ["npx", "@planu/cli@latest"],
8
8
  "packageName": "@planu/cli",