forge-workflow 0.0.5 → 0.0.6

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 (129) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.github/prompts/dev.prompt.md +6 -1
  36. package/.github/prompts/plan.prompt.md +59 -14
  37. package/.github/prompts/premerge.prompt.md +10 -0
  38. package/.github/prompts/review.prompt.md +7 -1
  39. package/.github/prompts/ship.prompt.md +95 -47
  40. package/.github/prompts/status.prompt.md +42 -0
  41. package/.github/prompts/validate.prompt.md +7 -1
  42. package/.github/prompts/verify.prompt.md +52 -4
  43. package/.kilocode/workflows/dev.md +6 -1
  44. package/.kilocode/workflows/plan.md +59 -14
  45. package/.kilocode/workflows/premerge.md +10 -0
  46. package/.kilocode/workflows/review.md +7 -1
  47. package/.kilocode/workflows/ship.md +95 -47
  48. package/.kilocode/workflows/status.md +42 -0
  49. package/.kilocode/workflows/validate.md +7 -1
  50. package/.kilocode/workflows/verify.md +52 -4
  51. package/.opencode/commands/dev.md +6 -1
  52. package/.opencode/commands/plan.md +59 -14
  53. package/.opencode/commands/premerge.md +10 -0
  54. package/.opencode/commands/review.md +7 -1
  55. package/.opencode/commands/ship.md +95 -47
  56. package/.opencode/commands/status.md +42 -0
  57. package/.opencode/commands/validate.md +7 -1
  58. package/.opencode/commands/verify.md +52 -4
  59. package/.roo/commands/dev.md +6 -1
  60. package/.roo/commands/plan.md +59 -14
  61. package/.roo/commands/premerge.md +10 -0
  62. package/.roo/commands/review.md +7 -1
  63. package/.roo/commands/ship.md +95 -47
  64. package/.roo/commands/status.md +42 -0
  65. package/.roo/commands/validate.md +7 -1
  66. package/.roo/commands/verify.md +52 -4
  67. package/AGENTS.md +97 -0
  68. package/CLAUDE.md +10 -0
  69. package/README.md +2 -2
  70. package/bin/forge-cmd.js +5 -1
  71. package/bin/forge-preflight.js +15 -2
  72. package/bin/forge.js +178 -9
  73. package/docs/ENHANCED_ONBOARDING.md +96 -86
  74. package/docs/ROADMAP.md +2 -2
  75. package/docs/TOOLCHAIN.md +23 -0
  76. package/docs/VALIDATION.md +1 -1
  77. package/lefthook.yml +11 -0
  78. package/lib/agents-config.js +2 -2
  79. package/lib/commands/_registry.js +134 -0
  80. package/lib/commands/clean.js +181 -0
  81. package/lib/commands/dev.js +58 -0
  82. package/lib/commands/push.js +196 -0
  83. package/lib/commands/recommend.js +1 -1
  84. package/lib/commands/sync.js +55 -0
  85. package/lib/commands/team.js +37 -0
  86. package/lib/commands/test.js +207 -0
  87. package/lib/commands/worktree.js +310 -0
  88. package/lib/docs-command.js +51 -0
  89. package/lib/docs-copy.js +50 -0
  90. package/lib/freshness-token.js +148 -0
  91. package/lib/greptile-match.js +80 -0
  92. package/lib/reset.js +309 -0
  93. package/lib/task-ownership.js +117 -0
  94. package/package.json +2 -2
  95. package/scripts/beads-context.sh +157 -22
  96. package/scripts/beads-context.test.js +5 -1
  97. package/scripts/check-forge-token.js +98 -0
  98. package/scripts/conflict-detect.sh +2 -2
  99. package/scripts/dep-guard.sh +6 -28
  100. package/scripts/file-index.sh +117 -23
  101. package/scripts/forge-team/index.sh +86 -0
  102. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  103. package/scripts/forge-team/lib/claim.sh +256 -0
  104. package/scripts/forge-team/lib/dashboard.sh +341 -0
  105. package/scripts/forge-team/lib/epic.sh +332 -0
  106. package/scripts/forge-team/lib/hooks.sh +253 -0
  107. package/scripts/forge-team/lib/identity.sh +235 -0
  108. package/scripts/forge-team/lib/sync-github.sh +317 -0
  109. package/scripts/forge-team/lib/verify.sh +284 -0
  110. package/scripts/forge-team/lib/workload.sh +296 -0
  111. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  112. package/scripts/forge-team/tests/claim.test.sh +179 -0
  113. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  114. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  115. package/scripts/forge-team/tests/epic.test.sh +176 -0
  116. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  117. package/scripts/forge-team/tests/identity.test.sh +176 -0
  118. package/scripts/forge-team/tests/integration.test.sh +371 -0
  119. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  120. package/scripts/forge-team/tests/verify.test.sh +314 -0
  121. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  122. package/scripts/forge-team/tests/workload.test.sh +209 -0
  123. package/scripts/lib/eval-runner.js +39 -0
  124. package/scripts/lib/jsonl-lock.sh +48 -0
  125. package/scripts/lib/sanitize.sh +116 -0
  126. package/scripts/pr-coordinator.sh +706 -0
  127. package/scripts/smart-status.sh +37 -10
  128. package/scripts/sync-utils.sh +24 -29
  129. package/scripts/test.js +3 -1
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Command Registry — Auto-Discovery
3
+ *
4
+ * Scans a commands directory for .js files, validates each exports
5
+ * { name, description, handler }, and builds a routing Map.
6
+ *
7
+ * Follows the same fs.readdirSync() pattern as lib/plugin-manager.js.
8
+ *
9
+ * @module _registry
10
+ */
11
+
12
+ const { existsSync, readdirSync } = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ /**
16
+ * @typedef {Object} CommandModule
17
+ * @property {string} name - Command name used for routing
18
+ * @property {string} description - Human-readable description
19
+ * @property {function(Array, Object, string): Promise<*>} handler - Async command handler
20
+ * @property {string} [usage] - Usage string (optional)
21
+ * @property {Object<string, string>} [flags] - Flag descriptions (optional)
22
+ */
23
+
24
+ /**
25
+ * Validate that a module exports the required command interface.
26
+ *
27
+ * @param {*} mod - The required module
28
+ * @returns {{ valid: boolean, reason?: string }}
29
+ */
30
+ function validateCommand(mod) {
31
+ if (!mod || typeof mod !== 'object') {
32
+ return { valid: false, reason: 'module does not export an object' };
33
+ }
34
+ if (typeof mod.name !== 'string' || !mod.name) {
35
+ return { valid: false, reason: 'missing or invalid "name" export' };
36
+ }
37
+ if (typeof mod.description !== 'string' || !mod.description) {
38
+ return { valid: false, reason: 'missing or invalid "description" export' };
39
+ }
40
+ if (typeof mod.handler !== 'function') {
41
+ return { valid: false, reason: 'missing or invalid "handler" export' };
42
+ }
43
+ return { valid: true };
44
+ }
45
+
46
+ /**
47
+ * Load and discover command modules from a directory.
48
+ *
49
+ * Scans `commandsDir` for `.js` files (excluding files starting with `_`),
50
+ * validates each module exports `{ name, description, handler }`, and builds
51
+ * a routing Map keyed by command name.
52
+ *
53
+ * Malformed modules are skipped with a `console.warn` — other commands
54
+ * continue loading. Duplicate command names warn and skip the later file.
55
+ *
56
+ * @param {string} commandsDir - Absolute path to the commands directory
57
+ * @returns {{ commands: Map<string, CommandModule>, getHelp: () => string }}
58
+ */
59
+ function loadCommands(commandsDir) {
60
+ /** @type {Map<string, CommandModule>} */
61
+ const commands = new Map();
62
+
63
+ if (!commandsDir || !existsSync(commandsDir)) {
64
+ return {
65
+ commands,
66
+ getHelp: () => buildHelp(commands),
67
+ };
68
+ }
69
+
70
+ const files = readdirSync(commandsDir)
71
+ .filter(f => f.endsWith('.js') && !f.startsWith('_'))
72
+ .sort(); // deterministic order — first file wins on duplicates
73
+
74
+ for (const file of files) {
75
+ const filePath = path.join(commandsDir, file);
76
+
77
+ let mod;
78
+ try {
79
+ mod = require(filePath);
80
+ } catch (_err) {
81
+ console.warn(`[registry] Skipping ${file}: failed to load — ${_err.message}`);
82
+ continue;
83
+ }
84
+
85
+ const validation = validateCommand(mod);
86
+ if (!validation.valid) {
87
+ console.warn(`[registry] Skipping ${file}: ${validation.reason}`);
88
+ continue;
89
+ }
90
+
91
+ if (commands.has(mod.name)) {
92
+ console.warn(
93
+ `[registry] Skipping ${file}: duplicate command name "${mod.name}"`
94
+ );
95
+ continue;
96
+ }
97
+
98
+ commands.set(mod.name, mod);
99
+ }
100
+
101
+ return {
102
+ commands,
103
+ getHelp: () => buildHelp(commands),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Build a formatted help string from discovered commands.
109
+ *
110
+ * @param {Map<string, CommandModule>} commands
111
+ * @returns {string}
112
+ */
113
+ function buildHelp(commands) {
114
+ if (commands.size === 0) {
115
+ return 'No commands available.';
116
+ }
117
+
118
+ const lines = ['Available commands:', ''];
119
+
120
+ // Find the longest name for alignment
121
+ let maxLen = 0;
122
+ for (const name of commands.keys()) {
123
+ if (name.length > maxLen) maxLen = name.length;
124
+ }
125
+
126
+ for (const [name, cmd] of commands) {
127
+ const padding = ' '.repeat(maxLen - name.length + 2);
128
+ lines.push(` ${name}${padding}${cmd.description}`);
129
+ }
130
+
131
+ return lines.join('\n');
132
+ }
133
+
134
+ module.exports = { loadCommands, validateCommand };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { stopDolt } = require('./worktree');
7
+
8
+ /**
9
+ * Forge Clean Command
10
+ * Remove worktrees for merged branches, stopping Dolt servers first.
11
+ * Uses execFileSync (not execSync) to prevent command injection (OWASP A03).
12
+ *
13
+ * @module commands/clean
14
+ */
15
+
16
+ /**
17
+ * Detect the default branch (main, master, develop, trunk).
18
+ * Tries origin/HEAD first, then probes common names.
19
+ * @param {Function} runFile - execFileSync-compatible function
20
+ * @returns {string} Default branch name
21
+ */
22
+ function getDefaultBranch(runFile) {
23
+ try {
24
+ return runFile('git', ['rev-parse', '--abbrev-ref', 'origin/HEAD'], { stdio: 'pipe' })
25
+ .toString().trim().replace('origin/', '');
26
+ } catch (_e) { /* intentional: origin/HEAD not set, probe common names */ // NOSONAR S2486
27
+ for (const name of ['main', 'master', 'develop', 'trunk']) {
28
+ try {
29
+ runFile('git', ['rev-parse', '--verify', name], { stdio: 'pipe' });
30
+ return name;
31
+ } catch (_e2) { /* intentional: try next branch name */ } // NOSONAR S2486
32
+ }
33
+ return 'main';
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Parse `git worktree list --porcelain` output into a map of path -> branch.
39
+ * @param {string} output - Raw porcelain output
40
+ * @returns {Map<string, string>} Map of worktree path -> branch name
41
+ */
42
+ function parseWorktreeList(output) {
43
+ const map = new Map();
44
+ const blocks = output.split('\n\n');
45
+ for (const block of blocks) {
46
+ const lines = block.trim().split('\n');
47
+ let wtPath = null;
48
+ let branch = null;
49
+ for (const line of lines) {
50
+ if (line.startsWith('worktree ')) {
51
+ wtPath = line.slice('worktree '.length);
52
+ }
53
+ if (line.startsWith('branch ')) {
54
+ // branch refs/heads/feat/foo -> feat/foo
55
+ branch = line.slice('branch refs/heads/'.length);
56
+ }
57
+ }
58
+ if (wtPath && branch) {
59
+ map.set(wtPath, branch);
60
+ }
61
+ }
62
+ return map;
63
+ }
64
+
65
+ /**
66
+ * Clean a single worktree directory if its branch is merged.
67
+ * @param {string} dir - Directory name within .worktrees/
68
+ * @param {Map<string, string>} worktreeMap - Path-to-branch mapping
69
+ * @param {string[]} mergedBranches - List of merged branch names
70
+ * @param {string} worktreesDir - Absolute path to .worktrees/
71
+ * @param {boolean} dryRun - If true, skip actual removal
72
+ * @param {Function} runFile - execFileSync-compatible function
73
+ * @param {object} fsApi - fs module (for DI)
74
+ * @returns {Promise<boolean>} True if the worktree was cleaned (or would be in dry-run)
75
+ */
76
+ async function cleanWorktree(dir, worktreeMap, mergedBranches, worktreesDir, dryRun, runFile, fsApi) {
77
+ const wtPath = path.resolve(worktreesDir, dir);
78
+ const branch = worktreeMap.get(wtPath);
79
+
80
+ if (branch && mergedBranches.includes(branch)) {
81
+ if (!dryRun) {
82
+ const stopResult = stopDolt(wtPath, { _exec: runFile, _fs: fsApi });
83
+ if (stopResult.stopped) {
84
+ await new Promise(r => setTimeout(r, 500));
85
+ }
86
+ try {
87
+ runFile('git', ['worktree', 'remove', wtPath], { stdio: 'pipe' });
88
+ } catch (_removeErr) { /* intentional: worktree has uncommitted changes, skip */ // NOSONAR S2486
89
+ return false;
90
+ }
91
+ }
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Main handler for the clean command.
99
+ * @param {string[]} _args - Positional arguments (unused)
100
+ * @param {object} flags - CLI flags
101
+ * @param {string} projectRoot - Project root path
102
+ * @param {object} [opts] - Options for dependency injection
103
+ * @param {Function} [opts._exec] - Override for execFileSync (testing)
104
+ * @param {object} [opts._fs] - Override for fs module (testing)
105
+ * @returns {Promise<{ success: boolean, cleaned: number, active: number, dryRun: boolean }>}
106
+ */
107
+ async function handler(_args, flags, projectRoot, opts = {}) {
108
+ const runFile = opts._exec || execFileSync;
109
+ const fsApi = opts._fs || fs;
110
+ const dryRun = !!(flags['--dry-run'] || flags.dryRun);
111
+
112
+ const worktreesDir = path.resolve(projectRoot, '.worktrees');
113
+
114
+ // If .worktrees/ doesn't exist, nothing to clean
115
+ if (!fsApi.existsSync(worktreesDir)) {
116
+ return { success: true, cleaned: 0, active: 0, dryRun };
117
+ }
118
+
119
+ // 1. List dirs in .worktrees/
120
+ const entries = fsApi.readdirSync(worktreesDir, { withFileTypes: true });
121
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
122
+
123
+ if (dirs.length === 0) {
124
+ return { success: true, cleaned: 0, active: 0, dryRun };
125
+ }
126
+
127
+ // 2. Detect default branch, then get merged branches
128
+ const defaultBranch = getDefaultBranch(runFile);
129
+ let mergedBranches;
130
+ try {
131
+ const mergedOutput = runFile('git', ['branch', '--merged', defaultBranch], { stdio: 'pipe' });
132
+ mergedBranches = mergedOutput
133
+ .toString()
134
+ .split('\n')
135
+ .map(b => b.trim().replace(/^\*\s*/, ''))
136
+ .filter(b => b.length > 0);
137
+ } catch (_e) { /* intentional: fallback to empty list */ // NOSONAR S2486
138
+ mergedBranches = [];
139
+ }
140
+
141
+ // 3. Get worktree -> branch mapping from git
142
+ let worktreeMap;
143
+ try {
144
+ const listOutput = runFile('git', ['worktree', 'list', '--porcelain'], { stdio: 'pipe' });
145
+ worktreeMap = parseWorktreeList(listOutput.toString());
146
+ } catch (_e) { /* intentional: fallback to empty map */ // NOSONAR S2486
147
+ worktreeMap = new Map();
148
+ }
149
+
150
+ // 4. For each worktree dir, check if its branch is merged
151
+ let cleaned = 0;
152
+ let active = 0;
153
+
154
+ for (const dir of dirs) {
155
+ const wasCleaned = await cleanWorktree(dir, worktreeMap, mergedBranches, worktreesDir, dryRun, runFile, fsApi);
156
+ if (wasCleaned) {
157
+ cleaned++;
158
+ } else {
159
+ active++;
160
+ }
161
+ }
162
+
163
+ // Prune stale worktree refs to prevent bare repo state
164
+ if (!dryRun && cleaned > 0) {
165
+ try {
166
+ runFile('git', ['worktree', 'prune'], { stdio: 'pipe' });
167
+ } catch (_e) { /* intentional: prune is best-effort cleanup */ } // NOSONAR S2486
168
+ }
169
+
170
+ return { success: true, cleaned, active, dryRun };
171
+ }
172
+
173
+ module.exports = {
174
+ name: 'clean',
175
+ description: 'Remove worktrees for merged branches (stops Dolt servers)',
176
+ usage: 'forge clean [--dry-run]',
177
+ flags: {
178
+ '--dry-run': 'Show what would be cleaned without removing',
179
+ },
180
+ handler,
181
+ };
@@ -500,6 +500,63 @@ function calculateDecisionRoute(score, dimensions) {
500
500
  return { route: DECISION_ROUTES.BLOCKED, score: effectiveScore };
501
501
  }
502
502
 
503
+ /**
504
+ * Verify task completion and auto-commit changes if needed.
505
+ *
506
+ * Checks for uncommitted changes, stages only scoped files, commits,
507
+ * and returns the result. NEVER uses `git add -A` or `git add .`.
508
+ *
509
+ * Security: Uses execFileSync only (OWASP A03 — no shell injection).
510
+ *
511
+ * @param {string} taskTitle - The task title for the commit message
512
+ * @param {string[]|null|undefined} ownedFiles - Files this task owns (scoped staging). If falsy, stages tracked modified files only.
513
+ * @param {object} [opts] - Options
514
+ * @param {Function} [opts._exec] - Injected execFileSync for testing
515
+ * @returns {{committed: boolean, autoCommitted: boolean, commitSha: string|null, hasChanges: boolean}}
516
+ * @example
517
+ * const result = verifyTaskCompletion('add login form', ['lib/auth.js'], { _exec: mockExec });
518
+ */
519
+ function verifyTaskCompletion(taskTitle, ownedFiles, opts = {}) {
520
+ const run = opts._exec || execFileSync;
521
+
522
+ // 1. Check for uncommitted changes
523
+ const status = run('git', ['status', '--porcelain']).toString().trim();
524
+
525
+ if (!status) {
526
+ return { committed: false, autoCommitted: false, commitSha: null, hasChanges: false };
527
+ }
528
+
529
+ // There are changes
530
+ let filesToStage = [];
531
+
532
+ if (ownedFiles && ownedFiles.length > 0) {
533
+ // 2. Stage only owned files — NEVER git add -A
534
+ filesToStage = ownedFiles;
535
+ } else {
536
+ // 3. Stage only tracked modified files from git diff --name-only
537
+ const diffOutput = run('git', ['diff', '--name-only']).toString().trim();
538
+ if (diffOutput) {
539
+ filesToStage = diffOutput.split('\n').filter(Boolean);
540
+ }
541
+ }
542
+
543
+ // If no files to stage, skip commit
544
+ if (filesToStage.length === 0) {
545
+ return { committed: false, autoCommitted: false, commitSha: null, hasChanges: true };
546
+ }
547
+
548
+ // Stage scoped files
549
+ run('git', ['add', ...filesToStage]);
550
+
551
+ // 4. Commit with standardized message
552
+ run('git', ['commit', '-m', `feat(task): ${taskTitle}`]);
553
+
554
+ // 5. Get commit sha
555
+ const commitSha = run('git', ['log', '-1', '--format=%H']).toString().trim();
556
+
557
+ return { committed: true, autoCommitted: true, commitSha, hasChanges: true };
558
+ }
559
+
503
560
  module.exports = {
504
561
  detectTDDPhase,
505
562
  identifyFilePairs,
@@ -510,4 +567,5 @@ module.exports = {
510
567
  executeDev,
511
568
  calculateDecisionRoute,
512
569
  DECISION_ROUTES,
570
+ verifyTaskCompletion,
513
571
  };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync, spawnSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const forgeToken = require('../../scripts/check-forge-token');
7
+
8
+ const isWindows = process.platform === 'win32';
9
+
10
+ /**
11
+ * Detect package manager from lock files.
12
+ * Same priority order as scripts/test.js.
13
+ *
14
+ * @param {string} projectRoot - Absolute path to project root
15
+ * @param {function} [existsSync] - Injected fs.existsSync for testing
16
+ * @returns {string} Package manager command name
17
+ */
18
+ function detectPackageManager(projectRoot, existsSync = fs.existsSync) {
19
+ if (existsSync(path.join(projectRoot, 'bun.lockb')) || existsSync(path.join(projectRoot, 'bun.lock'))) return 'bun';
20
+ if (existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) return 'pnpm';
21
+ if (existsSync(path.join(projectRoot, 'yarn.lock'))) return 'yarn';
22
+ return 'npm';
23
+ }
24
+
25
+ /**
26
+ * Get the current git branch name.
27
+ *
28
+ * @param {function} execFn - execFileSync or mock
29
+ * @returns {string} Current branch name
30
+ */
31
+ function getCurrentBranch(execFn) {
32
+ try {
33
+ return execFn('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim();
34
+ } catch (_err) { /* intentional: fallback to 'unknown' */ // NOSONAR S2486
35
+ return 'unknown';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Check if this is the first push to the current branch.
41
+ * If `git rev-list --count origin/<branch>` throws, the remote tracking
42
+ * branch doesn't exist yet — meaning this is a first push.
43
+ *
44
+ * @param {string} branch - Branch name
45
+ * @param {function} execFn - execFileSync or mock
46
+ * @returns {boolean} True if first push (no remote tracking branch)
47
+ */
48
+ function isFirstPush(branch, execFn) {
49
+ try {
50
+ execFn('git', ['rev-list', '--count', 'origin/' + branch], { encoding: 'utf8' });
51
+ return false;
52
+ } catch (_err) { /* intentional: no remote tracking branch means first push */ // NOSONAR S2486
53
+ return true;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Run branch protection check as a subprocess.
59
+ * @param {function} execFn - execFileSync or mock
60
+ * @param {string} projectRoot - Absolute path to project root
61
+ * @param {function} log - Logger function
62
+ * @returns {boolean} True if branch protection passed
63
+ */
64
+ function runBranchProtection(execFn, projectRoot, log) {
65
+ try {
66
+ execFn('node', [path.join(projectRoot, 'scripts', 'branch-protection.js')]);
67
+ return true;
68
+ } catch (_err) { /* intentional: fallback to failure */ // NOSONAR S2486
69
+ log('Branch protection check failed — push aborted.');
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Run lint via the detected package manager.
76
+ * @param {function} spawnFn - spawnSync or mock
77
+ * @param {string} pkgManager - Package manager command
78
+ * @param {string} projectRoot - Absolute path to project root
79
+ * @returns {boolean} True if lint passed
80
+ */
81
+ function runLint(spawnFn, pkgManager, projectRoot) {
82
+ const lintResult = spawnFn(pkgManager, ['run', 'lint'], {
83
+ stdio: 'inherit',
84
+ shell: isWindows,
85
+ cwd: projectRoot,
86
+ });
87
+ return lintResult.status === 0;
88
+ }
89
+
90
+ /**
91
+ * Run tests unless in quick mode, logging warnings for first push.
92
+ * @param {function} spawnFn - spawnSync or mock
93
+ * @param {function} execFn - execFileSync or mock
94
+ * @param {string} pkgManager - Package manager command
95
+ * @param {string} projectRoot - Absolute path to project root
96
+ * @param {boolean} quickMode - Whether to skip tests
97
+ * @param {function} log - Logger function
98
+ * @returns {boolean|undefined} True if tests passed, undefined if skipped
99
+ */
100
+ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
101
+ if (quickMode) {
102
+ log('Tests skipped (--quick) — CI will run full suite on GitHub');
103
+ const branch = getCurrentBranch(execFn);
104
+ if (isFirstPush(branch, execFn)) {
105
+ log('Warning: First push to this branch — consider `forge push` (full suite) before merge');
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ const testResult = spawnFn(pkgManager, ['run', 'test'], {
111
+ stdio: 'inherit',
112
+ shell: isWindows,
113
+ cwd: projectRoot,
114
+ timeout: 120000,
115
+ });
116
+ return testResult.status === 0;
117
+ }
118
+
119
+ module.exports = {
120
+ name: 'push',
121
+ description: 'Push with quality gates (branch protection + lint + tests)',
122
+ usage: 'forge push [--quick] [-- <git-push-args>]',
123
+ flags: {
124
+ '--quick': 'Skip tests (lint-only) for review-cycle pushes',
125
+ },
126
+
127
+ /**
128
+ * Push handler — runs branch protection, lint, optionally tests,
129
+ * then forwards to git push.
130
+ *
131
+ * @param {string[]} args - Passthrough args for git push (e.g. ['-u', 'origin', 'feat/slug'])
132
+ * @param {Object} flags - Parsed flags (e.g. { '--quick': true })
133
+ * @param {string} projectRoot - Absolute path to project root
134
+ * @param {Object} [deps] - Dependency injection for testing
135
+ * @returns {Promise<{success: boolean, quickMode: boolean, lintPassed: boolean, testsPassed?: boolean, pushed: boolean}>}
136
+ */
137
+ handler: async (args, flags, projectRoot, deps) => {
138
+ const execFn = deps?.execFileSync || execFileSync;
139
+ const spawnFn = deps?.spawnSync || spawnSync;
140
+ const existsFn = deps?.existsSync || fs.existsSync;
141
+ const log = deps?.log || console.log;
142
+ const tokenWrite = deps?.writeForgeToken || forgeToken.write;
143
+
144
+ const quickMode = !!(flags && (flags['--quick'] || flags.quick));
145
+ const pkgManager = detectPackageManager(projectRoot, existsFn);
146
+
147
+ // Step 1: Branch protection
148
+ const branchOk = runBranchProtection(execFn, projectRoot, log);
149
+ if (!branchOk) {
150
+ return { success: false, quickMode, lintPassed: false, pushed: false };
151
+ }
152
+
153
+ // Step 2: Lint
154
+ const lintPassed = runLint(spawnFn, pkgManager, projectRoot);
155
+ if (!lintPassed) {
156
+ log('Lint failed — push aborted.');
157
+ return { success: false, quickMode, lintPassed: false, pushed: false };
158
+ }
159
+
160
+ // Step 3: Tests (skip in quick mode)
161
+ const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log);
162
+ if (!quickMode && !testsPassed) {
163
+ return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
164
+ }
165
+
166
+ // Step 4: Write nonce token so lefthook skips pre-push hooks
167
+ try {
168
+ tokenWrite(projectRoot);
169
+ } catch (_err) { /* intentional: non-fatal, lefthook will re-run hooks */ // NOSONAR S2486
170
+ log('Warning: Could not write forge push token — lefthook hooks will run again');
171
+ }
172
+
173
+ // Step 5: git push with passthrough args
174
+ const gitArgs = args.filter(a => a !== '--quick');
175
+ try {
176
+ execFn('git', ['push', ...gitArgs], { stdio: 'inherit' });
177
+ } catch (_pushErr) { // NOSONAR S2486
178
+ log('git push failed.');
179
+ return {
180
+ success: false,
181
+ quickMode,
182
+ lintPassed: true,
183
+ ...(quickMode ? {} : { testsPassed: true }),
184
+ pushed: false,
185
+ };
186
+ }
187
+
188
+ return {
189
+ success: true,
190
+ quickMode,
191
+ lintPassed: true,
192
+ ...(quickMode ? {} : { testsPassed: true }),
193
+ pushed: true,
194
+ };
195
+ },
196
+ };
@@ -99,7 +99,7 @@ function formatRecommendations(recommendations) {
99
99
  * @returns {{ budgetMode: string, recommendations: Object, error?: string }}
100
100
  */
101
101
  function handleRecommend(flags, projectPath) {
102
- const budgetMode = flags.budget || 'startup';
102
+ const budgetMode = flags.budget || 'free';
103
103
 
104
104
  // Validate budget mode
105
105
  if (!BUDGET_MODES[budgetMode]) {
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ /**
6
+ * Forge Sync Command
7
+ * Syncs Beads issue data by running dolt pull + push.
8
+ * Uses execFileSync (not execSync) to prevent command injection (OWASP A03).
9
+ *
10
+ * @module commands/sync
11
+ */
12
+ module.exports = {
13
+ name: 'sync',
14
+ description: 'Sync Beads issue data (dolt pull + push)',
15
+ usage: 'forge sync',
16
+ flags: {},
17
+
18
+ /**
19
+ * Run beads dolt pull + push to sync issue data.
20
+ * @param {string[]} _args - Positional arguments (unused)
21
+ * @param {object} _flags - CLI flags (unused)
22
+ * @param {string} _projectRoot - Project root path (unused)
23
+ * @param {object} [opts] - Options for dependency injection
24
+ * @param {Function} [opts._exec] - Override for execFileSync (testing)
25
+ * @returns {Promise<{success: boolean, synced: boolean, message?: string, error?: string}>}
26
+ */
27
+ handler: async (_args, _flags, _projectRoot, opts = {}) => {
28
+ const exec = opts._exec || execFileSync;
29
+
30
+ // Step 1: Check if bd binary exists
31
+ try {
32
+ exec('bd', ['--version'], { stdio: 'pipe' });
33
+ } catch (_checkErr) { /* intentional: bd not installed, skip sync gracefully */ // NOSONAR S2486
34
+ return {
35
+ success: true,
36
+ synced: false,
37
+ message: 'Beads not installed — nothing to sync',
38
+ };
39
+ }
40
+
41
+ // Step 2: Run dolt pull + push
42
+ try {
43
+ exec('bd', ['dolt', 'pull'], { stdio: 'pipe' });
44
+ exec('bd', ['dolt', 'push'], { stdio: 'pipe' });
45
+ } catch (syncErr) {
46
+ return {
47
+ success: false,
48
+ synced: false,
49
+ error: syncErr.message,
50
+ };
51
+ }
52
+
53
+ return { success: true, synced: true };
54
+ },
55
+ };
@@ -0,0 +1,37 @@
1
+ const { existsSync } = require('node:fs');
2
+ const { execFileSync } = require('node:child_process');
3
+ const path = require('node:path');
4
+
5
+ // Resolve bash binary from fixed, known locations only (no PATH search).
6
+ // SonarCloud S4036: PATH must not be searched for OS commands.
7
+ function _resolveBash() {
8
+ if (process.platform === 'win32') {
9
+ // Fixed locations for Git Bash on Windows (no PATH search)
10
+ const candidates = [
11
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
12
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
13
+ `${process.env.LOCALAPPDATA || ''}\\Programs\\Git\\bin\\bash.exe`,
14
+ ];
15
+ for (const p of candidates) {
16
+ if (p && existsSync(p)) return p;
17
+ }
18
+ // Fallback: assume Git Bash is in PATH (less secure but functional)
19
+ return 'bash';
20
+ }
21
+ return '/usr/bin/bash';
22
+ }
23
+
24
+ function handleTeam(args) {
25
+ const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'forge-team', 'index.sh');
26
+ const bashPath = _resolveBash();
27
+
28
+ try {
29
+ execFileSync(bashPath, [scriptPath, ...args], {
30
+ stdio: 'inherit',
31
+ });
32
+ } catch (err) {
33
+ process.exit(err.status || 1);
34
+ }
35
+ }
36
+
37
+ module.exports = { handleTeam };