@planu/cli 4.4.1 → 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.
- package/CHANGELOG.md +14 -1
- package/dist/engine/evidence-gates/artifact-reader.js +5 -31
- 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/engine/spec-migrator/planu-canonical-policy.js +3 -3
- package/dist/engine/universal-rules/rules/planu-workflow.js +1 -1
- 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.js +19 -2
- package/dist/tools/skill-registry/install.js +9 -0
- package/dist/tools/update-status/evidence-gate.js +2 -2
- package/dist/types/skill-registry.d.ts +59 -0
- package/dist/types/spec-format.d.ts +21 -0
- package/package.json +16 -16
- package/planu-native.json +8 -29
- package/planu-plugin.json +7 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
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
|
+
|
|
8
|
+
## [4.4.2] - 2026-06-05
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
- fix: keep spec folders spec.md-only and store evidence externally
|
|
12
|
+
|
|
13
|
+
|
|
1
14
|
## [4.4.1] - 2026-06-04
|
|
2
15
|
|
|
3
16
|
### Bug Fixes
|
|
@@ -4029,4 +4042,4 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning:
|
|
|
4029
4042
|
- Mermaid diagram generation (architecture, sequence, state machine, ER, data flow)
|
|
4030
4043
|
- Multi-language i18n (EN/ES/PT) for generated specs
|
|
4031
4044
|
- Clean Architecture (hexagonal) — engine, tools, storage, types layers
|
|
4032
|
-
- 10,857 tests with ≥95% coverage
|
|
4045
|
+
- 10,857 tests with ≥95% coverage
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { projectDataDir } from '../../storage/base-store.js';
|
|
5
5
|
const DiscoverySchema = z.object({
|
|
@@ -49,20 +49,6 @@ const ContractValidationSchema = z.object({
|
|
|
49
49
|
reportPath: z.string().optional(),
|
|
50
50
|
summary: z.string().optional(),
|
|
51
51
|
});
|
|
52
|
-
function resolveSpecPath(spec, projectPath) {
|
|
53
|
-
const specPath = spec.specPath;
|
|
54
|
-
if (!specPath?.trim()) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
if (isAbsolute(specPath) || !projectPath) {
|
|
58
|
-
return specPath;
|
|
59
|
-
}
|
|
60
|
-
return join(projectPath, specPath);
|
|
61
|
-
}
|
|
62
|
-
function specEvidencePath(spec, filename, projectPath) {
|
|
63
|
-
const specPath = resolveSpecPath(spec, projectPath);
|
|
64
|
-
return specPath ? join(dirname(specPath), 'evidence', filename) : null;
|
|
65
|
-
}
|
|
66
52
|
function handoffEvidencePath(projectId, specId, filename) {
|
|
67
53
|
return join(projectDataDir(projectId), 'handoffs', specId, filename);
|
|
68
54
|
}
|
|
@@ -108,28 +94,19 @@ export async function readEvidenceArtifacts(args) {
|
|
|
108
94
|
label: 'Discovery evidence',
|
|
109
95
|
invalidArtifacts,
|
|
110
96
|
schema: DiscoverySchema,
|
|
111
|
-
paths: [
|
|
112
|
-
specEvidencePath(args.spec, 'discovery.json', args.projectPath),
|
|
113
|
-
handoffEvidencePath(args.projectId, args.specId, 'discovery.json'),
|
|
114
|
-
],
|
|
97
|
+
paths: [handoffEvidencePath(args.projectId, args.specId, 'discovery.json')],
|
|
115
98
|
});
|
|
116
99
|
const taskPlan = await readOptional({
|
|
117
100
|
label: 'Task plan evidence',
|
|
118
101
|
invalidArtifacts,
|
|
119
102
|
schema: TaskPlanSchema,
|
|
120
|
-
paths: [
|
|
121
|
-
specEvidencePath(args.spec, 'task-plan.json', args.projectPath),
|
|
122
|
-
handoffEvidencePath(args.projectId, args.specId, 'task-plan.json'),
|
|
123
|
-
],
|
|
103
|
+
paths: [handoffEvidencePath(args.projectId, args.specId, 'task-plan.json')],
|
|
124
104
|
});
|
|
125
105
|
const traceabilityMatrix = await readOptional({
|
|
126
106
|
label: 'Traceability matrix evidence',
|
|
127
107
|
invalidArtifacts,
|
|
128
108
|
schema: TraceabilityMatrixSchema,
|
|
129
|
-
paths: [
|
|
130
|
-
specEvidencePath(args.spec, 'traceability-matrix.json', args.projectPath),
|
|
131
|
-
handoffEvidencePath(args.projectId, args.specId, 'traceability-matrix.json'),
|
|
132
|
-
],
|
|
109
|
+
paths: [handoffEvidencePath(args.projectId, args.specId, 'traceability-matrix.json')],
|
|
133
110
|
});
|
|
134
111
|
const contractValidations = [];
|
|
135
112
|
for (const filename of [
|
|
@@ -143,10 +120,7 @@ export async function readEvidenceArtifacts(args) {
|
|
|
143
120
|
label: filename,
|
|
144
121
|
invalidArtifacts,
|
|
145
122
|
schema: ContractValidationSchema,
|
|
146
|
-
paths: [
|
|
147
|
-
specEvidencePath(args.spec, filename, args.projectPath),
|
|
148
|
-
handoffEvidencePath(args.projectId, args.specId, filename),
|
|
149
|
-
],
|
|
123
|
+
paths: [handoffEvidencePath(args.projectId, args.specId, filename)],
|
|
150
124
|
});
|
|
151
125
|
if (evidence) {
|
|
152
126
|
contractValidations.push(evidence);
|
|
@@ -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,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
|
+
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
|
|
401
|
-
const effectiveCriteriaLines =
|
|
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,
|
|
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
|
-
/**
|
|
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
|