@planu/cli 4.3.18 → 4.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/engine/pii-detector/core.js +23 -4
- package/dist/engine/text-signal-boundaries.d.ts +6 -0
- package/dist/engine/text-signal-boundaries.js +62 -0
- package/dist/tools/challenge-spec/scenarios-data.js +25 -19
- package/dist/tools/challenge-spec/scenarios-security.js +29 -23
- package/dist/tools/challenge-spec/scenarios-utils.d.ts +1 -0
- package/dist/tools/challenge-spec/scenarios-utils.js +1 -0
- package/dist/tools/create-spec/spec-builder.js +16 -7
- package/dist/tools/create-spec.js +158 -36
- package/package.json +9 -9
- package/planu-native.json +29 -8
- package/planu-plugin.json +35 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// pii-detector/core.ts — PII detection in spec text (SPEC-030)
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { stripNonContractText } from '../text-signal-boundaries.js';
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
export const patterns = require('../../config/pii-patterns.json');
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -33,6 +34,16 @@ function isBlacklistedContext(modelContext) {
|
|
|
33
34
|
const lower = modelContext.toLowerCase();
|
|
34
35
|
return patterns.modelContextBlacklist.some((b) => lower.includes(b.toLowerCase()));
|
|
35
36
|
}
|
|
37
|
+
function isWhitelistedContext(modelContext) {
|
|
38
|
+
const lower = modelContext.toLowerCase();
|
|
39
|
+
return patterns.modelContextWhitelist.some((w) => lower.includes(w.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
function hasFieldCue(contextWindow) {
|
|
42
|
+
return /\b(field|fields|column|columns|property|properties|attribute|schema|model|entity|table|class|interface)\b/i.test(contextWindow);
|
|
43
|
+
}
|
|
44
|
+
function hasCollectionCue(contextWindow) {
|
|
45
|
+
return /\b(collect|store|process|persist|save|record|provide|must have|includes?|contains?)\b/i.test(contextWindow);
|
|
46
|
+
}
|
|
36
47
|
/**
|
|
37
48
|
* Determines if a detected PII field is a GDPR Art. 9 special category.
|
|
38
49
|
*/
|
|
@@ -77,18 +88,26 @@ function inferModelContext(contextWindow) {
|
|
|
77
88
|
*/
|
|
78
89
|
function extractCandidates(text) {
|
|
79
90
|
const candidates = [];
|
|
80
|
-
const
|
|
91
|
+
const cleaned = stripNonContractText(text);
|
|
92
|
+
const lower = cleaned.toLowerCase();
|
|
81
93
|
/* v8 ignore start */
|
|
82
|
-
const words =
|
|
94
|
+
const words = cleaned.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g) ?? [];
|
|
83
95
|
/* v8 ignore stop */
|
|
84
96
|
for (const word of words) {
|
|
85
97
|
if (word.length < 3) {
|
|
86
98
|
continue;
|
|
87
99
|
}
|
|
100
|
+
if (matchVocabulary(normalizeField(word)) === null) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
88
103
|
const wordIndex = lower.indexOf(word.toLowerCase());
|
|
89
|
-
const contextWindow =
|
|
104
|
+
const contextWindow = cleaned.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
|
|
90
105
|
const context = inferModelContext(contextWindow);
|
|
91
|
-
|
|
106
|
+
if (isWhitelistedContext(context) ||
|
|
107
|
+
(hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
|
|
108
|
+
(hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow))) {
|
|
109
|
+
candidates.push({ field: word, context });
|
|
110
|
+
}
|
|
92
111
|
}
|
|
93
112
|
return candidates;
|
|
94
113
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Remove fenced code and quoted examples before keyword-driven inference. */
|
|
2
|
+
export declare function stripNonContractText(text: string): string;
|
|
3
|
+
/** True only when a regex match appears in contract prose and is not negated/excluded. */
|
|
4
|
+
export declare function hasAffirmedMatch(text: string, pattern: RegExp): boolean;
|
|
5
|
+
export declare function hasAnyAffirmedMatch(text: string, patterns: RegExp[]): boolean;
|
|
6
|
+
//# sourceMappingURL=text-signal-boundaries.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// engine/text-signal-boundaries.ts — Boundary-aware keyword matching for advisory signals.
|
|
2
|
+
const NEGATION_WINDOW_WORDS = 5;
|
|
3
|
+
const NEGATION_TERMS = new Set([
|
|
4
|
+
'no',
|
|
5
|
+
'not',
|
|
6
|
+
'without',
|
|
7
|
+
'avoid',
|
|
8
|
+
'exclude',
|
|
9
|
+
'excluding',
|
|
10
|
+
'except',
|
|
11
|
+
'skip',
|
|
12
|
+
'remove',
|
|
13
|
+
'disable',
|
|
14
|
+
'disallow',
|
|
15
|
+
'never',
|
|
16
|
+
]);
|
|
17
|
+
const OUT_OF_SCOPE_RE = /\b(?:out\s+of\s+scope|not\s+in\s+scope|no\s+scope|do\s+not|don't)\b/i;
|
|
18
|
+
/** Remove fenced code and quoted examples before keyword-driven inference. */
|
|
19
|
+
export function stripNonContractText(text) {
|
|
20
|
+
return text
|
|
21
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
22
|
+
.replace(/`[^`\n]+`/g, ' ')
|
|
23
|
+
.replace(/"[^"\n]+"/g, ' ')
|
|
24
|
+
.replace(/'[^'\n]+'/g, ' ');
|
|
25
|
+
}
|
|
26
|
+
function wordTokensBefore(text, index) {
|
|
27
|
+
return text
|
|
28
|
+
.slice(Math.max(0, index - 160), index)
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.split(/[^a-z0-9]+/i)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
function isNegatedAt(text, index) {
|
|
34
|
+
const before = wordTokensBefore(text, index);
|
|
35
|
+
const recent = before.slice(-NEGATION_WINDOW_WORDS);
|
|
36
|
+
return recent.some((word) => NEGATION_TERMS.has(word));
|
|
37
|
+
}
|
|
38
|
+
function isOutOfScopeLine(line, indexInLine) {
|
|
39
|
+
return OUT_OF_SCOPE_RE.test(line.slice(0, Math.max(indexInLine, 0) + 80));
|
|
40
|
+
}
|
|
41
|
+
/** True only when a regex match appears in contract prose and is not negated/excluded. */
|
|
42
|
+
export function hasAffirmedMatch(text, pattern) {
|
|
43
|
+
const cleaned = stripNonContractText(text);
|
|
44
|
+
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
|
|
45
|
+
const re = new RegExp(pattern.source, flags);
|
|
46
|
+
let match = re.exec(cleaned);
|
|
47
|
+
while (match) {
|
|
48
|
+
const index = match.index;
|
|
49
|
+
const lineStart = cleaned.lastIndexOf('\n', index) + 1;
|
|
50
|
+
const lineEnd = cleaned.indexOf('\n', index);
|
|
51
|
+
const line = cleaned.slice(lineStart, lineEnd === -1 ? cleaned.length : lineEnd);
|
|
52
|
+
if (!isNegatedAt(cleaned, index) && !isOutOfScopeLine(line, index - lineStart)) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
match = re.exec(cleaned);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
export function hasAnyAffirmedMatch(text, patterns) {
|
|
60
|
+
return patterns.some((pattern) => hasAffirmedMatch(text, pattern));
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=text-signal-boundaries.js.map
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// tools/challenge-spec/scenarios-data.ts — Data consistency and partial write scenarios
|
|
2
|
-
import { contentMentions } from './scenarios-utils.js';
|
|
2
|
+
import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
|
|
3
|
+
const WRITE_SIGNALS = [
|
|
4
|
+
/\b(transaction|persist|database write|save record|create record|update record|delete record)\b/i,
|
|
5
|
+
/\b(order|checkout|submit form|mutation|multi-step write)\b/i,
|
|
6
|
+
];
|
|
7
|
+
const CACHE_SIGNALS = [/\b(cache|real-time|realtime|concurrent|websocket|stale data)\b/i];
|
|
8
|
+
const DUPLICATE_OPERATION_SIGNALS = [
|
|
9
|
+
/\b(submit|form|checkout|order|payment|create record|mutation|button click)\b/i,
|
|
10
|
+
];
|
|
3
11
|
export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
4
12
|
const scenarios = [];
|
|
5
13
|
const lower = content.toLowerCase();
|
|
6
14
|
// Partial writes
|
|
7
|
-
if (
|
|
8
|
-
lower.includes('update') ||
|
|
9
|
-
lower.includes('create') ||
|
|
10
|
-
lower.includes('modify') ||
|
|
11
|
-
lower.includes('save')) {
|
|
15
|
+
if (hasAnyAffirmedMatch(content, WRITE_SIGNALS)) {
|
|
12
16
|
scenarios.push({
|
|
13
17
|
scenario: 'Partial write: server crashes mid-transaction',
|
|
14
18
|
probability: 'low',
|
|
@@ -22,7 +26,7 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
|
22
26
|
});
|
|
23
27
|
}
|
|
24
28
|
// Stale data
|
|
25
|
-
if (
|
|
29
|
+
if (hasAnyAffirmedMatch(content, CACHE_SIGNALS)) {
|
|
26
30
|
scenarios.push({
|
|
27
31
|
scenario: 'Stale data served from cache after underlying data changes',
|
|
28
32
|
probability: 'high',
|
|
@@ -35,18 +39,20 @@ export function generateDataConsistencyScenarios(_spec, content, _knowledge) {
|
|
|
35
39
|
userExperience: 'Show "last updated" timestamp. Offer manual refresh option.',
|
|
36
40
|
});
|
|
37
41
|
}
|
|
38
|
-
// Duplicate operations
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
42
|
+
// Duplicate operations only apply to explicit user-triggered mutations.
|
|
43
|
+
if (hasAnyAffirmedMatch(content, DUPLICATE_OPERATION_SIGNALS)) {
|
|
44
|
+
scenarios.push({
|
|
45
|
+
scenario: 'User double-clicks submit, causing duplicate records',
|
|
46
|
+
probability: 'high',
|
|
47
|
+
impact: 'medium',
|
|
48
|
+
currentHandling: contentMentions(lower, ['idempoten', 'debounce', 'disable', 'lock'])
|
|
49
|
+
? 'Some prevention mentioned'
|
|
50
|
+
: 'Not addressed',
|
|
51
|
+
requiredHandling: 'Implement idempotency keys for mutations. Disable submit button on click. Use unique constraints in DB.',
|
|
52
|
+
dataConsistency: 'No duplicate records from repeated submissions.',
|
|
53
|
+
userExperience: 'Button disabled during submission. Clear success/error feedback.',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
50
56
|
return scenarios;
|
|
51
57
|
}
|
|
52
58
|
//# sourceMappingURL=scenarios-data.js.map
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// tools/challenge-spec/scenarios-security.ts — Authentication, injection, and CSRF scenarios
|
|
2
|
-
import { contentMentions } from './scenarios-utils.js';
|
|
2
|
+
import { contentMentions, hasAnyAffirmedMatch } from './scenarios-utils.js';
|
|
3
|
+
const AUTH_SIGNALS = [
|
|
4
|
+
/\b(auth|authentication|login|session|token|jwt|oauth|permission|role|rbac)\b/i,
|
|
5
|
+
];
|
|
6
|
+
const INPUT_SIGNALS = [
|
|
7
|
+
/\b(user input|form|query param|search input|request body|payload|html input)\b/i,
|
|
8
|
+
];
|
|
9
|
+
const STATE_CHANGING_WEB_SIGNALS = [
|
|
10
|
+
/\b(form submit|post request|put request|delete request|mutation|state-changing|cookie auth)\b/i,
|
|
11
|
+
];
|
|
3
12
|
export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
4
13
|
const scenarios = [];
|
|
5
14
|
const lower = content.toLowerCase();
|
|
15
|
+
const text = content;
|
|
6
16
|
// Authentication bypass
|
|
7
|
-
if (
|
|
8
|
-
lower.includes('login') ||
|
|
9
|
-
lower.includes('user') ||
|
|
10
|
-
lower.includes('permission') ||
|
|
11
|
-
lower.includes('role')) {
|
|
17
|
+
if (hasAnyAffirmedMatch(text, AUTH_SIGNALS)) {
|
|
12
18
|
scenarios.push({
|
|
13
19
|
scenario: 'Unauthorized access attempt / authentication bypass',
|
|
14
20
|
probability: 'high',
|
|
@@ -22,11 +28,7 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
|
22
28
|
});
|
|
23
29
|
}
|
|
24
30
|
// Injection attacks
|
|
25
|
-
if (
|
|
26
|
-
lower.includes('form') ||
|
|
27
|
-
lower.includes('query') ||
|
|
28
|
-
lower.includes('search') ||
|
|
29
|
-
lower.includes('user')) {
|
|
31
|
+
if (hasAnyAffirmedMatch(text, INPUT_SIGNALS)) {
|
|
30
32
|
scenarios.push({
|
|
31
33
|
scenario: 'SQL injection / XSS / command injection via user input',
|
|
32
34
|
probability: 'high',
|
|
@@ -39,18 +41,22 @@ export function generateSecurityScenarios(_spec, content, _knowledge) {
|
|
|
39
41
|
userExperience: 'Transparent to user. Invalid input is silently sanitized.',
|
|
40
42
|
});
|
|
41
43
|
}
|
|
42
|
-
// CSRF / rate limiting
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
: '
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
// CSRF / rate limiting only applies when the spec explicitly involves auth or
|
|
45
|
+
// state-changing web input. Tooling/spec-cleanup tasks should not get this as noise.
|
|
46
|
+
if (hasAnyAffirmedMatch(text, AUTH_SIGNALS) ||
|
|
47
|
+
hasAnyAffirmedMatch(text, STATE_CHANGING_WEB_SIGNALS)) {
|
|
48
|
+
scenarios.push({
|
|
49
|
+
scenario: 'CSRF attack or brute-force attempt',
|
|
50
|
+
probability: 'medium',
|
|
51
|
+
impact: 'high',
|
|
52
|
+
currentHandling: contentMentions(lower, ['csrf', 'rate limit', 'throttle', 'captcha'])
|
|
53
|
+
? 'Some protection mentioned'
|
|
54
|
+
: 'Not addressed',
|
|
55
|
+
requiredHandling: 'Implement CSRF tokens for state-changing operations. Rate-limit sensitive endpoints. Add CAPTCHA for auth endpoints.',
|
|
56
|
+
dataConsistency: 'Prevent mass data modifications from automated attacks.',
|
|
57
|
+
userExperience: 'Show rate limit message. Require CAPTCHA after N failed attempts.',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
54
60
|
return scenarios;
|
|
55
61
|
}
|
|
56
62
|
//# sourceMappingURL=scenarios-security.js.map
|
|
@@ -3,4 +3,5 @@
|
|
|
3
3
|
* lowerContent is expected to already be lowercased by the caller.
|
|
4
4
|
*/
|
|
5
5
|
export declare function contentMentions(lowerContent: string, keywords: string[]): boolean;
|
|
6
|
+
export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
|
|
6
7
|
//# sourceMappingURL=scenarios-utils.d.ts.map
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
export function contentMentions(lowerContent, keywords) {
|
|
7
7
|
return keywords.some((kw) => lowerContent.includes(kw.toLowerCase()));
|
|
8
8
|
}
|
|
9
|
+
export { hasAffirmedMatch, hasAnyAffirmedMatch, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
|
|
9
10
|
//# sourceMappingURL=scenarios-utils.js.map
|
|
@@ -182,6 +182,15 @@ const EN_STOPWORDS = new Set([
|
|
|
182
182
|
'it',
|
|
183
183
|
'be',
|
|
184
184
|
'as',
|
|
185
|
+
'spec',
|
|
186
|
+
'create',
|
|
187
|
+
'workflow',
|
|
188
|
+
'files',
|
|
189
|
+
'file',
|
|
190
|
+
'example',
|
|
191
|
+
'avoid',
|
|
192
|
+
'planu',
|
|
193
|
+
'feature',
|
|
185
194
|
]);
|
|
186
195
|
/** Infer meaningful tags from title and description when no tags were provided. */
|
|
187
196
|
export function inferTagsFromContent(title, description, type) {
|
|
@@ -213,13 +222,13 @@ export function inferTagsFromContent(title, description, type) {
|
|
|
213
222
|
}
|
|
214
223
|
return tags;
|
|
215
224
|
}
|
|
216
|
-
/** Build the final tags array
|
|
217
|
-
function buildTags(tags, feature,
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return result;
|
|
225
|
+
/** Build the final tags array from explicit user/tool input only. */
|
|
226
|
+
function buildTags(tags, feature, _title, _description, _type) {
|
|
227
|
+
const explicitTags = tags
|
|
228
|
+
.map((tag) => slugify(tag))
|
|
229
|
+
.filter((tag) => tag.length > 0 && !EN_STOPWORDS.has(tag));
|
|
230
|
+
const result = feature ? [...new Set([...explicitTags, slugify(feature)])] : explicitTags;
|
|
231
|
+
return [...new Set(result)];
|
|
223
232
|
}
|
|
224
233
|
/* v8 ignore start -- filesystem scanning is best-effort, tested via integration */
|
|
225
234
|
/** Scan filesystem for SPEC-XXX directories to find max ID globally. */
|
|
@@ -14,7 +14,7 @@ import { setupGitBranch, checkContradictions, fireSpecCreatedHook, generatePostC
|
|
|
14
14
|
import { notifyStoreChange } from '../engine/doc-generator/portal/regen-hook.js';
|
|
15
15
|
import { compactObj } from '../engine/compact-obj.js';
|
|
16
16
|
import { buildCreateSpecSummary } from '../engine/human-summary.js';
|
|
17
|
-
import { runAutoPostCreatePipeline
|
|
17
|
+
import { runAutoPostCreatePipeline } from './create-spec/auto-pipeline.js';
|
|
18
18
|
import { generateLeanSpecContent } from '../engine/spec-format/lean-spec-generator.js';
|
|
19
19
|
import { generateLeanTechnicalContent } from '../engine/spec-format/lean-technical-generator.js';
|
|
20
20
|
import { buildUnifiedSpecContent } from '../engine/spec-format/unified-spec-builder.js';
|
|
@@ -133,6 +133,16 @@ function runSimplicityCheck(text, hours) {
|
|
|
133
133
|
return null;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
function makeAdvisorySignal(args) {
|
|
137
|
+
return { ...args, persistable: false };
|
|
138
|
+
}
|
|
139
|
+
function buildAdvisoryCompat() {
|
|
140
|
+
return {
|
|
141
|
+
legacyTopLevelAdvisoryFields: true,
|
|
142
|
+
deprecatedAfter: 'vNext+2',
|
|
143
|
+
canonicalField: 'advisorySignals',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
136
146
|
/** SPEC-612: resolve outOfScope items (provided by user, or auto-suggested from description). */
|
|
137
147
|
function resolveOutOfScope(description, provided) {
|
|
138
148
|
if (provided !== undefined && provided.length > 0) {
|
|
@@ -181,18 +191,6 @@ async function runComplexityAdvice(projectId, currentSpecId, tags, targetCriteri
|
|
|
181
191
|
return null;
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
|
-
function formatSimplicityLines(r) {
|
|
185
|
-
const icon = r.recommendation === 'simplify' ? '🔴' : r.recommendation === 'review' ? '🟡' : '🟢';
|
|
186
|
-
const signalNames = r.signals.map((s) => s.type).join(', ');
|
|
187
|
-
const lines = [
|
|
188
|
-
'',
|
|
189
|
-
`${icon} **Simplicity**: score ${String(r.score)}/100 — ${signalNames !== '' ? signalNames : 'no signals'}`,
|
|
190
|
-
];
|
|
191
|
-
if (r.recommendation !== 'approve' && r.simpleAlternative !== null) {
|
|
192
|
-
lines.push(` Suggestion: ${r.simpleAlternative}`);
|
|
193
|
-
}
|
|
194
|
-
return lines;
|
|
195
|
-
}
|
|
196
194
|
/** SPEC-783: Synthesize agent team findings into unified spec.md ## Technical section. */
|
|
197
195
|
async function handleAgentTeamSynthesis(specId, projectPath, findings) {
|
|
198
196
|
try {
|
|
@@ -723,9 +721,21 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
723
721
|
/* v8 ignore next -- requires real git repo */
|
|
724
722
|
...(gitSetupResult ? { gitAutoSetup: gitSetupResult.data } : {}),
|
|
725
723
|
};
|
|
724
|
+
const advisorySignals = [];
|
|
726
725
|
const splitResult = buildSplitResult(splitSuggestion, knowledge?.experienceLevel);
|
|
727
726
|
if (splitResult) {
|
|
728
727
|
result.splitSuggestion = splitResult;
|
|
728
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
729
|
+
key: 'split-suggestion',
|
|
730
|
+
kind: 'complexity',
|
|
731
|
+
message: 'Spec may benefit from splitting.',
|
|
732
|
+
source: 'heuristic',
|
|
733
|
+
evidence: ['spec-splitter heuristic'],
|
|
734
|
+
confidence: 0.5,
|
|
735
|
+
surface: 'structuredContent',
|
|
736
|
+
value: splitResult,
|
|
737
|
+
deprecatedAlias: 'splitSuggestion',
|
|
738
|
+
}));
|
|
729
739
|
}
|
|
730
740
|
// Dispatch hook event (fire-and-forget)
|
|
731
741
|
fireSpecCreatedHook(projectId, spec, params.projectPath ?? '');
|
|
@@ -780,31 +790,97 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
780
790
|
const qualityValue = unwrapBudget(qualityResult, null, budgetWarnings);
|
|
781
791
|
if (qualityValue !== null) {
|
|
782
792
|
result.qualityScore = qualityValue;
|
|
793
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
794
|
+
key: 'quality-score',
|
|
795
|
+
kind: 'quality',
|
|
796
|
+
message: `Quality score ${String(qualityValue.total)}/100 (${qualityValue.grade}).`,
|
|
797
|
+
source: 'validator',
|
|
798
|
+
evidence: ['spec-quality-scorer'],
|
|
799
|
+
confidence: 0.7,
|
|
800
|
+
surface: 'structuredContent',
|
|
801
|
+
value: qualityValue,
|
|
802
|
+
deprecatedAlias: 'qualityScore',
|
|
803
|
+
}));
|
|
783
804
|
}
|
|
784
805
|
// SPEC-485: Simplicity autopilot — detect over-engineering signals (best-effort, sync)
|
|
785
806
|
const simplicityResult = runSimplicityCheck([params.description, ...filteredCriteria].join('\n'), estimation.devHours);
|
|
786
807
|
if (simplicityResult) {
|
|
787
808
|
result.simplicityCheck = simplicityResult;
|
|
809
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
810
|
+
key: 'simplicity-check',
|
|
811
|
+
kind: 'simplicity',
|
|
812
|
+
message: `Simplicity recommendation: ${simplicityResult.recommendation}.`,
|
|
813
|
+
source: 'heuristic',
|
|
814
|
+
evidence: simplicityResult.signals.map((signal) => signal.type),
|
|
815
|
+
confidence: 0.55,
|
|
816
|
+
surface: 'structuredContent',
|
|
817
|
+
value: simplicityResult,
|
|
818
|
+
deprecatedAlias: 'simplicityCheck',
|
|
819
|
+
}));
|
|
788
820
|
}
|
|
789
821
|
// SPEC-514: duplicate results
|
|
790
822
|
const possibleDuplicates = unwrapBudget(duplicatesResult, [], budgetWarnings);
|
|
791
823
|
if (possibleDuplicates.length > 0) {
|
|
792
824
|
result.possibleDuplicates = possibleDuplicates;
|
|
825
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
826
|
+
key: 'possible-duplicates',
|
|
827
|
+
kind: 'duplicate',
|
|
828
|
+
message: `${String(possibleDuplicates.length)} possible duplicate spec(s) detected.`,
|
|
829
|
+
source: 'heuristic',
|
|
830
|
+
evidence: possibleDuplicates.map((dup) => dup.specId),
|
|
831
|
+
confidence: 0.5,
|
|
832
|
+
surface: 'structuredContent',
|
|
833
|
+
value: possibleDuplicates,
|
|
834
|
+
deprecatedAlias: 'possibleDuplicates',
|
|
835
|
+
}));
|
|
793
836
|
}
|
|
794
837
|
// SPEC-222 Trigger 2: Auto-challenge hint for high-risk specs
|
|
795
838
|
if (spec.risk === 'high' || spec.difficulty >= 4) {
|
|
796
839
|
result.riskWarning = HIGH_RISK_WARNING;
|
|
840
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
841
|
+
key: 'risk-warning',
|
|
842
|
+
kind: 'challenge',
|
|
843
|
+
message: 'High-risk heuristic warning generated.',
|
|
844
|
+
source: 'heuristic',
|
|
845
|
+
evidence: [`risk:${spec.risk}`, `difficulty:${String(spec.difficulty)}`],
|
|
846
|
+
confidence: 0.6,
|
|
847
|
+
surface: 'structuredContent',
|
|
848
|
+
value: HIGH_RISK_WARNING,
|
|
849
|
+
deprecatedAlias: 'riskWarning',
|
|
850
|
+
}));
|
|
797
851
|
}
|
|
798
852
|
// SPEC-614 AC4: complexity advice
|
|
799
853
|
const cAdvice = unwrapBudget(complexityResult, null, budgetWarnings);
|
|
800
854
|
if (cAdvice) {
|
|
801
855
|
result.complexityAdvice = cAdvice;
|
|
856
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
857
|
+
key: 'complexity-advice',
|
|
858
|
+
kind: 'complexity',
|
|
859
|
+
message: cAdvice.reasoning,
|
|
860
|
+
source: 'history',
|
|
861
|
+
evidence: [`similarSpecs:${String(cAdvice.similarSpecsCount)}`],
|
|
862
|
+
confidence: 0.6,
|
|
863
|
+
surface: 'structuredContent',
|
|
864
|
+
value: cAdvice,
|
|
865
|
+
deprecatedAlias: 'complexityAdvice',
|
|
866
|
+
}));
|
|
802
867
|
}
|
|
803
868
|
// SPEC-615: prior decisions
|
|
804
869
|
const priorLinks = unwrapBudget(priorLinksResult, [], budgetWarnings);
|
|
805
870
|
if (priorLinks.length > 0) {
|
|
806
871
|
result.priorDecisions = priorLinks.map((l) => l.decisionId);
|
|
807
872
|
spec.priorDecisions = result.priorDecisions;
|
|
873
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
874
|
+
key: 'prior-decisions',
|
|
875
|
+
kind: 'prior-decision',
|
|
876
|
+
message: `${String(priorLinks.length)} prior decision link(s) found.`,
|
|
877
|
+
source: 'history',
|
|
878
|
+
evidence: priorLinks.map((link) => link.decisionId),
|
|
879
|
+
confidence: 0.65,
|
|
880
|
+
surface: 'structuredContent',
|
|
881
|
+
value: result.priorDecisions,
|
|
882
|
+
deprecatedAlias: 'priorDecisions',
|
|
883
|
+
}));
|
|
808
884
|
}
|
|
809
885
|
// Post-creation suggestions
|
|
810
886
|
const nextSteps = unwrapBudget(nextStepsResult, [], budgetWarnings);
|
|
@@ -871,30 +947,13 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
871
947
|
if (contradictionHint) {
|
|
872
948
|
lines.push('', `⚠️ ${contradictionHint}`);
|
|
873
949
|
}
|
|
874
|
-
if (possibleDuplicates.length > 0
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
}
|
|
881
|
-
if (result.riskWarning) {
|
|
882
|
-
lines.push('', `⚠️ ${result.riskWarning}`);
|
|
883
|
-
}
|
|
884
|
-
if (splitResult) {
|
|
885
|
-
lines.push('', `💡 ${splitResult.formatted ?? 'Consider splitting this spec.'}`);
|
|
886
|
-
}
|
|
887
|
-
// SPEC-492: Quality score output
|
|
888
|
-
if (result.qualityScore) {
|
|
889
|
-
const qs = result.qualityScore;
|
|
890
|
-
lines.push('', `📊 **Quality score**: ${String(qs.total)}/100 (${qs.grade})`);
|
|
891
|
-
}
|
|
892
|
-
// SPEC-485: Simplicity autopilot output
|
|
893
|
-
if (simplicityResult) {
|
|
894
|
-
lines.push(...formatSimplicityLines(simplicityResult));
|
|
950
|
+
if (possibleDuplicates.length > 0 ||
|
|
951
|
+
result.riskWarning ||
|
|
952
|
+
splitResult ||
|
|
953
|
+
result.qualityScore ||
|
|
954
|
+
simplicityResult) {
|
|
955
|
+
lines.push('', 'Advisory signals were computed and are available in structuredContent.advisorySignals.');
|
|
895
956
|
}
|
|
896
|
-
// Append auto-pipeline output (SPEC-445)
|
|
897
|
-
lines.push(...formatPipelineLines(pipelineResult));
|
|
898
957
|
const allNextSteps = (result.nextSteps ?? []).map((step) => (typeof step === 'string' ? step : formatPostCreationSuggestion(step)));
|
|
899
958
|
const markdownText = allNextSteps.length > 0
|
|
900
959
|
? addNextSteps(formatSuccess(ti('tools.create_spec.success', { id: spec.id, title: spec.title }), lines.join('\n')), allNextSteps)
|
|
@@ -904,15 +963,54 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
904
963
|
// SPEC-612: Record outOfScope auto-suggestion in autopilot summary
|
|
905
964
|
if (outOfScopeSuggestionMsg !== null) {
|
|
906
965
|
collector.pushOk('scope-boundaries', outOfScopeSuggestionMsg);
|
|
966
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
967
|
+
key: 'out-of-scope-suggestions',
|
|
968
|
+
kind: 'out-of-scope',
|
|
969
|
+
message: outOfScopeSuggestionMsg,
|
|
970
|
+
source: 'heuristic',
|
|
971
|
+
evidence: ['scope-boundaries suggester'],
|
|
972
|
+
confidence: 0.45,
|
|
973
|
+
surface: 'structuredContent',
|
|
974
|
+
value: spec.outOfScope ?? [],
|
|
975
|
+
}));
|
|
907
976
|
}
|
|
908
977
|
if (autopilot.detectedPatterns.length > 0) {
|
|
909
978
|
collector.pushOk('pattern-detection', `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`);
|
|
979
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
980
|
+
key: 'detected-patterns',
|
|
981
|
+
kind: 'domain',
|
|
982
|
+
message: `Detected patterns: ${autopilot.detectedPatterns.join(', ')}`,
|
|
983
|
+
source: 'heuristic',
|
|
984
|
+
evidence: autopilot.detectedPatterns,
|
|
985
|
+
confidence: 0.5,
|
|
986
|
+
surface: 'structuredContent',
|
|
987
|
+
value: autopilot.detectedPatterns,
|
|
988
|
+
}));
|
|
910
989
|
}
|
|
911
990
|
const totalSuggestedFiles = autopilot.suggestedFiles.create.length +
|
|
912
991
|
autopilot.suggestedFiles.modify.length +
|
|
913
992
|
autopilot.suggestedFiles.test.length;
|
|
914
993
|
if (totalSuggestedFiles > 0) {
|
|
915
994
|
collector.pushOk('file-analysis', `Suggested ${String(totalSuggestedFiles)} files (${String(autopilot.suggestedFiles.create.length)} create, ${String(autopilot.suggestedFiles.modify.length)} modify, ${String(autopilot.suggestedFiles.test.length)} test)`);
|
|
995
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
996
|
+
key: 'suggested-files',
|
|
997
|
+
kind: 'file',
|
|
998
|
+
message: `${String(totalSuggestedFiles)} possible related file(s) detected.`,
|
|
999
|
+
source: 'heuristic',
|
|
1000
|
+
evidence: [
|
|
1001
|
+
...autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1002
|
+
...autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1003
|
+
...autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1004
|
+
],
|
|
1005
|
+
confidence: 0.45,
|
|
1006
|
+
surface: 'structuredContent',
|
|
1007
|
+
value: {
|
|
1008
|
+
create: autopilot.suggestedFiles.create.map((f) => f.path),
|
|
1009
|
+
modify: autopilot.suggestedFiles.modify.map((f) => f.path),
|
|
1010
|
+
test: autopilot.suggestedFiles.test.map((f) => f.path),
|
|
1011
|
+
},
|
|
1012
|
+
deprecatedAlias: 'autopilotSummary.suggestedFiles',
|
|
1013
|
+
}));
|
|
916
1014
|
}
|
|
917
1015
|
if (filteredCriteria.length > 0) {
|
|
918
1016
|
collector.pushOk('criteria-enrichment', `Added ${String(filteredCriteria.length)} acceptance criteria from project context`);
|
|
@@ -926,15 +1024,37 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
926
1024
|
? ` — difficulty ${String(spec.difficulty)}: use Opus to add exact file paths + function names`
|
|
927
1025
|
: '';
|
|
928
1026
|
collector.pushOk('readiness', `Readiness score: ${String(pipelineResult.readinessScore)}/100${opusHint}`);
|
|
1027
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1028
|
+
key: 'readiness-score',
|
|
1029
|
+
kind: 'readiness',
|
|
1030
|
+
message: `Readiness score: ${String(pipelineResult.readinessScore)}/100.`,
|
|
1031
|
+
source: 'validator',
|
|
1032
|
+
evidence: ['auto post-creation pipeline'],
|
|
1033
|
+
confidence: 0.6,
|
|
1034
|
+
surface: 'structuredContent',
|
|
1035
|
+
value: pipelineResult.readinessScore,
|
|
1036
|
+
}));
|
|
929
1037
|
}
|
|
930
1038
|
else if (spec.difficulty >= 3) {
|
|
931
1039
|
// SPEC-629: always surface the Opus hint for high-difficulty specs
|
|
932
1040
|
collector.pushOk('recommend_model', `Difficulty ${String(spec.difficulty)} spec — use Opus to add exact file paths, function names and anticipated test breaks.`);
|
|
1041
|
+
advisorySignals.push(makeAdvisorySignal({
|
|
1042
|
+
key: 'model-recommendation',
|
|
1043
|
+
kind: 'model',
|
|
1044
|
+
message: 'High-difficulty spec may benefit from a stronger model for review.',
|
|
1045
|
+
source: 'heuristic',
|
|
1046
|
+
evidence: [`difficulty:${String(spec.difficulty)}`],
|
|
1047
|
+
confidence: 0.4,
|
|
1048
|
+
surface: 'structuredContent',
|
|
1049
|
+
value: { recommendedTier: 'max' },
|
|
1050
|
+
}));
|
|
933
1051
|
}
|
|
934
1052
|
if (calibrationEnrichment.calibrationNote) {
|
|
935
1053
|
collector.pushOk('calibration', `📐 ${calibrationEnrichment.calibrationNote}`);
|
|
936
1054
|
}
|
|
937
1055
|
const humanSummary = buildCreateSpecSummary(spec.title, estimation.devHours);
|
|
1056
|
+
result.advisorySignals = advisorySignals;
|
|
1057
|
+
result.compat = buildAdvisoryCompat();
|
|
938
1058
|
const compactResult = compactObj(result);
|
|
939
1059
|
// SPEC-722: Issue a planner token for this spec creation.
|
|
940
1060
|
// Best-effort — token failure never blocks spec creation.
|
|
@@ -965,6 +1085,8 @@ export async function handleCreateSpec(inputParams, server) {
|
|
|
965
1085
|
: undefined;
|
|
966
1086
|
const baseResult = toolResult(markdownText, {
|
|
967
1087
|
...compactResult,
|
|
1088
|
+
advisorySignals,
|
|
1089
|
+
compat: buildAdvisoryCompat(),
|
|
968
1090
|
humanSummary,
|
|
969
1091
|
...(collector.hasEntries() ? { autopilotSummary: collector.getMessages() } : {}),
|
|
970
1092
|
...(suggestedFilesPayload !== undefined
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planu/cli",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.19",
|
|
4
4
|
"description": "Planu — MCP Server for Spec Driven Development with native Rust acceleration for hot paths. Cross-platform (Linux/macOS/Windows, x64/arm64, glibc/musl).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
"packageName": "@planu/core"
|
|
35
35
|
},
|
|
36
36
|
"optionalDependencies": {
|
|
37
|
-
"@planu/core-darwin-arm64": "4.3.
|
|
38
|
-
"@planu/core-darwin-x64": "4.3.
|
|
39
|
-
"@planu/core-linux-arm64-gnu": "4.3.
|
|
40
|
-
"@planu/core-linux-arm64-musl": "4.3.
|
|
41
|
-
"@planu/core-linux-x64-gnu": "4.3.
|
|
42
|
-
"@planu/core-linux-x64-musl": "4.3.
|
|
43
|
-
"@planu/core-win32-arm64-msvc": "4.3.
|
|
44
|
-
"@planu/core-win32-x64-msvc": "4.3.
|
|
37
|
+
"@planu/core-darwin-arm64": "4.3.19",
|
|
38
|
+
"@planu/core-darwin-x64": "4.3.19",
|
|
39
|
+
"@planu/core-linux-arm64-gnu": "4.3.19",
|
|
40
|
+
"@planu/core-linux-arm64-musl": "4.3.19",
|
|
41
|
+
"@planu/core-linux-x64-gnu": "4.3.19",
|
|
42
|
+
"@planu/core-linux-x64-musl": "4.3.19",
|
|
43
|
+
"@planu/core-win32-arm64-msvc": "4.3.19",
|
|
44
|
+
"@planu/core-win32-x64-msvc": "4.3.19"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=24.0.0"
|
package/planu-native.json
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dev.planu.native",
|
|
3
3
|
"displayName": "Planu Native Lightweight Surface",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.19",
|
|
5
5
|
"packageName": "@planu/cli",
|
|
6
6
|
"modes": {
|
|
7
7
|
"lightweight": {
|
|
8
8
|
"requiresMcp": false,
|
|
9
9
|
"requiresDaemon": false,
|
|
10
|
-
"hosts": [
|
|
10
|
+
"hosts": [
|
|
11
|
+
"codex",
|
|
12
|
+
"claude-code"
|
|
13
|
+
],
|
|
11
14
|
"commands": [
|
|
12
15
|
{
|
|
13
16
|
"id": "planu.status",
|
|
14
17
|
"title": "Project status",
|
|
15
18
|
"description": "Show the compact Planu project snapshot without loading the MCP tool graph.",
|
|
16
19
|
"invocation": "planu status",
|
|
17
|
-
"hosts": [
|
|
20
|
+
"hosts": [
|
|
21
|
+
"codex",
|
|
22
|
+
"claude-code"
|
|
23
|
+
],
|
|
18
24
|
"requiresMcp": false,
|
|
19
25
|
"requiresDaemon": false,
|
|
20
26
|
"mapsTo": "handlePlanStatus"
|
|
@@ -24,7 +30,10 @@
|
|
|
24
30
|
"title": "Create spec",
|
|
25
31
|
"description": "Create a new spec through the CLI-backed SDD contract.",
|
|
26
32
|
"invocation": "planu spec create \"<title>\"",
|
|
27
|
-
"hosts": [
|
|
33
|
+
"hosts": [
|
|
34
|
+
"codex",
|
|
35
|
+
"claude-code"
|
|
36
|
+
],
|
|
28
37
|
"requiresMcp": false,
|
|
29
38
|
"requiresDaemon": false,
|
|
30
39
|
"mapsTo": "handleCreateSpec"
|
|
@@ -34,7 +43,10 @@
|
|
|
34
43
|
"title": "List specs",
|
|
35
44
|
"description": "List specs in the current project with optional status/type filters.",
|
|
36
45
|
"invocation": "planu spec list",
|
|
37
|
-
"hosts": [
|
|
46
|
+
"hosts": [
|
|
47
|
+
"codex",
|
|
48
|
+
"claude-code"
|
|
49
|
+
],
|
|
38
50
|
"requiresMcp": false,
|
|
39
51
|
"requiresDaemon": false,
|
|
40
52
|
"mapsTo": "handleListSpecs"
|
|
@@ -44,7 +56,10 @@
|
|
|
44
56
|
"title": "Validate spec",
|
|
45
57
|
"description": "Validate a spec against the current codebase from the native CLI surface.",
|
|
46
58
|
"invocation": "planu spec validate SPEC-001",
|
|
47
|
-
"hosts": [
|
|
59
|
+
"hosts": [
|
|
60
|
+
"codex",
|
|
61
|
+
"claude-code"
|
|
62
|
+
],
|
|
48
63
|
"requiresMcp": false,
|
|
49
64
|
"requiresDaemon": false,
|
|
50
65
|
"mapsTo": "handleValidate"
|
|
@@ -54,7 +69,10 @@
|
|
|
54
69
|
"title": "Audit technical debt",
|
|
55
70
|
"description": "Run the read-only project audit path for lightweight debt checks.",
|
|
56
71
|
"invocation": "planu audit debt",
|
|
57
|
-
"hosts": [
|
|
72
|
+
"hosts": [
|
|
73
|
+
"codex",
|
|
74
|
+
"claude-code"
|
|
75
|
+
],
|
|
58
76
|
"requiresMcp": false,
|
|
59
77
|
"requiresDaemon": false,
|
|
60
78
|
"mapsTo": "handleAudit"
|
|
@@ -64,7 +82,10 @@
|
|
|
64
82
|
"title": "Check release readiness",
|
|
65
83
|
"description": "Check local branch cleanliness and main/develop/release sync readiness.",
|
|
66
84
|
"invocation": "planu release check",
|
|
67
|
-
"hosts": [
|
|
85
|
+
"hosts": [
|
|
86
|
+
"codex",
|
|
87
|
+
"claude-code"
|
|
88
|
+
],
|
|
68
89
|
"requiresMcp": false,
|
|
69
90
|
"requiresDaemon": false,
|
|
70
91
|
"mapsTo": "releaseCommand"
|
package/planu-plugin.json
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "4.3.
|
|
5
|
+
"version": "4.3.19",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
|
-
"command": [
|
|
7
|
+
"command": [
|
|
8
|
+
"npx",
|
|
9
|
+
"@planu/cli@latest"
|
|
10
|
+
],
|
|
8
11
|
"packageName": "@planu/cli",
|
|
9
12
|
"capabilities": {
|
|
10
13
|
"tools": [
|
|
@@ -23,17 +26,42 @@
|
|
|
23
26
|
"create_skill",
|
|
24
27
|
"skill_search"
|
|
25
28
|
],
|
|
26
|
-
"resources": [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
"resources": [
|
|
30
|
+
"planu://specs/list",
|
|
31
|
+
"planu://specs/{id}",
|
|
32
|
+
"planu://project/status",
|
|
33
|
+
"planu://roadmap"
|
|
34
|
+
],
|
|
35
|
+
"prompts": [
|
|
36
|
+
"create-spec-from-idea",
|
|
37
|
+
"review-spec-readiness",
|
|
38
|
+
"generate-implementation-plan"
|
|
39
|
+
],
|
|
40
|
+
"subagents": [
|
|
41
|
+
"sdd-orchestrator",
|
|
42
|
+
"spec-challenger",
|
|
43
|
+
"test-generator"
|
|
44
|
+
]
|
|
29
45
|
},
|
|
30
46
|
"compatibility": {
|
|
31
47
|
"minimumHostVersion": "1.0.0",
|
|
32
|
-
"requiredFeatures": [
|
|
48
|
+
"requiredFeatures": [
|
|
49
|
+
"mcp-tools",
|
|
50
|
+
"file-editing"
|
|
51
|
+
]
|
|
33
52
|
},
|
|
34
53
|
"repository": "https://github.com/planu-dev/planu",
|
|
35
54
|
"author": "Planu",
|
|
36
55
|
"license": "MIT",
|
|
37
56
|
"homepage": "https://planu.dev",
|
|
38
|
-
"keywords": [
|
|
57
|
+
"keywords": [
|
|
58
|
+
"sdd",
|
|
59
|
+
"spec-driven-development",
|
|
60
|
+
"mcp",
|
|
61
|
+
"specs",
|
|
62
|
+
"planning",
|
|
63
|
+
"ai",
|
|
64
|
+
"bdd",
|
|
65
|
+
"tdd"
|
|
66
|
+
]
|
|
39
67
|
}
|