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.
Files changed (105) hide show
  1. package/.claude/commands/dev.md +314 -0
  2. package/.claude/commands/plan.md +389 -0
  3. package/.claude/commands/premerge.md +179 -0
  4. package/.claude/commands/research.md +42 -0
  5. package/.claude/commands/review.md +442 -0
  6. package/.claude/commands/rollback.md +721 -0
  7. package/.claude/commands/ship.md +134 -0
  8. package/.claude/commands/sonarcloud.md +152 -0
  9. package/.claude/commands/status.md +77 -0
  10. package/.claude/commands/validate.md +237 -0
  11. package/.claude/commands/verify.md +221 -0
  12. package/.claude/rules/greptile-review-process.md +285 -0
  13. package/.claude/rules/workflow.md +105 -0
  14. package/.claude/scripts/greptile-resolve.sh +526 -0
  15. package/.claude/scripts/load-env.sh +32 -0
  16. package/.forge/hooks/check-tdd.js +240 -0
  17. package/.github/PLUGIN_TEMPLATE.json +32 -0
  18. package/.mcp.json.example +12 -0
  19. package/AGENTS.md +169 -0
  20. package/CLAUDE.md +99 -0
  21. package/LICENSE +21 -0
  22. package/README.md +414 -0
  23. package/bin/forge-cmd.js +313 -0
  24. package/bin/forge-validate.js +303 -0
  25. package/bin/forge.js +4228 -0
  26. package/docs/AGENT_INSTALL_PROMPT.md +342 -0
  27. package/docs/ENHANCED_ONBOARDING.md +602 -0
  28. package/docs/EXAMPLES.md +482 -0
  29. package/docs/GREPTILE_SETUP.md +400 -0
  30. package/docs/MANUAL_REVIEW_GUIDE.md +106 -0
  31. package/docs/ROADMAP.md +359 -0
  32. package/docs/SETUP.md +632 -0
  33. package/docs/TOOLCHAIN.md +849 -0
  34. package/docs/VALIDATION.md +363 -0
  35. package/docs/WORKFLOW.md +400 -0
  36. package/docs/planning/PROGRESS.md +396 -0
  37. package/docs/plans/.gitkeep +0 -0
  38. package/docs/plans/2026-02-27-forge-test-suite-v2-decisions.md +21 -0
  39. package/docs/plans/2026-02-27-forge-test-suite-v2-design.md +362 -0
  40. package/docs/plans/2026-02-27-forge-test-suite-v2-tasks.md +343 -0
  41. package/docs/plans/2026-03-02-superpowers-gaps-decisions.md +26 -0
  42. package/docs/plans/2026-03-02-superpowers-gaps-design.md +239 -0
  43. package/docs/plans/2026-03-02-superpowers-gaps-tasks.md +260 -0
  44. package/docs/plans/2026-03-04-agent-command-parity-design.md +163 -0
  45. package/docs/plans/2026-03-04-verify-worktree-cleanup-decisions.md +7 -0
  46. package/docs/plans/2026-03-04-verify-worktree-cleanup-design.md +165 -0
  47. package/docs/plans/2026-03-05-forge-uto-decisions.md +6 -0
  48. package/docs/plans/2026-03-05-forge-uto-design.md +116 -0
  49. package/docs/plans/2026-03-05-forge-uto-tasks.md +244 -0
  50. package/docs/plans/2026-03-10-command-creator-and-eval-decisions.md +52 -0
  51. package/docs/plans/2026-03-10-command-creator-and-eval-design.md +350 -0
  52. package/docs/plans/2026-03-10-command-creator-and-eval-tasks.md +426 -0
  53. package/docs/plans/2026-03-10-stale-workflow-refs-decisions.md +8 -0
  54. package/docs/plans/2026-03-10-stale-workflow-refs-design.md +80 -0
  55. package/docs/plans/2026-03-10-stale-workflow-refs-tasks.md +90 -0
  56. package/docs/plans/2026-03-14-beads-plan-context-decisions.md +9 -0
  57. package/docs/plans/2026-03-14-beads-plan-context-design.md +171 -0
  58. package/docs/plans/2026-03-14-beads-plan-context-tasks.md +160 -0
  59. package/docs/plans/2026-03-14-skill-eval-loop-decisions.md +33 -0
  60. package/docs/plans/2026-03-14-skill-eval-loop-design.md +118 -0
  61. package/docs/plans/2026-03-14-skill-eval-loop-results.md +78 -0
  62. package/docs/plans/2026-03-14-skill-eval-loop-tasks.md +160 -0
  63. package/docs/plans/2026-03-15-agent-command-parity-v2-decisions.md +11 -0
  64. package/docs/plans/2026-03-15-agent-command-parity-v2-design.md +145 -0
  65. package/docs/plans/2026-03-15-agent-command-parity-v2-tasks.md +211 -0
  66. package/docs/research/TEMPLATE.md +292 -0
  67. package/docs/research/advanced-testing.md +297 -0
  68. package/docs/research/agent-permissions.md +167 -0
  69. package/docs/research/dependency-chain.md +328 -0
  70. package/docs/research/forge-workflow-v2.md +550 -0
  71. package/docs/research/plugin-architecture.md +772 -0
  72. package/docs/research/pr4-cli-automation.md +326 -0
  73. package/docs/research/premerge-verify-restructure.md +205 -0
  74. package/docs/research/skills-restructure.md +508 -0
  75. package/docs/research/sonarcloud-perfection-plan.md +166 -0
  76. package/docs/research/sonarcloud-quality-gate.md +184 -0
  77. package/docs/research/superpowers-integration.md +403 -0
  78. package/docs/research/superpowers.md +319 -0
  79. package/docs/research/test-environment.md +519 -0
  80. package/install.sh +1062 -0
  81. package/lefthook.yml +39 -0
  82. package/lib/agents/README.md +198 -0
  83. package/lib/agents/claude.plugin.json +28 -0
  84. package/lib/agents/cline.plugin.json +22 -0
  85. package/lib/agents/codex.plugin.json +19 -0
  86. package/lib/agents/copilot.plugin.json +24 -0
  87. package/lib/agents/cursor.plugin.json +25 -0
  88. package/lib/agents/kilocode.plugin.json +22 -0
  89. package/lib/agents/opencode.plugin.json +20 -0
  90. package/lib/agents/roo.plugin.json +23 -0
  91. package/lib/agents-config.js +2112 -0
  92. package/lib/commands/dev.js +513 -0
  93. package/lib/commands/plan.js +696 -0
  94. package/lib/commands/recommend.js +119 -0
  95. package/lib/commands/ship.js +377 -0
  96. package/lib/commands/status.js +378 -0
  97. package/lib/commands/validate.js +602 -0
  98. package/lib/context-merge.js +359 -0
  99. package/lib/plugin-catalog.js +360 -0
  100. package/lib/plugin-manager.js +166 -0
  101. package/lib/plugin-recommender.js +141 -0
  102. package/lib/project-discovery.js +491 -0
  103. package/lib/setup.js +118 -0
  104. package/lib/workflow-profiles.js +203 -0
  105. 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
+ };