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.
- package/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +178 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- package/lib/agents-config.js +2 -2
- package/lib/commands/_registry.js +134 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/dev.js +58 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/sync.js +55 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/freshness-token.js +148 -0
- package/lib/greptile-match.js +80 -0
- package/lib/reset.js +309 -0
- package/lib/task-ownership.js +117 -0
- package/package.json +2 -2
- package/scripts/beads-context.sh +157 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +706 -0
- package/scripts/smart-status.sh +37 -10
- package/scripts/sync-utils.sh +24 -29
- 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
|
+
};
|
package/lib/commands/dev.js
CHANGED
|
@@ -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 || '
|
|
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 };
|