forge-workflow 0.0.6 → 0.0.8

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 (55) hide show
  1. package/.cursorrules +149 -0
  2. package/bin/forge.js +43 -3
  3. package/lib/agents/README.md +46 -1
  4. package/lib/agents/cline.plugin.json +11 -4
  5. package/lib/agents/codex.plugin.json +2 -2
  6. package/lib/agents/copilot.plugin.json +5 -5
  7. package/lib/agents/cursor.plugin.json +1 -1
  8. package/lib/agents/kilocode.plugin.json +1 -1
  9. package/lib/agents/opencode.plugin.json +7 -4
  10. package/lib/agents/roo.plugin.json +10 -3
  11. package/lib/agents-config.js +127 -79
  12. package/lib/codex-skills.js +50 -0
  13. package/lib/commands/_issue.js +172 -0
  14. package/lib/commands/_registry.js +40 -1
  15. package/lib/commands/claim.js +5 -0
  16. package/lib/commands/close.js +5 -0
  17. package/lib/commands/commands-reset.js +147 -0
  18. package/lib/commands/create.js +5 -0
  19. package/lib/commands/dev.js +26 -0
  20. package/lib/commands/issue.js +5 -0
  21. package/lib/commands/list.js +5 -0
  22. package/lib/commands/plan.js +18 -0
  23. package/lib/commands/ready.js +5 -0
  24. package/lib/commands/setup.js +4295 -0
  25. package/lib/commands/ship.js +20 -0
  26. package/lib/commands/show.js +5 -0
  27. package/lib/commands/status.js +210 -44
  28. package/lib/commands/sync.js +19 -1
  29. package/lib/commands/update.js +5 -0
  30. package/lib/commands/validate.js +13 -0
  31. package/lib/detect-agent.js +38 -8
  32. package/lib/detection-utils.js +405 -0
  33. package/lib/file-utils.js +260 -0
  34. package/lib/forge-context.js +42 -0
  35. package/lib/frontmatter.js +79 -0
  36. package/lib/husky-migration.js +113 -12
  37. package/lib/lefthook-check.js +27 -6
  38. package/lib/plugin-manager.js +225 -72
  39. package/lib/project-discovery.js +39 -5
  40. package/lib/runtime-health.js +305 -0
  41. package/lib/shell-utils.js +50 -0
  42. package/lib/ui-utils.js +43 -0
  43. package/lib/validation-utils.js +163 -0
  44. package/lib/workflow/enforce-stage.js +179 -0
  45. package/lib/workflow/stages.js +201 -0
  46. package/lib/workflow/state.js +332 -0
  47. package/opencode.json +67 -0
  48. package/package.json +15 -5
  49. package/scripts/beads-context.sh +12 -4
  50. package/scripts/check-agents.js +103 -0
  51. package/scripts/lib/eval-runner.js +50 -0
  52. package/scripts/pr-coordinator.sh +71 -21
  53. package/scripts/smart-status.sh +21 -11
  54. package/scripts/sync-commands.js +49 -20
  55. package/scripts/test.js +16 -1
@@ -367,6 +367,26 @@ async function executeShip(options) {
367
367
  }
368
368
 
369
369
  module.exports = {
370
+ name: 'ship',
371
+ description: 'Create a pull request from validated feature work',
372
+ handler: async (args, flags = {}) => {
373
+ const result = await executeShip({
374
+ featureSlug: args[0],
375
+ title: args[1],
376
+ dryRun: Boolean(flags.dryRun || flags['--dry-run']),
377
+ });
378
+ if (!result.success) {
379
+ return result;
380
+ }
381
+
382
+ const lines = [result.message];
383
+ if (result.prUrl) lines.push(`PR: ${result.prUrl}`);
384
+
385
+ return {
386
+ ...result,
387
+ output: lines.join('\n'),
388
+ };
389
+ },
370
390
  extractKeyDecisions,
371
391
  extractTestScenarios,
372
392
  getTestCoverage,
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { makeAliasCommand } = require('./_issue');
4
+
5
+ module.exports = makeAliasCommand('show');
@@ -3,6 +3,13 @@
3
3
  * Detects workflow stage (1-9) with confidence scoring
4
4
  */
5
5
 
6
+ const { secureExecFileSync } = require('../shell-utils');
7
+ const {
8
+ readWorkflowState,
9
+ getAllowedTransitionsForWorkflowState,
10
+ } = require('../workflow/state');
11
+ const { STAGE_IDS } = require('../workflow/stages');
12
+
6
13
  const WORKFLOW_STAGES = {
7
14
  1: { name: 'Fresh Start', nextCommand: 'research' },
8
15
  2: { name: 'Research', nextCommand: 'research' },
@@ -15,6 +22,16 @@ const WORKFLOW_STAGES = {
15
22
  9: { name: 'Verification', nextCommand: 'verify' },
16
23
  };
17
24
 
25
+ const AUTHORITATIVE_STAGE_NAMES = {
26
+ plan: 'Planning',
27
+ dev: 'Development',
28
+ validate: 'Validation',
29
+ ship: 'Shipping',
30
+ review: 'Review',
31
+ premerge: 'Premerge',
32
+ verify: 'Verification',
33
+ };
34
+
18
35
  /**
19
36
  * Analyze branch state
20
37
  * @param {string} branch - Current branch name
@@ -302,63 +319,175 @@ function detectStage(context) {
302
319
  };
303
320
  }
304
321
 
305
- /**
306
- * Format status output
307
- * @param {object} result - Detection result
308
- * @returns {string} Formatted output
309
- */
310
- function formatStatus(result) {
311
- const lines = [];
322
+ function parseStatusInputs(args = [], flags = {}) {
323
+ const statusArgs = Array.isArray(args) ? args : [];
324
+ const getNextValue = (flagName) => {
325
+ const index = statusArgs.indexOf(flagName);
326
+ if (index !== -1 && index + 1 < statusArgs.length) {
327
+ return statusArgs[index + 1];
328
+ }
329
+ return null;
330
+ };
331
+ const getInlineValue = (flagName) => {
332
+ const prefix = `${flagName}=`;
333
+ const match = statusArgs.find(arg => typeof arg === 'string' && arg.startsWith(prefix));
334
+ return match ? match.slice(prefix.length) : null;
335
+ };
312
336
 
313
- // Header
314
- lines.push('');
315
- lines.push(`✓ Current Stage: ${result.stage} - ${result.stageName}`);
316
- lines.push(` Confidence: ${result.confidence.toUpperCase()} (${result.confidenceScore}%)`);
317
- lines.push('');
337
+ return {
338
+ issueId: flags.issueId || flags['--issue-id'] || getInlineValue('--issue-id') || getNextValue('--issue-id'),
339
+ workflowState: flags.workflowState || flags['--workflow-state'] || getInlineValue('--workflow-state') || getNextValue('--workflow-state'),
340
+ bdComments: flags.bdComments || flags['--bd-comments'] || getInlineValue('--bd-comments') || getNextValue('--bd-comments'),
341
+ };
342
+ }
318
343
 
319
- // Completed checks
320
- const completed = [];
321
- if (result.factors.files.hasResearch) {
322
- completed.push('✓ Research doc exists');
323
- }
324
- if (result.factors.files.hasPlan) {
325
- completed.push('✓ Plan created');
326
- }
327
- if (result.factors.branch.onFeatureBranch) {
328
- completed.push('✓ Feature branch created');
344
+ function extractWorkflowStateFromComments(comments = '') {
345
+ const matches = String(comments).match(/^WorkflowState:\s*(\{.*\})$/gm);
346
+ if (!matches || matches.length === 0) {
347
+ return null;
329
348
  }
330
- if (result.factors.files.hasTests) {
331
- completed.push('✓ Tests written');
349
+
350
+ const latest = matches.at(-1).replace(/^WorkflowState:\s*/, '');
351
+ return readWorkflowState(latest);
352
+ }
353
+
354
+ function readWorkflowStateFromBeads(issueId, options = {}) {
355
+ if (!issueId) {
356
+ return null;
332
357
  }
333
- if (result.factors.files.testsPass) {
334
- completed.push(' Tests passing');
358
+
359
+ const comments = options.comments || secureExecFileSync('bd', ['comments', 'list', issueId], {
360
+ encoding: 'utf8',
361
+ stdio: ['pipe', 'pipe', 'pipe'],
362
+ }).trim();
363
+
364
+ if (!comments) {
365
+ return null;
335
366
  }
336
- if (result.factors.checks.allChecksPass) {
337
- completed.push('✓ All checks passing');
367
+
368
+ return extractWorkflowStateFromComments(comments);
369
+ }
370
+
371
+ function resolveWorkflowState(inputs) {
372
+ try {
373
+ const workflowState = inputs.workflowState
374
+ ? readWorkflowState(inputs.workflowState)
375
+ : readWorkflowStateFromBeads(inputs.issueId, { comments: inputs.bdComments });
376
+ return { workflowState, fallbackReason: null };
377
+ } catch (error) {
378
+ return {
379
+ workflowState: null,
380
+ fallbackReason: error instanceof Error ? error.message : String(error),
381
+ };
338
382
  }
339
- if (result.factors.pr.hasPR) {
340
- completed.push(`✓ PR created (#${result.factors.pr.prNumber})`);
383
+ }
384
+
385
+ function buildAuthoritativeStatus(workflowState) {
386
+ const currentStage = workflowState.currentStage;
387
+ const stageIndex = STAGE_IDS.indexOf(currentStage);
388
+ const nextStages = getAllowedTransitionsForWorkflowState(workflowState);
389
+
390
+ return {
391
+ stage: stageIndex === -1 ? null : stageIndex + 1,
392
+ stageId: currentStage,
393
+ stageName: AUTHORITATIVE_STAGE_NAMES[currentStage] || currentStage,
394
+ confidence: 'high',
395
+ confidenceScore: 100,
396
+ runCommand: currentStage,
397
+ nextCommand: nextStages[0] || null,
398
+ nextStages,
399
+ authoritative: true,
400
+ workflowState,
401
+ factors: {
402
+ files: {
403
+ hasResearch: workflowState.completedStages.includes('plan'),
404
+ hasPlan: workflowState.completedStages.includes('plan') || currentStage !== 'plan',
405
+ testsPass: workflowState.completedStages.includes('validate'),
406
+ },
407
+ branch: {},
408
+ pr: {},
409
+ checks: { allChecksPass: workflowState.completedStages.includes('validate') },
410
+ beads: { hasActiveIssue: true },
411
+ },
412
+ };
413
+ }
414
+
415
+ function buildMissingWorkflowStateStatus() {
416
+ return {
417
+ stage: null,
418
+ stageId: null,
419
+ stageName: 'Unknown',
420
+ confidence: 'low',
421
+ confidenceScore: 0,
422
+ authoritative: false,
423
+ missingWorkflowState: true,
424
+ output: '\nNo authoritative workflow state available.\nProvide --workflow-state or --issue-id so /status can read recorded stage state.\n',
425
+ };
426
+ }
427
+
428
+ function buildHeaderLines(title, detailLines = []) {
429
+ return ['', title, ...detailLines, ''];
430
+ }
431
+
432
+ function buildSectionLines(title, items = []) {
433
+ if (items.length === 0) {
434
+ return [];
341
435
  }
342
- if (result.factors.pr.prApproved) {
343
- completed.push('✓ PR approved');
436
+
437
+ return [title, ...items.map(item => ` ${item}`), ''];
438
+ }
439
+
440
+ function collectCompletedChecks(result) {
441
+ const completed = [];
442
+
443
+ if (result.factors.files.hasResearch) completed.push('Research doc exists');
444
+ if (result.factors.files.hasPlan) completed.push('Plan created');
445
+ if (result.factors.branch.onFeatureBranch) completed.push('Feature branch created');
446
+ if (result.factors.files.hasTests) completed.push('Tests written');
447
+ if (result.factors.files.testsPass) completed.push('Tests passing');
448
+ if (result.factors.checks.allChecksPass) completed.push('All checks passing');
449
+ if (result.factors.pr.hasPR) completed.push(`PR created (#${result.factors.pr.prNumber})`);
450
+ if (result.factors.pr.prApproved) completed.push('PR approved');
451
+ if (result.factors.pr.prMerged) completed.push('PR merged');
452
+
453
+ return completed;
454
+ }
455
+
456
+ function formatAuthoritativeStatus(result) {
457
+ const completedStages = result.workflowState.completedStages.map(stageId => stageId);
458
+ const allowedCommands = result.nextStages.map(stageId => '/' + stageId).join(', ');
459
+ const nextLines = [`Run now: /${result.runCommand}`];
460
+
461
+ if (result.nextCommand) {
462
+ nextLines.push(`Next after this: /${result.nextCommand}`);
344
463
  }
345
- if (result.factors.pr.prMerged) {
346
- completed.push('✓ PR merged');
464
+ if (allowedCommands) {
465
+ nextLines.push(`Allowed after this: ${allowedCommands}`);
347
466
  }
348
467
 
349
- if (completed.length > 0) {
350
- lines.push('Completed:');
351
- completed.forEach(item => lines.push(` ${item}`));
352
- lines.push('');
353
- }
468
+ return [
469
+ ...buildHeaderLines(`Current Stage: ${result.stageId} - ${result.stageName}`, [
470
+ ' Source: authoritative workflow state',
471
+ ` Classification: ${result.workflowState.workflowDecisions.classification}`,
472
+ ]),
473
+ ...buildSectionLines('Completed stages:', completedStages),
474
+ ...nextLines,
475
+ '',
476
+ ].join('\n');
477
+ }
354
478
 
355
- // Next command
356
- lines.push(`Next: /${result.nextCommand}`);
357
- lines.push('');
479
+ function formatHeuristicStatus(result) {
480
+ const lines = [
481
+ ...buildHeaderLines(`Current Stage: ${result.stage} - ${result.stageName}`, [
482
+ ` Confidence: ${result.confidence.toUpperCase()} (${result.confidenceScore}%)`,
483
+ ]),
484
+ ...buildSectionLines('Completed:', collectCompletedChecks(result)),
485
+ `Next: /${result.nextCommand}`,
486
+ '',
487
+ ];
358
488
 
359
- // Low confidence warning
360
489
  if (result.confidence === 'low') {
361
- lines.push('⚠️ Low confidence - Manual verification suggested');
490
+ lines.push('Low confidence - Manual verification suggested');
362
491
  lines.push(' Conflicting signals detected. Please verify current stage.');
363
492
  lines.push('');
364
493
  }
@@ -366,7 +495,44 @@ function formatStatus(result) {
366
495
  return lines.join('\n');
367
496
  }
368
497
 
498
+ /**
499
+ * Format status output
500
+ * @param {object} result - Detection result
501
+ * @returns {string} Formatted output
502
+ */
503
+ function formatStatus(result) {
504
+ if (result.authoritative && result.workflowState) {
505
+ return formatAuthoritativeStatus(result);
506
+ }
507
+
508
+ return formatHeuristicStatus(result);
509
+ }
510
+
511
+
369
512
  module.exports = {
513
+ name: 'status',
514
+ description: 'Intelligent stage detection with confidence scoring',
515
+ handler: async (args, flags, _projectRoot) => {
516
+ const inputs = parseStatusInputs(args, flags);
517
+ const { workflowState, fallbackReason } = resolveWorkflowState(inputs);
518
+
519
+ if (workflowState) {
520
+ const result = buildAuthoritativeStatus(workflowState);
521
+ return { success: true, output: formatStatus(result), ...result };
522
+ }
523
+
524
+ return {
525
+ success: true,
526
+ ...buildMissingWorkflowStateStatus(),
527
+ ...(fallbackReason ? { fallbackReason } : {}),
528
+ };
529
+ },
530
+ buildAuthoritativeStatus,
531
+ buildMissingWorkflowStateStatus,
532
+ extractWorkflowStateFromComments,
533
+ resolveWorkflowState,
534
+ parseStatusInputs,
535
+ readWorkflowStateFromBeads,
370
536
  detectStage,
371
537
  analyzeBranch,
372
538
  analyzeFiles,
@@ -2,6 +2,17 @@
2
2
 
3
3
  const { execFileSync } = require('node:child_process');
4
4
 
5
+ function isRecoverableBeadsSyncError(error) {
6
+ const message = error?.message ?? String(error ?? '');
7
+ return (
8
+ message.includes('failed to open database') ||
9
+ message.includes('database not found') ||
10
+ message.includes('no beads configuration found') ||
11
+ message.includes('remote \'origin\' not found') ||
12
+ message.includes('remote "origin" not found')
13
+ );
14
+ }
15
+
5
16
  /**
6
17
  * Forge Sync Command
7
18
  * Syncs Beads issue data by running dolt pull + push.
@@ -30,7 +41,7 @@ module.exports = {
30
41
  // Step 1: Check if bd binary exists
31
42
  try {
32
43
  exec('bd', ['--version'], { stdio: 'pipe' });
33
- } catch (_checkErr) { /* intentional: bd not installed, skip sync gracefully */ // NOSONAR S2486
44
+ } catch { /* intentional: bd not installed, skip sync gracefully */ // NOSONAR S2486
34
45
  return {
35
46
  success: true,
36
47
  synced: false,
@@ -43,6 +54,13 @@ module.exports = {
43
54
  exec('bd', ['dolt', 'pull'], { stdio: 'pipe' });
44
55
  exec('bd', ['dolt', 'push'], { stdio: 'pipe' });
45
56
  } catch (syncErr) {
57
+ if (isRecoverableBeadsSyncError(syncErr)) {
58
+ return {
59
+ success: true,
60
+ synced: false,
61
+ message: 'Beads is installed but not initialized for sync in this worktree — skipping sync',
62
+ };
63
+ }
46
64
  return {
47
65
  success: false,
48
66
  synced: false,
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { makeAliasCommand } = require('./_issue');
4
+
5
+ module.exports = makeAliasCommand('update');
@@ -593,6 +593,19 @@ function executeDebugMode({ fixAttempts = 0, claim } = {}) {
593
593
  }
594
594
 
595
595
  module.exports = {
596
+ name: 'validate',
597
+ description: 'Run validation checks across type, lint, security, and tests',
598
+ handler: async () => {
599
+ const result = await executeValidate();
600
+ if (!result.success) {
601
+ return result;
602
+ }
603
+
604
+ return {
605
+ ...result,
606
+ output: result.summary,
607
+ };
608
+ },
596
609
  runTypeCheck,
597
610
  runLint,
598
611
  runSecurityScan,
@@ -13,6 +13,25 @@
13
13
  const fs = require('node:fs');
14
14
  const path = require('node:path');
15
15
 
16
+ const CANONICAL_AGENT_IDS = new Set([
17
+ 'claude',
18
+ 'cline',
19
+ 'codex',
20
+ 'copilot',
21
+ 'cursor',
22
+ 'kilocode',
23
+ 'opencode',
24
+ 'roo',
25
+ ]);
26
+
27
+ const LEGACY_AGENT_ALIASES = Object.freeze({
28
+ 'claude-code': 'claude',
29
+ 'github-copilot': 'copilot',
30
+ kilo: 'kilocode',
31
+ 'kilo-code': 'kilocode',
32
+ 'roo-code': 'roo',
33
+ });
34
+
16
35
  /**
17
36
  * Layer 2 mapping: env var → agent name.
18
37
  * Order matters — first match wins within this layer.
@@ -34,9 +53,9 @@ const AGENT_ENV_VARS = [
34
53
  // OpenCode
35
54
  ['OPENCODE_CLIENT', 'opencode'],
36
55
  // GitHub Copilot
37
- ['COPILOT_MODEL', 'github-copilot'],
38
- ['COPILOT_ALLOW_ALL', 'github-copilot'],
39
- ['COPILOT_GITHUB_TOKEN', 'github-copilot'],
56
+ ['COPILOT_MODEL', 'copilot'],
57
+ ['COPILOT_ALLOW_ALL', 'copilot'],
58
+ ['COPILOT_GITHUB_TOKEN', 'copilot'],
40
59
  // Cline, Roo Code, Kilocode — no env vars (VSCode extensions, config-file-only)
41
60
  ];
42
61
 
@@ -52,16 +71,26 @@ const CONFIG_SIGNATURES = [
52
71
  [path.join('.cursor', 'rules'), 'cursor'],
53
72
  ['.clinerules', 'cline'],
54
73
  ['.cline', 'cline'],
55
- [path.join('.roo', 'rules'), 'roo-code'],
56
- ['.roo', 'roo-code'],
74
+ [path.join('.roo', 'rules'), 'roo'],
75
+ ['.roo', 'roo'],
76
+ ['.roorules', 'roo'],
77
+ ['.kilo.md', 'kilocode'],
57
78
  ['.kilocode', 'kilocode'],
58
79
  ['codex.md', 'codex'],
59
80
  ['.codex', 'codex'],
60
81
  [path.join('.opencode', 'commands'), 'opencode'],
61
82
  ['.opencode', 'opencode'],
62
- [path.join('.github', 'copilot-instructions.md'), 'github-copilot'],
83
+ [path.join('.github', 'copilot-instructions.md'), 'copilot'],
63
84
  ];
64
85
 
86
+ function normalizeAgentId(agentId) {
87
+ if (!agentId) return agentId;
88
+ const trimmed = String(agentId).trim();
89
+ if (!trimmed) return trimmed;
90
+ const normalized = trimmed.toLowerCase();
91
+ return LEGACY_AGENT_ALIASES[normalized] || (CANONICAL_AGENT_IDS.has(normalized) ? normalized : trimmed);
92
+ }
93
+
65
94
  /**
66
95
  * Detect the actively running AI agent from environment signals.
67
96
  *
@@ -77,7 +106,7 @@ const CONFIG_SIGNATURES = [
77
106
  function detectActiveAgent(env = process.env) {
78
107
  // Layer 1: AI_AGENT universal env var
79
108
  if (env.AI_AGENT) {
80
- return { name: env.AI_AGENT, source: 'env', confidence: 'high' };
109
+ return { name: normalizeAgentId(env.AI_AGENT), source: 'env', confidence: 'high' };
81
110
  }
82
111
 
83
112
  // Layer 2: Agent-specific env vars
@@ -144,7 +173,7 @@ function detectConfiguredAgents(projectRoot) {
144
173
  const fullPath = path.join(projectRoot, relativePath);
145
174
  try {
146
175
  if (fs.existsSync(fullPath)) {
147
- detected.add(agentName);
176
+ detected.add(normalizeAgentId(agentName));
148
177
  }
149
178
  } catch (_err) {
150
179
  // Permission error or other fs issue — skip silently
@@ -188,4 +217,5 @@ module.exports = {
188
217
  detectActiveAgent,
189
218
  detectConfiguredAgents,
190
219
  detectEnvironment,
220
+ normalizeAgentId,
191
221
  };