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.
Files changed (164) 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/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. 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
+ };
@@ -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
  };
@@ -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,