forge-workflow 0.0.5 → 0.0.7
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/.cursorrules +149 -0
- 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 +211 -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/README.md +46 -1
- package/lib/agents/cline.plugin.json +11 -4
- package/lib/agents/codex.plugin.json +2 -2
- package/lib/agents/copilot.plugin.json +5 -5
- package/lib/agents/cursor.plugin.json +1 -1
- package/lib/agents/kilocode.plugin.json +1 -1
- package/lib/agents/opencode.plugin.json +7 -4
- package/lib/agents/roo.plugin.json +10 -3
- package/lib/agents-config.js +129 -81
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +173 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +84 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +71 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/validate.js +13 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/freshness-token.js +148 -0
- package/lib/frontmatter.js +79 -0
- package/lib/greptile-match.js +80 -0
- package/lib/husky-migration.js +113 -12
- package/lib/lefthook-check.js +27 -6
- package/lib/plugin-manager.js +225 -72
- package/lib/project-discovery.js +39 -5
- package/lib/reset.js +309 -0
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/task-ownership.js +117 -0
- package/lib/ui-utils.js +43 -0
- package/lib/validation-utils.js +163 -0
- package/lib/workflow/enforce-stage.js +179 -0
- package/lib/workflow/stages.js +201 -0
- package/lib/workflow/state.js +332 -0
- package/opencode.json +67 -0
- package/package.json +16 -6
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-agents.js +103 -0
- 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 +756 -0
- package/scripts/smart-status.sh +58 -21
- package/scripts/sync-commands.js +49 -20
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +18 -1
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
const { normalizeStageId } = require('../workflow/stages');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} CommandModule
|
|
18
|
+
* @property {string} name - Command name used for routing
|
|
19
|
+
* @property {string} description - Human-readable description
|
|
20
|
+
* @property {function(Array, Object, string): Promise<*>} handler - Async command handler
|
|
21
|
+
* @property {string} [usage] - Usage string (optional)
|
|
22
|
+
* @property {Object<string, string>} [flags] - Flag descriptions (optional)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Validate that a module exports the required command interface.
|
|
27
|
+
*
|
|
28
|
+
* @param {*} mod - The required module
|
|
29
|
+
* @returns {{ valid: boolean, reason?: string }}
|
|
30
|
+
*/
|
|
31
|
+
function validateCommand(mod) {
|
|
32
|
+
if (!mod || typeof mod !== 'object') {
|
|
33
|
+
return { valid: false, reason: 'module does not export an object' };
|
|
34
|
+
}
|
|
35
|
+
if (typeof mod.name !== 'string' || !mod.name) {
|
|
36
|
+
return { valid: false, reason: 'missing or invalid "name" export' };
|
|
37
|
+
}
|
|
38
|
+
if (typeof mod.description !== 'string' || !mod.description) {
|
|
39
|
+
return { valid: false, reason: 'missing or invalid "description" export' };
|
|
40
|
+
}
|
|
41
|
+
if (typeof mod.handler !== 'function') {
|
|
42
|
+
return { valid: false, reason: 'missing or invalid "handler" export' };
|
|
43
|
+
}
|
|
44
|
+
return { valid: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load and discover command modules from a directory.
|
|
49
|
+
*
|
|
50
|
+
* Scans `commandsDir` for `.js` files (excluding files starting with `_`),
|
|
51
|
+
* validates each module exports `{ name, description, handler }`, and builds
|
|
52
|
+
* a routing Map keyed by command name.
|
|
53
|
+
*
|
|
54
|
+
* Malformed modules are skipped with a `console.warn` — other commands
|
|
55
|
+
* continue loading. Duplicate command names warn and skip the later file.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} commandsDir - Absolute path to the commands directory
|
|
58
|
+
* @returns {{ commands: Map<string, CommandModule>, getHelp: () => string }}
|
|
59
|
+
*/
|
|
60
|
+
function loadCommands(commandsDir) {
|
|
61
|
+
/** @type {Map<string, CommandModule>} */
|
|
62
|
+
const commands = new Map();
|
|
63
|
+
|
|
64
|
+
if (!commandsDir || !existsSync(commandsDir)) {
|
|
65
|
+
return {
|
|
66
|
+
commands,
|
|
67
|
+
getHelp: () => buildHelp(commands),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const files = readdirSync(commandsDir)
|
|
72
|
+
.filter(f => f.endsWith('.js') && !f.startsWith('_'))
|
|
73
|
+
.sort(); // deterministic order — first file wins on duplicates
|
|
74
|
+
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const filePath = path.join(commandsDir, file);
|
|
77
|
+
|
|
78
|
+
let mod;
|
|
79
|
+
try {
|
|
80
|
+
mod = require(filePath);
|
|
81
|
+
} catch (_err) {
|
|
82
|
+
console.warn(`[registry] Skipping ${file}: failed to load — ${_err.message}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const validation = validateCommand(mod);
|
|
87
|
+
if (!validation.valid) {
|
|
88
|
+
console.warn(`[registry] Skipping ${file}: ${validation.reason}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (commands.has(mod.name)) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[registry] Skipping ${file}: duplicate command name "${mod.name}"`
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
commands.set(mod.name, mod);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
commands,
|
|
104
|
+
getHelp: () => buildHelp(commands),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a formatted help string from discovered commands.
|
|
110
|
+
*
|
|
111
|
+
* @param {Map<string, CommandModule>} commands
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function buildHelp(commands) {
|
|
115
|
+
if (commands.size === 0) {
|
|
116
|
+
return 'No commands available.';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const lines = ['Available commands:', ''];
|
|
120
|
+
|
|
121
|
+
// Find the longest name for alignment
|
|
122
|
+
let maxLen = 0;
|
|
123
|
+
for (const name of commands.keys()) {
|
|
124
|
+
if (name.length > maxLen) maxLen = name.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const [name, cmd] of commands) {
|
|
128
|
+
const padding = ' '.repeat(maxLen - name.length + 2);
|
|
129
|
+
lines.push(` ${name}${padding}${cmd.description}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isStageCommand(commandName) {
|
|
136
|
+
return normalizeStageId(commandName) !== null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function executeCommand(commands, commandName, args, flags, projectRoot, options = {}) {
|
|
140
|
+
const command = commands.get(commandName);
|
|
141
|
+
if (!command) {
|
|
142
|
+
return { success: false, error: `Unknown command: ${commandName}` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (typeof options.enforceStage === 'function' && isStageCommand(commandName)) {
|
|
147
|
+
const enforcement = await options.enforceStage({
|
|
148
|
+
commandName,
|
|
149
|
+
args,
|
|
150
|
+
flags,
|
|
151
|
+
projectRoot,
|
|
152
|
+
command,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (enforcement?.allowed === false) {
|
|
156
|
+
return {
|
|
157
|
+
success: false,
|
|
158
|
+
error: enforcement.error ?? `Stage ${commandName} is blocked.`,
|
|
159
|
+
enforcement,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return await command.handler(args, flags, projectRoot);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
error: err?.message ?? `Failed to execute ${commandName}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { loadCommands, validateCommand, executeCommand };
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module commands-reset
|
|
5
|
+
*
|
|
6
|
+
* Reset agent command files to match the canonical source (commands/*.md or .claude/commands/*.md).
|
|
7
|
+
* Rebuilds adapted files via sync-commands and optionally writes them.
|
|
8
|
+
*
|
|
9
|
+
* Used by `forge commands reset [--dry-run] [--all] [command-name]`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const {
|
|
15
|
+
syncCommands,
|
|
16
|
+
contentHash,
|
|
17
|
+
resolveCanonicalCommandsDir,
|
|
18
|
+
writeSyncManifest,
|
|
19
|
+
} = require('../../scripts/sync-commands.js');
|
|
20
|
+
|
|
21
|
+
function validateCommandName(commandName) {
|
|
22
|
+
if (commandName && !/^[a-z0-9-]+$/.test(commandName)) {
|
|
23
|
+
return [`Invalid command name: "${commandName}" - only lowercase letters, numbers, and hyphens allowed`];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureCanonicalCommandExists(repoRoot, canonicalDir, commandName) {
|
|
30
|
+
if (!commandName) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (fs.existsSync(path.join(canonicalDir, `${commandName}.md`))) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const canonicalLabel = path.relative(repoRoot, canonicalDir).replaceAll('\\', '/');
|
|
39
|
+
return [`Command not found: ${canonicalLabel}/${commandName}.md`];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function filterPlannedEntries(entries, commandName, all) {
|
|
43
|
+
if (commandName && all !== true) {
|
|
44
|
+
return entries.filter(entry =>
|
|
45
|
+
entry.filename.replaceAll('.prompt.md', '').replaceAll('.md', '') === commandName ||
|
|
46
|
+
entry.dir.includes(`/${commandName}/`)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return entries;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function recordEntryResult(entry, reset, skipped, dryRun) {
|
|
54
|
+
const relativeFile = path.join(entry.dir, entry.filename);
|
|
55
|
+
|
|
56
|
+
if (dryRun) {
|
|
57
|
+
if (fs.existsSync(entry.filePath)) {
|
|
58
|
+
const existing = fs.readFileSync(entry.filePath, 'utf8');
|
|
59
|
+
if (contentHash(existing) === contentHash(entry.content)) {
|
|
60
|
+
skipped.push({ agent: entry.agent, file: relativeFile });
|
|
61
|
+
} else {
|
|
62
|
+
reset.push({ agent: entry.agent, file: relativeFile });
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
reset.push({ agent: entry.agent, file: relativeFile });
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const targetDir = path.dirname(entry.filePath);
|
|
71
|
+
if (!fs.existsSync(targetDir)) {
|
|
72
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (fs.existsSync(entry.filePath)) {
|
|
76
|
+
const existing = fs.readFileSync(entry.filePath, 'utf8');
|
|
77
|
+
if (contentHash(existing) === contentHash(entry.content)) {
|
|
78
|
+
skipped.push({ agent: entry.agent, file: relativeFile });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fs.writeFileSync(entry.filePath, entry.content);
|
|
84
|
+
reset.push({ agent: entry.agent, file: relativeFile });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resetCommands({ repoRoot, commandName, all, dryRun }) {
|
|
88
|
+
const errors = [];
|
|
89
|
+
const reset = [];
|
|
90
|
+
const skipped = [];
|
|
91
|
+
|
|
92
|
+
errors.push(...validateCommandName(commandName));
|
|
93
|
+
if (errors.length > 0) {
|
|
94
|
+
return { reset, skipped, errors };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const canonicalDir = resolveCanonicalCommandsDir(repoRoot);
|
|
98
|
+
if (!canonicalDir) {
|
|
99
|
+
errors.push('Canonical source directory not found: commands/ or .claude/commands/');
|
|
100
|
+
return { reset, skipped, errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
errors.push(...ensureCanonicalCommandExists(repoRoot, canonicalDir, commandName));
|
|
104
|
+
if (errors.length > 0) {
|
|
105
|
+
return { reset, skipped, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = syncCommands({ dryRun: true, check: false, repoRoot, canonicalDir });
|
|
109
|
+
if (!result.planned || result.planned.length === 0) {
|
|
110
|
+
return { reset, skipped, errors };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const entries = filterPlannedEntries(result.planned, commandName, all);
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
recordEntryResult(entry, reset, skipped, dryRun);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!dryRun) {
|
|
119
|
+
// Refresh the manifest using the full expected sync set without rewriting
|
|
120
|
+
// unrelated command files that were intentionally excluded from this reset.
|
|
121
|
+
writeSyncManifest(repoRoot, result.planned);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { reset, skipped, errors };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
name: 'commands-reset',
|
|
129
|
+
description: 'Reset generated agent command files to canonical content',
|
|
130
|
+
handler: async (args, flags, repoRoot) => {
|
|
131
|
+
const commandName = args.find(arg => !arg.startsWith('-'));
|
|
132
|
+
const result = resetCommands({
|
|
133
|
+
repoRoot,
|
|
134
|
+
commandName,
|
|
135
|
+
all: Boolean(flags.all),
|
|
136
|
+
dryRun: Boolean(flags.dryRun),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (result.errors.length > 0) {
|
|
140
|
+
return { success: false, error: result.errors.join('; ') };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { success: true, ...result };
|
|
144
|
+
},
|
|
145
|
+
resetCommands,
|
|
146
|
+
resolveCanonicalCommandsDir,
|
|
147
|
+
};
|
package/lib/commands/dev.js
CHANGED
|
@@ -500,7 +500,90 @@ 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 = {
|
|
561
|
+
name: 'dev',
|
|
562
|
+
description: 'Run the TDD development stage with phase guidance',
|
|
563
|
+
handler: async (args, flags = {}) => {
|
|
564
|
+
const featureName = args[0] || 'feature';
|
|
565
|
+
const phaseInput = flags.phase || args[1];
|
|
566
|
+
const phase = typeof phaseInput === 'string' ? phaseInput.toUpperCase() : undefined;
|
|
567
|
+
if (phase && !['RED', 'GREEN', 'REFACTOR'].includes(phase)) {
|
|
568
|
+
return {
|
|
569
|
+
success: false,
|
|
570
|
+
error: `Invalid phase '${phaseInput}'. Valid phases: red, green, refactor`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const result = await executeDev(featureName, phase ? { phase } : {});
|
|
575
|
+
if (!result.success) {
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const lines = [`TDD Phase: ${result.phase || result.detectedPhase}`];
|
|
580
|
+
if (result.guidance) lines.push('', result.guidance);
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
...result,
|
|
584
|
+
output: lines.join('\n'),
|
|
585
|
+
};
|
|
586
|
+
},
|
|
504
587
|
detectTDDPhase,
|
|
505
588
|
identifyFilePairs,
|
|
506
589
|
runTests,
|
|
@@ -510,4 +593,5 @@ module.exports = {
|
|
|
510
593
|
executeDev,
|
|
511
594
|
calculateDecisionRoute,
|
|
512
595
|
DECISION_ROUTES,
|
|
596
|
+
verifyTaskCompletion,
|
|
513
597
|
};
|
package/lib/commands/plan.js
CHANGED
|
@@ -680,6 +680,24 @@ async function executePlan(featureName) { // NOSONAR S3776
|
|
|
680
680
|
}
|
|
681
681
|
|
|
682
682
|
module.exports = {
|
|
683
|
+
name: 'plan',
|
|
684
|
+
description: 'Create implementation plan from researched feature context',
|
|
685
|
+
handler: async (args) => {
|
|
686
|
+
const result = await executePlan(args[0]);
|
|
687
|
+
if (!result.success) {
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const lines = [`Plan created: ${result.summary || result.branchName || args[0]}`];
|
|
692
|
+
if (result.beadsIssueId) lines.push(`Beads: ${result.beadsIssueId}`);
|
|
693
|
+
if (result.branchName) lines.push(`Branch: ${result.branchName}`);
|
|
694
|
+
if (result.nextCommand) lines.push(`Next: ${result.nextCommand}`);
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
...result,
|
|
698
|
+
output: lines.join('\n'),
|
|
699
|
+
};
|
|
700
|
+
},
|
|
683
701
|
readResearchDoc,
|
|
684
702
|
detectScope,
|
|
685
703
|
createBeadsIssue,
|