forge-workflow 0.0.1
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/.claude/commands/dev.md +314 -0
- package/.claude/commands/plan.md +389 -0
- package/.claude/commands/premerge.md +179 -0
- package/.claude/commands/research.md +42 -0
- package/.claude/commands/review.md +442 -0
- package/.claude/commands/rollback.md +721 -0
- package/.claude/commands/ship.md +134 -0
- package/.claude/commands/sonarcloud.md +152 -0
- package/.claude/commands/status.md +77 -0
- package/.claude/commands/validate.md +237 -0
- package/.claude/commands/verify.md +221 -0
- package/.claude/rules/greptile-review-process.md +285 -0
- package/.claude/rules/workflow.md +105 -0
- package/.claude/scripts/greptile-resolve.sh +526 -0
- package/.claude/scripts/load-env.sh +32 -0
- package/.forge/hooks/check-tdd.js +240 -0
- package/.github/PLUGIN_TEMPLATE.json +32 -0
- package/.mcp.json.example +12 -0
- package/AGENTS.md +169 -0
- package/CLAUDE.md +99 -0
- package/LICENSE +21 -0
- package/README.md +414 -0
- package/bin/forge-cmd.js +313 -0
- package/bin/forge-validate.js +303 -0
- package/bin/forge.js +4228 -0
- package/docs/AGENT_INSTALL_PROMPT.md +342 -0
- package/docs/ENHANCED_ONBOARDING.md +602 -0
- package/docs/EXAMPLES.md +482 -0
- package/docs/GREPTILE_SETUP.md +400 -0
- package/docs/MANUAL_REVIEW_GUIDE.md +106 -0
- package/docs/ROADMAP.md +359 -0
- package/docs/SETUP.md +632 -0
- package/docs/TOOLCHAIN.md +849 -0
- package/docs/VALIDATION.md +363 -0
- package/docs/WORKFLOW.md +400 -0
- package/docs/planning/PROGRESS.md +396 -0
- package/docs/plans/.gitkeep +0 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-decisions.md +21 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-design.md +362 -0
- package/docs/plans/2026-02-27-forge-test-suite-v2-tasks.md +343 -0
- package/docs/plans/2026-03-02-superpowers-gaps-decisions.md +26 -0
- package/docs/plans/2026-03-02-superpowers-gaps-design.md +239 -0
- package/docs/plans/2026-03-02-superpowers-gaps-tasks.md +260 -0
- package/docs/plans/2026-03-04-agent-command-parity-design.md +163 -0
- package/docs/plans/2026-03-04-verify-worktree-cleanup-decisions.md +7 -0
- package/docs/plans/2026-03-04-verify-worktree-cleanup-design.md +165 -0
- package/docs/plans/2026-03-05-forge-uto-decisions.md +6 -0
- package/docs/plans/2026-03-05-forge-uto-design.md +116 -0
- package/docs/plans/2026-03-05-forge-uto-tasks.md +244 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-decisions.md +52 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-design.md +350 -0
- package/docs/plans/2026-03-10-command-creator-and-eval-tasks.md +426 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-decisions.md +8 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-design.md +80 -0
- package/docs/plans/2026-03-10-stale-workflow-refs-tasks.md +90 -0
- package/docs/plans/2026-03-14-beads-plan-context-decisions.md +9 -0
- package/docs/plans/2026-03-14-beads-plan-context-design.md +171 -0
- package/docs/plans/2026-03-14-beads-plan-context-tasks.md +160 -0
- package/docs/plans/2026-03-14-skill-eval-loop-decisions.md +33 -0
- package/docs/plans/2026-03-14-skill-eval-loop-design.md +118 -0
- package/docs/plans/2026-03-14-skill-eval-loop-results.md +78 -0
- package/docs/plans/2026-03-14-skill-eval-loop-tasks.md +160 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-decisions.md +11 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-design.md +145 -0
- package/docs/plans/2026-03-15-agent-command-parity-v2-tasks.md +211 -0
- package/docs/research/TEMPLATE.md +292 -0
- package/docs/research/advanced-testing.md +297 -0
- package/docs/research/agent-permissions.md +167 -0
- package/docs/research/dependency-chain.md +328 -0
- package/docs/research/forge-workflow-v2.md +550 -0
- package/docs/research/plugin-architecture.md +772 -0
- package/docs/research/pr4-cli-automation.md +326 -0
- package/docs/research/premerge-verify-restructure.md +205 -0
- package/docs/research/skills-restructure.md +508 -0
- package/docs/research/sonarcloud-perfection-plan.md +166 -0
- package/docs/research/sonarcloud-quality-gate.md +184 -0
- package/docs/research/superpowers-integration.md +403 -0
- package/docs/research/superpowers.md +319 -0
- package/docs/research/test-environment.md +519 -0
- package/install.sh +1062 -0
- package/lefthook.yml +39 -0
- package/lib/agents/README.md +198 -0
- package/lib/agents/claude.plugin.json +28 -0
- package/lib/agents/cline.plugin.json +22 -0
- package/lib/agents/codex.plugin.json +19 -0
- package/lib/agents/copilot.plugin.json +24 -0
- package/lib/agents/cursor.plugin.json +25 -0
- package/lib/agents/kilocode.plugin.json +22 -0
- package/lib/agents/opencode.plugin.json +20 -0
- package/lib/agents/roo.plugin.json +23 -0
- package/lib/agents-config.js +2112 -0
- package/lib/commands/dev.js +513 -0
- package/lib/commands/plan.js +696 -0
- package/lib/commands/recommend.js +119 -0
- package/lib/commands/ship.js +377 -0
- package/lib/commands/status.js +378 -0
- package/lib/commands/validate.js +602 -0
- package/lib/context-merge.js +359 -0
- package/lib/plugin-catalog.js +360 -0
- package/lib/plugin-manager.js +166 -0
- package/lib/plugin-recommender.js +141 -0
- package/lib/project-discovery.js +491 -0
- package/lib/setup.js +118 -0
- package/lib/workflow-profiles.js +203 -0
- package/package.json +115 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Command - OpenSpec & Beads Integration
|
|
3
|
+
* Creates implementation plan after research is complete
|
|
4
|
+
*
|
|
5
|
+
* Security: Uses execFileSync instead of exec/execSync to prevent command injection
|
|
6
|
+
* OWASP: Mitigates A03:2021 - Injection vulnerabilities
|
|
7
|
+
*
|
|
8
|
+
* @module commands/plan
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { execFileSync } = require('node:child_process');
|
|
14
|
+
|
|
15
|
+
// Constants for security
|
|
16
|
+
// Note: cwd is resolved at call-time via getExecOptions() to avoid stale require-time snapshots
|
|
17
|
+
const EXEC_TIMEOUT = 120000; // 2 minutes max per command
|
|
18
|
+
function getExecOptions() {
|
|
19
|
+
return { encoding: 'utf8', cwd: process.cwd(), timeout: EXEC_TIMEOUT };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_SLUG_LENGTH = 100;
|
|
23
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate feature slug format
|
|
27
|
+
* Ensures slug matches expected pattern and doesn't contain path traversal
|
|
28
|
+
*
|
|
29
|
+
* @param {string} slug - Feature slug to validate
|
|
30
|
+
* @returns {{valid: boolean, error?: string}} Validation result
|
|
31
|
+
* @private
|
|
32
|
+
*/
|
|
33
|
+
function validateFeatureSlug(slug) {
|
|
34
|
+
if (!slug || typeof slug !== 'string') {
|
|
35
|
+
return { valid: false, error: 'Feature slug must be a non-empty string' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Length limits (prevent DoS)
|
|
39
|
+
const MIN_SLUG_LENGTH = 3;
|
|
40
|
+
|
|
41
|
+
if (slug.length < MIN_SLUG_LENGTH) {
|
|
42
|
+
return { valid: false, error: `Slug too short (minimum ${MIN_SLUG_LENGTH} characters)` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (slug.length > MAX_SLUG_LENGTH) {
|
|
46
|
+
return { valid: false, error: `Slug too long (maximum ${MAX_SLUG_LENGTH} characters)` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Only allow lowercase alphanumeric and hyphens; must start and end with alphanumeric
|
|
50
|
+
const slugPattern = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; // NOSONAR S5852 - no backtracking: anchored, alternation is possessive
|
|
51
|
+
if (!slugPattern.test(slug)) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
error: `Invalid slug format '${slug}'. Only lowercase letters, numbers, and hyphens allowed.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { valid: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read research document from file
|
|
63
|
+
*
|
|
64
|
+
* @param {string} featureSlug - Feature slug (must match /^[a-z0-9-]+$/)
|
|
65
|
+
* @returns {{success: boolean, content?: string, path?: string, error?: string}} Research document result
|
|
66
|
+
* @example
|
|
67
|
+
* const research = readResearchDoc('payment-integration');
|
|
68
|
+
* if (research.success) {
|
|
69
|
+
* console.log(research.content);
|
|
70
|
+
* }
|
|
71
|
+
*/
|
|
72
|
+
function readResearchDoc(featureSlug) {
|
|
73
|
+
// Validate slug format (OWASP A03 - Injection prevention)
|
|
74
|
+
const validation = validateFeatureSlug(featureSlug);
|
|
75
|
+
if (!validation.valid) {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
error: validation.error,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const researchPath = path.join(process.cwd(), 'docs', 'research', `${featureSlug}.md`);
|
|
84
|
+
|
|
85
|
+
if (!fs.existsSync(researchPath)) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
error: `Research document not found: ${researchPath}\n\nRun /research ${featureSlug} first to create research document.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check file size to prevent resource exhaustion (max 5MB)
|
|
93
|
+
const stats = fs.statSync(researchPath);
|
|
94
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: `Research document too large (${Math.round(stats.size / 1024 / 1024)}MB). Maximum size: 5MB`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const content = fs.readFileSync(researchPath, 'utf8');
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
success: true,
|
|
105
|
+
content,
|
|
106
|
+
path: `docs/research/${featureSlug}.md`,
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
error: `Failed to read research document: ${error.message}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detect scope from research content
|
|
118
|
+
* Determines whether feature is tactical (quick fix) or strategic (architecture change)
|
|
119
|
+
*
|
|
120
|
+
* Detection strategy:
|
|
121
|
+
* 1. Check explicit "Scope Assessment" section first
|
|
122
|
+
* 2. Fall back to keyword analysis for strategic indicators
|
|
123
|
+
* 3. Default to tactical if no clear signals
|
|
124
|
+
*
|
|
125
|
+
* @param {string} researchContent - Research document markdown content
|
|
126
|
+
* @returns {{type: 'tactical'|'strategic', requiresOpenSpec: boolean, reason: string}} Scope analysis result
|
|
127
|
+
* @example
|
|
128
|
+
* const scope = detectScope(researchMarkdown);
|
|
129
|
+
* if (scope.type === 'strategic') {
|
|
130
|
+
* console.log('Requires OpenSpec proposal:', scope.reason);
|
|
131
|
+
* }
|
|
132
|
+
*/
|
|
133
|
+
function detectScope(researchContent) {
|
|
134
|
+
if (!researchContent || typeof researchContent !== 'string') {
|
|
135
|
+
return {
|
|
136
|
+
type: 'tactical',
|
|
137
|
+
requiresOpenSpec: false,
|
|
138
|
+
reason: 'Invalid research content (defaulting to tactical)',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check explicit Scope Assessment section (highest priority)
|
|
143
|
+
// Use line-by-line parsing to avoid ReDoS
|
|
144
|
+
const lines = researchContent.split('\n');
|
|
145
|
+
let scopeLine = null;
|
|
146
|
+
let inScopeSection = false;
|
|
147
|
+
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
if (/##\s*Scope Assessment/i.test(line)) {
|
|
150
|
+
inScopeSection = true;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (inScopeSection && /^##\s+/.test(line)) {
|
|
154
|
+
// Hit next section, stop searching
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (inScopeSection && /\*\*Strategic\/Tactical\*\*:\s*(Strategic|Tactical)/i.test(line)) {
|
|
158
|
+
scopeLine = line;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const scopeMatch = scopeLine ? /\*\*Strategic\/Tactical\*\*:\s*(Strategic|Tactical)/i.exec(scopeLine) : null;
|
|
164
|
+
|
|
165
|
+
if (scopeMatch) {
|
|
166
|
+
const type = scopeMatch[1].toLowerCase();
|
|
167
|
+
return {
|
|
168
|
+
type,
|
|
169
|
+
requiresOpenSpec: type === 'strategic',
|
|
170
|
+
reason: `Explicit scope declaration: ${scopeMatch[1]}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Strategic keyword detection (fallback)
|
|
175
|
+
const strategicKeywords = [
|
|
176
|
+
'architecture',
|
|
177
|
+
'architectural',
|
|
178
|
+
'database schema',
|
|
179
|
+
'api endpoint',
|
|
180
|
+
'major',
|
|
181
|
+
'breaking change',
|
|
182
|
+
'migration',
|
|
183
|
+
'refactor',
|
|
184
|
+
'redesign',
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
const lowerContent = researchContent.toLowerCase();
|
|
188
|
+
const foundKeywords = strategicKeywords.filter(keyword => lowerContent.includes(keyword));
|
|
189
|
+
|
|
190
|
+
if (foundKeywords.length > 0) {
|
|
191
|
+
return {
|
|
192
|
+
type: 'strategic',
|
|
193
|
+
requiresOpenSpec: true,
|
|
194
|
+
reason: `Strategic keywords detected: ${foundKeywords.join(', ')}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Default to tactical (safe fallback)
|
|
199
|
+
return {
|
|
200
|
+
type: 'tactical',
|
|
201
|
+
requiresOpenSpec: false,
|
|
202
|
+
reason: 'No strategic indicators found (tactical by default)',
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Create Beads issue for the feature
|
|
208
|
+
* Executes `bd create` command with appropriate description based on scope
|
|
209
|
+
*
|
|
210
|
+
* Security: Uses execFileSync (not exec) to prevent command injection
|
|
211
|
+
*
|
|
212
|
+
* @param {string} featureName - Feature name (human-readable)
|
|
213
|
+
* @param {string} researchPath - Research document path (e.g., "docs/research/feature.md")
|
|
214
|
+
* @param {'tactical'|'strategic'} scope - Scope type
|
|
215
|
+
* @returns {{success: boolean, issueId?: string, description?: string, error?: string}} Beads creation result
|
|
216
|
+
* @example
|
|
217
|
+
* const result = createBeadsIssue('Payment Integration', 'docs/research/payment.md', 'strategic');
|
|
218
|
+
* if (result.success) {
|
|
219
|
+
* console.log('Created issue:', result.issueId);
|
|
220
|
+
* }
|
|
221
|
+
*/
|
|
222
|
+
function createBeadsIssue(featureName, researchPath, scope) {
|
|
223
|
+
if (!featureName || !researchPath) {
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: 'Feature name and research path are required',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (scope !== 'tactical' && scope !== 'strategic') {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
error: `Invalid scope '${scope}'. Must be 'tactical' or 'strategic'`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
let description = `Research: ${researchPath}`;
|
|
239
|
+
|
|
240
|
+
if (scope === 'strategic') {
|
|
241
|
+
// Sanitize derived slug: keep only safe characters (OWASP A03)
|
|
242
|
+
const featureSlug = featureName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/(?:^-+|-+$)/g, '').slice(0, MAX_SLUG_LENGTH); // NOSONAR S5852 - non-overlapping anchors, no backtracking
|
|
243
|
+
const slugValidation = validateFeatureSlug(featureSlug);
|
|
244
|
+
if (!slugValidation.valid) {
|
|
245
|
+
return { success: false, error: `Cannot generate valid slug from feature name: ${slugValidation.error}` };
|
|
246
|
+
}
|
|
247
|
+
description += `\n\nOpenSpec: openspec/changes/${featureSlug}/`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Execute bd create command using execFileSync for safety (OWASP A03)
|
|
251
|
+
const result = execFileSync( // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
252
|
+
'bd', // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
253
|
+
['create', `--title=${featureName}`, `--description=${description}`, '--type=feature', '--priority=2'],
|
|
254
|
+
getExecOptions()
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// Extract issue ID from output (format: "Created issue: forge-xxx")
|
|
258
|
+
const match = /Created issue:\s*(forge-[a-z0-9]+)/i.exec(result) || /(forge-[a-z0-9]+)/.exec(result);
|
|
259
|
+
|
|
260
|
+
if (!match) {
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
error: 'Failed to extract issue ID from bd create output\n\nEnsure beads is installed: bunx beads init',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
success: true,
|
|
269
|
+
issueId: match[1],
|
|
270
|
+
description,
|
|
271
|
+
};
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// Check for timeout
|
|
274
|
+
if (error.killed && error.signal === 'SIGTERM') {
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
error: 'Beads command timed out after 2 minutes.',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Provide actionable error message
|
|
282
|
+
const bdNotFound = error.message.includes('ENOENT') || error.message.includes('not found');
|
|
283
|
+
const errorMsg = bdNotFound
|
|
284
|
+
? 'beads (bd) command not found. Install with: bunx beads init'
|
|
285
|
+
: `Failed to create Beads issue: ${error.message}`;
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
success: false,
|
|
289
|
+
error: errorMsg,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create feature branch
|
|
296
|
+
* Creates and checks out a new git branch following feat/<slug> convention
|
|
297
|
+
*
|
|
298
|
+
* Security: Uses execFileSync with array args to prevent command injection
|
|
299
|
+
*
|
|
300
|
+
* @param {string} featureSlug - Feature slug (must match /^[a-z0-9-]+$/)
|
|
301
|
+
* @returns {{success: boolean, branchName?: string, error?: string}} Branch creation result
|
|
302
|
+
* @example
|
|
303
|
+
* const result = createFeatureBranch('payment-integration');
|
|
304
|
+
* if (result.success) {
|
|
305
|
+
* console.log('Created branch:', result.branchName);
|
|
306
|
+
* }
|
|
307
|
+
*/
|
|
308
|
+
function createFeatureBranch(featureSlug) {
|
|
309
|
+
// Validate slug format (OWASP A03 - Injection prevention)
|
|
310
|
+
const validation = validateFeatureSlug(featureSlug);
|
|
311
|
+
if (!validation.valid) {
|
|
312
|
+
return {
|
|
313
|
+
success: false,
|
|
314
|
+
error: validation.error,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const branchName = `feat/${featureSlug}`;
|
|
320
|
+
|
|
321
|
+
// Check if branch already exists
|
|
322
|
+
try {
|
|
323
|
+
execFileSync('git', ['rev-parse', '--verify', branchName], { ...getExecOptions(), stdio: 'pipe' }); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
error: `Branch ${branchName} already exists\n\nSwitch to it with: git checkout ${branchName}`,
|
|
327
|
+
};
|
|
328
|
+
} catch {
|
|
329
|
+
// Branch doesn't exist, continue (expected case)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Create and checkout branch
|
|
333
|
+
execFileSync('git', ['checkout', '-b', branchName], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
branchName,
|
|
338
|
+
};
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// Check for timeout
|
|
341
|
+
if (error.killed && error.signal === 'SIGTERM') {
|
|
342
|
+
return {
|
|
343
|
+
success: false,
|
|
344
|
+
error: 'Git command timed out after 2 minutes.',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Provide actionable error message
|
|
349
|
+
const gitNotFound = error.message.includes('ENOENT') || error.message.includes('not found');
|
|
350
|
+
const errorMsg = gitNotFound
|
|
351
|
+
? 'git command not found. Ensure git is installed and in PATH'
|
|
352
|
+
: `Failed to create branch: ${error.message}`;
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
success: false,
|
|
356
|
+
error: errorMsg,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Extract design decisions from research content
|
|
363
|
+
* Parses "Key Decisions" section from research markdown
|
|
364
|
+
*
|
|
365
|
+
* Supports two formats:
|
|
366
|
+
* 1. Numbered with reasoning: "### Decision 1: Title\n**Reasoning**: ..."
|
|
367
|
+
* 2. Simple headings: "### Decision Title"
|
|
368
|
+
*
|
|
369
|
+
* @param {string} researchContent - Research document markdown content
|
|
370
|
+
* @returns {{decisions: string[]}} Extracted design decisions
|
|
371
|
+
* @example
|
|
372
|
+
* const design = extractDesignDecisions(researchMarkdown);
|
|
373
|
+
* design.decisions.forEach(d => console.log(d));
|
|
374
|
+
*/
|
|
375
|
+
function extractDesignDecisions(researchContent) { // NOSONAR S3776
|
|
376
|
+
if (!researchContent || typeof researchContent !== 'string') {
|
|
377
|
+
return { decisions: [] };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const decisions = [];
|
|
381
|
+
|
|
382
|
+
// Match numbered decisions with reasoning (preferred format)
|
|
383
|
+
const decisionPattern = /###\s*Decision\s*\d+:\s*([^\r\n]+)[\r\n]+\*\*Reasoning\*\*:\s*([^\r\n]+)/gi; // NOSONAR S5852 - uses [^\r\n]+ (bounded), handles both LF and CRLF
|
|
384
|
+
let match;
|
|
385
|
+
|
|
386
|
+
while ((match = decisionPattern.exec(researchContent)) !== null) {
|
|
387
|
+
decisions.push(`${match[1]}\nReasoning: ${match[2]}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Fall back to simpler format if no numbered decisions found
|
|
391
|
+
// Uses line-by-line parsing to avoid ReDoS from [\s\S]*? patterns
|
|
392
|
+
if (decisions.length === 0) {
|
|
393
|
+
const lines = researchContent.split('\n');
|
|
394
|
+
let inDecisionsSection = false;
|
|
395
|
+
for (const line of lines) {
|
|
396
|
+
if (/^##\s+Key Decisions/i.test(line)) {
|
|
397
|
+
inDecisionsSection = true;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (inDecisionsSection && /^##\s+/.test(line)) break;
|
|
401
|
+
if (inDecisionsSection && /^###\s+/.test(line)) {
|
|
402
|
+
const cleaned = line.replace(/^###\s+/, '').trim();
|
|
403
|
+
if (cleaned) decisions.push(cleaned);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
decisions,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Extract TDD tasks from research content
|
|
415
|
+
* Parses "TDD Test Scenarios" section and converts to RED-GREEN-REFACTOR tasks
|
|
416
|
+
*
|
|
417
|
+
* Each scenario generates 3 tasks:
|
|
418
|
+
* - RED: Write test for scenario
|
|
419
|
+
* - GREEN: Implement scenario
|
|
420
|
+
* - REFACTOR: Refactor scenario code
|
|
421
|
+
*
|
|
422
|
+
* @param {string} researchContent - Research document markdown content
|
|
423
|
+
* @returns {Array<{phase: 'RED'|'GREEN'|'REFACTOR', description: string}>} TDD-ordered tasks
|
|
424
|
+
* @example
|
|
425
|
+
* const tasks = extractTasksFromResearch(researchMarkdown);
|
|
426
|
+
* tasks.forEach(t => console.log(`[${t.phase}] ${t.description}`));
|
|
427
|
+
*/
|
|
428
|
+
function extractTasksFromResearch(researchContent) {
|
|
429
|
+
if (!researchContent || typeof researchContent !== 'string') {
|
|
430
|
+
return [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const tasks = [];
|
|
434
|
+
|
|
435
|
+
// Split by lines to avoid ReDoS - safer than complex regex
|
|
436
|
+
const lines = researchContent.split('\n');
|
|
437
|
+
let inScenariosSection = false;
|
|
438
|
+
const scenarioLines = [];
|
|
439
|
+
|
|
440
|
+
for (const line of lines) {
|
|
441
|
+
if (/##[^#]*TDD Test Scenarios/i.test(line)) {
|
|
442
|
+
inScenariosSection = true;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (inScenariosSection && /^##\s+[^#]/.test(line)) {
|
|
446
|
+
// Hit next top-level section (## but not ###), stop
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (inScenariosSection) {
|
|
450
|
+
scenarioLines.push(line);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Extract scenario headings (safe - simple pattern, no backtracking)
|
|
455
|
+
const scenarioPattern = /###\s*Scenario\s*\d+:\s*([^\n]+)/gi;
|
|
456
|
+
const sectionContent = scenarioLines.join('\n');
|
|
457
|
+
let match;
|
|
458
|
+
|
|
459
|
+
while ((match = scenarioPattern.exec(sectionContent)) !== null) {
|
|
460
|
+
const scenario = match[1].trim();
|
|
461
|
+
|
|
462
|
+
if (!scenario) {
|
|
463
|
+
continue; // Skip empty scenarios
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Each scenario becomes 3 tasks (RED-GREEN-REFACTOR cycle)
|
|
467
|
+
tasks.push(
|
|
468
|
+
{ phase: 'RED', description: `Write test: ${scenario}` },
|
|
469
|
+
{ phase: 'GREEN', description: `Implement: ${scenario}` },
|
|
470
|
+
{ phase: 'REFACTOR', description: `Refactor: ${scenario}` },
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return tasks;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Detect DRY (Don't Repeat Yourself) violations during Phase 2 codebase exploration
|
|
479
|
+
* Checks whether an existing implementation already covers the planned feature.
|
|
480
|
+
* If a match is found, the design doc's approach section must be updated to
|
|
481
|
+
* "extend existing [file/function]" rather than "create new".
|
|
482
|
+
*
|
|
483
|
+
* @param {{ searchTerm: string, matches: Array<{ file: string, line: number }> }} params
|
|
484
|
+
* @returns {{ violation: boolean, existingFile?: string, existingLine?: number }}
|
|
485
|
+
* @example
|
|
486
|
+
* const result = detectDRYViolation({ searchTerm: 'validateSlug', matches: [{ file: 'lib/utils.js', line: 42 }] });
|
|
487
|
+
* // => { violation: true, existingFile: 'lib/utils.js', existingLine: 42 }
|
|
488
|
+
*
|
|
489
|
+
* const empty = detectDRYViolation({ searchTerm: 'foo', matches: [] });
|
|
490
|
+
* // => { violation: false }
|
|
491
|
+
*/
|
|
492
|
+
function detectDRYViolation({ searchTerm: _searchTerm, matches } = {}) {
|
|
493
|
+
if (!Array.isArray(matches) || matches.length === 0) {
|
|
494
|
+
return { violation: false };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const first = matches[0];
|
|
498
|
+
return {
|
|
499
|
+
violation: true,
|
|
500
|
+
existingFile: first.file,
|
|
501
|
+
existingLine: first.line,
|
|
502
|
+
allMatches: matches,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Apply YAGNI (You Aren't Gonna Need It) filter to planned tasks
|
|
508
|
+
* Checks whether each task maps to a specific requirement, success criterion,
|
|
509
|
+
* or edge case in the design doc. Tasks with no anchor are flagged as potential scope creep.
|
|
510
|
+
*
|
|
511
|
+
* Supports two call modes:
|
|
512
|
+
* - Single task: { task: string, designDoc: string } → { flagged, anchor?, reason? }
|
|
513
|
+
* - Multi-task: { tasks: string[], designDoc: string } → { allFlagged, flaggedTasks, message? }
|
|
514
|
+
*
|
|
515
|
+
* Matching logic: any keyword from the task title found in the designDoc (case-insensitive).
|
|
516
|
+
*
|
|
517
|
+
* @param {{ task?: string, tasks?: string[], designDoc: string }} params
|
|
518
|
+
* @returns {{ flagged?: boolean, anchor?: string, reason?: string, allFlagged?: boolean, flaggedTasks?: string[], message?: string }}
|
|
519
|
+
* @example
|
|
520
|
+
* applyYAGNIFilter({ task: 'Add validateSlug function', designDoc: '## Success Criteria\n- validateSlug validates slug format' })
|
|
521
|
+
* // => { flagged: false, anchor: 'validateSlug' }
|
|
522
|
+
*
|
|
523
|
+
* applyYAGNIFilter({ task: 'Add dark mode toggle', designDoc: '## Success Criteria\n- validateSlug validates slug format' })
|
|
524
|
+
* // => { flagged: true, reason: 'No matching requirement found in design doc' }
|
|
525
|
+
*
|
|
526
|
+
* applyYAGNIFilter({ tasks: ['Task A', 'Task B'], designDoc: '## Purpose\nFoo' })
|
|
527
|
+
* // => { allFlagged: true, flaggedTasks: ['Task A', 'Task B'], message: "Design doc doesn't cover all tasks — needs amendment" }
|
|
528
|
+
*
|
|
529
|
+
* applyYAGNIFilter({ tasks: ['validateSlug function', 'dark mode toggle'], designDoc: '## Success Criteria\n- validateSlug validates slug' })
|
|
530
|
+
* // => { allFlagged: false, flaggedTasks: ['dark mode toggle'] }
|
|
531
|
+
*/
|
|
532
|
+
const YAGNI_STOP_WORDS = new Set(['a', 'an', 'the', 'add', 'in', 'of', 'to', 'for', 'is', 'it', 'and', 'or', 'with', 'that', 'this', 'be', 'as', 'by', 'at', 'on', 'if']);
|
|
533
|
+
|
|
534
|
+
function meaningfulKeywords(title) {
|
|
535
|
+
return String(title).toLowerCase().split(/\s+/)
|
|
536
|
+
.map(kw => kw.replace(/^[`'"()[\]{},]+|[`'"()[\]{},.:;!?]+$/g, '')) // NOSONAR S5852 — literal character class, no backtracking
|
|
537
|
+
.filter(kw => kw.length > 2 && !YAGNI_STOP_WORDS.has(kw));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
|
|
541
|
+
const lowerDoc = typeof designDoc === 'string' ? designDoc.toLowerCase() : '';
|
|
542
|
+
|
|
543
|
+
// Multi-task mode: returns allFlagged + flaggedTasks list for partial violations
|
|
544
|
+
if (Array.isArray(tasks)) {
|
|
545
|
+
if (tasks.length === 0) return { allFlagged: false, flaggedTasks: [] };
|
|
546
|
+
|
|
547
|
+
const flaggedTasks = tasks.filter(t => {
|
|
548
|
+
const keywords = meaningfulKeywords(t);
|
|
549
|
+
return keywords.length === 0 || !keywords.some(kw => lowerDoc.includes(kw));
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
if (flaggedTasks.length === tasks.length) {
|
|
553
|
+
return {
|
|
554
|
+
allFlagged: true,
|
|
555
|
+
flaggedTasks,
|
|
556
|
+
message: "Design doc doesn't cover all tasks — needs amendment",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return { allFlagged: false, flaggedTasks };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Single-task mode
|
|
564
|
+
const keywords = meaningfulKeywords(task || '');
|
|
565
|
+
const matchedKeyword = keywords.find(kw => lowerDoc.includes(kw));
|
|
566
|
+
|
|
567
|
+
if (matchedKeyword) {
|
|
568
|
+
return { flagged: false, anchor: matchedKeyword };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { flagged: true, reason: 'No matching requirement found in design doc' };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Execute full plan workflow
|
|
576
|
+
* Orchestrates tactical or strategic planning workflow
|
|
577
|
+
*
|
|
578
|
+
* Tactical workflow (quick fixes, <1 day):
|
|
579
|
+
* 1. Read research document
|
|
580
|
+
* 2. Detect scope (tactical)
|
|
581
|
+
* 3. Create Beads issue
|
|
582
|
+
* 4. Create feature branch
|
|
583
|
+
* → Next: /dev command
|
|
584
|
+
*
|
|
585
|
+
* Strategic workflow (architecture changes, >1 day):
|
|
586
|
+
* 1. Read research document
|
|
587
|
+
* 2. Detect scope (strategic)
|
|
588
|
+
* 3. Create Beads issue with OpenSpec link
|
|
589
|
+
* 4. Create feature branch
|
|
590
|
+
* 5. Generate OpenSpec proposal (proposal.md, tasks.md, design.md)
|
|
591
|
+
* 6. Commit and push proposal
|
|
592
|
+
* 7. Create proposal PR
|
|
593
|
+
* → Next: Wait for proposal approval
|
|
594
|
+
*
|
|
595
|
+
* @param {string} featureName - Feature name (human-readable, e.g., "Payment Integration")
|
|
596
|
+
* @returns {Promise<{
|
|
597
|
+
* success: boolean,
|
|
598
|
+
* scope?: 'tactical'|'strategic',
|
|
599
|
+
* beadsIssueId?: string,
|
|
600
|
+
* branchName?: string,
|
|
601
|
+
* openSpecCreated?: boolean,
|
|
602
|
+
* proposalPR?: {url: string, number: number},
|
|
603
|
+
* summary?: string,
|
|
604
|
+
* nextCommand?: string,
|
|
605
|
+
* error?: string
|
|
606
|
+
* }>} Execution result
|
|
607
|
+
* @example
|
|
608
|
+
* const result = await executePlan('Payment Integration');
|
|
609
|
+
* if (result.success) {
|
|
610
|
+
* console.log(result.summary);
|
|
611
|
+
* console.log('Next:', result.nextCommand);
|
|
612
|
+
* }
|
|
613
|
+
*/
|
|
614
|
+
async function executePlan(featureName) { // NOSONAR S3776
|
|
615
|
+
if (!featureName || typeof featureName !== 'string') {
|
|
616
|
+
return {
|
|
617
|
+
success: false,
|
|
618
|
+
error: 'Feature name is required and must be a string',
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const featureSlug = featureName.toLowerCase()
|
|
623
|
+
.replaceAll(/[^a-z0-9-]/g, '-')
|
|
624
|
+
.split('-').filter(Boolean).join('-');
|
|
625
|
+
|
|
626
|
+
// Validate generated slug
|
|
627
|
+
const validation = validateFeatureSlug(featureSlug);
|
|
628
|
+
if (!validation.valid) {
|
|
629
|
+
return {
|
|
630
|
+
success: false,
|
|
631
|
+
error: `Invalid feature name generates invalid slug '${featureSlug}': ${validation.error}`,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
// Step 1: Read research document
|
|
637
|
+
const research = readResearchDoc(featureSlug);
|
|
638
|
+
if (!research.success) {
|
|
639
|
+
return {
|
|
640
|
+
success: false,
|
|
641
|
+
error: `Research document not found: ${research.error}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Step 2: Detect scope (tactical vs strategic)
|
|
646
|
+
const scope = detectScope(research.content);
|
|
647
|
+
|
|
648
|
+
// Step 3: Create Beads issue
|
|
649
|
+
const beads = createBeadsIssue(featureName, research.path, scope.type);
|
|
650
|
+
if (!beads.success) {
|
|
651
|
+
return {
|
|
652
|
+
success: false,
|
|
653
|
+
error: `Failed to create Beads issue: ${beads.error}`,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Step 4: Create feature branch
|
|
658
|
+
const branch = createFeatureBranch(featureSlug);
|
|
659
|
+
if (!branch.success) {
|
|
660
|
+
return {
|
|
661
|
+
success: false,
|
|
662
|
+
error: `Failed to create branch: ${branch.error}`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Build result summary
|
|
667
|
+
const result = {
|
|
668
|
+
success: true,
|
|
669
|
+
scope: scope.type,
|
|
670
|
+
beadsIssueId: beads.issueId,
|
|
671
|
+
branchName: branch.branchName,
|
|
672
|
+
summary: `Plan created for ${featureName} (${scope.type} scope)`,
|
|
673
|
+
// Strategic path: /propose not yet implemented — falls through to /dev until it is
|
|
674
|
+
nextCommand: '/dev',
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return result;
|
|
678
|
+
} catch (error) {
|
|
679
|
+
return {
|
|
680
|
+
success: false,
|
|
681
|
+
error: `Unexpected error in executePlan: ${error.message}`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
module.exports = {
|
|
687
|
+
readResearchDoc,
|
|
688
|
+
detectScope,
|
|
689
|
+
createBeadsIssue,
|
|
690
|
+
createFeatureBranch,
|
|
691
|
+
extractDesignDecisions,
|
|
692
|
+
extractTasksFromResearch,
|
|
693
|
+
detectDRYViolation,
|
|
694
|
+
applyYAGNIFilter,
|
|
695
|
+
executePlan,
|
|
696
|
+
};
|