forge-workflow 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.github/prompts/dev.prompt.md +6 -1
  36. package/.github/prompts/plan.prompt.md +59 -14
  37. package/.github/prompts/premerge.prompt.md +10 -0
  38. package/.github/prompts/review.prompt.md +7 -1
  39. package/.github/prompts/ship.prompt.md +95 -47
  40. package/.github/prompts/status.prompt.md +42 -0
  41. package/.github/prompts/validate.prompt.md +7 -1
  42. package/.github/prompts/verify.prompt.md +52 -4
  43. package/.kilocode/workflows/dev.md +6 -1
  44. package/.kilocode/workflows/plan.md +59 -14
  45. package/.kilocode/workflows/premerge.md +10 -0
  46. package/.kilocode/workflows/review.md +7 -1
  47. package/.kilocode/workflows/ship.md +95 -47
  48. package/.kilocode/workflows/status.md +42 -0
  49. package/.kilocode/workflows/validate.md +7 -1
  50. package/.kilocode/workflows/verify.md +52 -4
  51. package/.opencode/commands/dev.md +6 -1
  52. package/.opencode/commands/plan.md +59 -14
  53. package/.opencode/commands/premerge.md +10 -0
  54. package/.opencode/commands/review.md +7 -1
  55. package/.opencode/commands/ship.md +95 -47
  56. package/.opencode/commands/status.md +42 -0
  57. package/.opencode/commands/validate.md +7 -1
  58. package/.opencode/commands/verify.md +52 -4
  59. package/.roo/commands/dev.md +6 -1
  60. package/.roo/commands/plan.md +59 -14
  61. package/.roo/commands/premerge.md +10 -0
  62. package/.roo/commands/review.md +7 -1
  63. package/.roo/commands/ship.md +95 -47
  64. package/.roo/commands/status.md +42 -0
  65. package/.roo/commands/validate.md +7 -1
  66. package/.roo/commands/verify.md +52 -4
  67. package/AGENTS.md +97 -0
  68. package/CLAUDE.md +10 -0
  69. package/README.md +2 -2
  70. package/bin/forge-cmd.js +5 -1
  71. package/bin/forge-preflight.js +15 -2
  72. package/bin/forge.js +178 -9
  73. package/docs/ENHANCED_ONBOARDING.md +96 -86
  74. package/docs/ROADMAP.md +2 -2
  75. package/docs/TOOLCHAIN.md +23 -0
  76. package/docs/VALIDATION.md +1 -1
  77. package/lefthook.yml +11 -0
  78. package/lib/agents-config.js +2 -2
  79. package/lib/commands/_registry.js +134 -0
  80. package/lib/commands/clean.js +181 -0
  81. package/lib/commands/dev.js +58 -0
  82. package/lib/commands/push.js +196 -0
  83. package/lib/commands/recommend.js +1 -1
  84. package/lib/commands/sync.js +55 -0
  85. package/lib/commands/team.js +37 -0
  86. package/lib/commands/test.js +207 -0
  87. package/lib/commands/worktree.js +310 -0
  88. package/lib/docs-command.js +51 -0
  89. package/lib/docs-copy.js +50 -0
  90. package/lib/freshness-token.js +148 -0
  91. package/lib/greptile-match.js +80 -0
  92. package/lib/reset.js +309 -0
  93. package/lib/task-ownership.js +117 -0
  94. package/package.json +2 -2
  95. package/scripts/beads-context.sh +157 -22
  96. package/scripts/beads-context.test.js +5 -1
  97. package/scripts/check-forge-token.js +98 -0
  98. package/scripts/conflict-detect.sh +2 -2
  99. package/scripts/dep-guard.sh +6 -28
  100. package/scripts/file-index.sh +117 -23
  101. package/scripts/forge-team/index.sh +86 -0
  102. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  103. package/scripts/forge-team/lib/claim.sh +256 -0
  104. package/scripts/forge-team/lib/dashboard.sh +341 -0
  105. package/scripts/forge-team/lib/epic.sh +332 -0
  106. package/scripts/forge-team/lib/hooks.sh +253 -0
  107. package/scripts/forge-team/lib/identity.sh +235 -0
  108. package/scripts/forge-team/lib/sync-github.sh +317 -0
  109. package/scripts/forge-team/lib/verify.sh +284 -0
  110. package/scripts/forge-team/lib/workload.sh +296 -0
  111. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  112. package/scripts/forge-team/tests/claim.test.sh +179 -0
  113. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  114. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  115. package/scripts/forge-team/tests/epic.test.sh +176 -0
  116. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  117. package/scripts/forge-team/tests/identity.test.sh +176 -0
  118. package/scripts/forge-team/tests/integration.test.sh +371 -0
  119. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  120. package/scripts/forge-team/tests/verify.test.sh +314 -0
  121. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  122. package/scripts/forge-team/tests/workload.test.sh +209 -0
  123. package/scripts/lib/eval-runner.js +39 -0
  124. package/scripts/lib/jsonl-lock.sh +48 -0
  125. package/scripts/lib/sanitize.sh +116 -0
  126. package/scripts/pr-coordinator.sh +706 -0
  127. package/scripts/smart-status.sh +37 -10
  128. package/scripts/sync-utils.sh +24 -29
  129. package/scripts/test.js +3 -1
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Freshness Token — Validate/Ship stage freshness state
3
+ *
4
+ * Tracks whether `main` has moved forward since /validate ran,
5
+ * so /ship can warn if validation results are stale.
6
+ *
7
+ * Token file: `<projectRoot>/.forge-freshness` (ephemeral, gitignored)
8
+ *
9
+ * @module freshness-token
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const { execFileSync } = require('node:child_process');
15
+
16
+ const TOKEN_FILENAME = '.forge-freshness';
17
+
18
+ /**
19
+ * Get the current git branch name.
20
+ * @param {string} projectRoot - Absolute path to the project root.
21
+ * @returns {string} Current branch name.
22
+ */
23
+ function getCurrentBranch(projectRoot) {
24
+ // Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
25
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'HEAD'], {
26
+ encoding: 'utf8'
27
+ }).trim();
28
+ }
29
+
30
+ /**
31
+ * Detect the default branch (main, master, develop, trunk).
32
+ * @param {string} projectRoot - Absolute path to the project root.
33
+ * @returns {string} Default branch name.
34
+ */
35
+ function getDefaultBranch(projectRoot) {
36
+ try {
37
+ // Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
38
+ return execFileSync('git', ['-C', projectRoot, 'rev-parse', '--abbrev-ref', 'origin/HEAD'], {
39
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
40
+ }).trim().replace('origin/', '');
41
+ } catch (_e) { /* intentional: origin/HEAD not set, probe common branch names */ // NOSONAR S2486
42
+ for (const name of ['main', 'master', 'develop', 'trunk']) {
43
+ try {
44
+ // Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
45
+ execFileSync('git', ['-C', projectRoot, 'rev-parse', '--verify', name], { stdio: 'pipe' });
46
+ return name;
47
+ } catch (_e2) { /* intentional: branch doesn't exist, try next name */ } // NOSONAR S2486
48
+ }
49
+ return 'main';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get the merge-base commit between HEAD and the default branch.
55
+ * @param {string} projectRoot - Absolute path to the project root.
56
+ * @returns {string} The merge-base commit SHA.
57
+ * @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
58
+ */
59
+ function getMergeBase(projectRoot) {
60
+ const base = getDefaultBranch(projectRoot);
61
+ try {
62
+ // Safe: execFileSync with array args prevents injection (OWASP A03). PATH is OS-controlled.
63
+ return execFileSync('git', ['-C', projectRoot, 'merge-base', 'HEAD', base], {
64
+ encoding: 'utf8',
65
+ stdio: ['pipe', 'pipe', 'pipe']
66
+ }).trim();
67
+ } catch (err) {
68
+ throw new Error(
69
+ `Failed to compute merge-base for HEAD and ${base} in "${projectRoot}": ${err.message}`
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Write a freshness token to `<projectRoot>/.forge-freshness`.
76
+ *
77
+ * Records the current branch, base commit (merge-base HEAD main),
78
+ * and a timestamp so /ship can detect if validation is stale.
79
+ *
80
+ * @param {string} projectRoot - Absolute path to the project root.
81
+ * @returns {{ timestamp: number, branch: string, baseCommit: string }} The written token.
82
+ * @throws {Error} If git merge-base fails (no common ancestor, detached HEAD, etc.)
83
+ */
84
+ function writeFreshnessToken(projectRoot) {
85
+ const branch = getCurrentBranch(projectRoot);
86
+ const baseCommit = getMergeBase(projectRoot);
87
+
88
+ const token = {
89
+ timestamp: Date.now(),
90
+ branch,
91
+ baseCommit
92
+ };
93
+
94
+ const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
95
+ fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), 'utf8');
96
+
97
+ return token;
98
+ }
99
+
100
+ /**
101
+ * Read and parse the freshness token from `<projectRoot>/.forge-freshness`.
102
+ *
103
+ * @param {string} projectRoot - Absolute path to the project root.
104
+ * @returns {{ timestamp: number, branch: string, baseCommit: string } | null}
105
+ * The token object, or null if the file is missing or corrupted.
106
+ */
107
+ function readFreshnessToken(projectRoot) {
108
+ const tokenPath = path.join(projectRoot, TOKEN_FILENAME);
109
+
110
+ try {
111
+ const content = fs.readFileSync(tokenPath, 'utf8');
112
+ return JSON.parse(content);
113
+ } catch (_err) { /* intentional: missing file (ENOENT), corrupted JSON, or empty file — return null */ // NOSONAR S2486
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Check if a freshness token is stale.
120
+ *
121
+ * A token is stale when:
122
+ * - It is null (missing or corrupted)
123
+ * - The current merge-base differs from the token's baseCommit
124
+ * (meaning main has moved forward since validation)
125
+ * - git merge-base fails (can't verify freshness)
126
+ *
127
+ * @param {{ timestamp: number, branch: string, baseCommit: string } | null} token
128
+ * @param {string} projectRoot - Absolute path to the project root.
129
+ * @returns {boolean} True if validation results are stale and should be re-run.
130
+ */
131
+ function isStale(token, projectRoot) {
132
+ if (token === null) {
133
+ return true;
134
+ }
135
+
136
+ try {
137
+ const currentBase = getMergeBase(projectRoot);
138
+ return currentBase !== token.baseCommit;
139
+ } catch (_err) { /* intentional: merge-base failed, treat as stale since freshness can't be verified */ // NOSONAR S2486
140
+ return true;
141
+ }
142
+ }
143
+
144
+ module.exports = {
145
+ writeFreshnessToken,
146
+ readFreshnessToken,
147
+ isStale
148
+ };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ /**
6
+ * Match unresolved review threads to recent commits that touched the same files.
7
+ *
8
+ * For each thread, runs `git log --oneline --follow -- <file>` to find
9
+ * recent commits. If a commit exists, the thread is considered resolved
10
+ * (the file was modified after the review comment).
11
+ *
12
+ * Uses execFileSync (not exec) to prevent shell injection (OWASP A03).
13
+ *
14
+ * @param {Array<{file: string, line: number}>} threads - Review threads with file paths
15
+ * @param {string} projectRoot - Absolute path to the project root (used with git -C)
16
+ * @param {object} [opts] - Options
17
+ * @param {Function} [opts._exec] - Injected exec function for testing (defaults to execFileSync)
18
+ * @param {string} [opts.sinceCommit] - Merge-base SHA to restrict log to PR commits only
19
+ * @returns {Array<{file: string, line: number, resolved: boolean, sha?: string, reason?: string}>}
20
+ */
21
+ function matchThreadsToCommits(threads, projectRoot, opts = {}) {
22
+ if (!threads || threads.length === 0) {
23
+ return [];
24
+ }
25
+
26
+ const exec = opts._exec || ((cmd, args) => {
27
+ return execFileSync(cmd, args, { encoding: 'utf8' });
28
+ });
29
+
30
+ // Determine commit range — restrict to PR commits when sinceCommit provided
31
+ let sinceCommit = opts.sinceCommit;
32
+ if (!sinceCommit) {
33
+ try {
34
+ sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'main']).trim();
35
+ } catch (_e) { // NOSONAR S2486
36
+ /* intentional: main branch not found, try master instead */
37
+ try {
38
+ sinceCommit = exec('git', ['-C', projectRoot, 'merge-base', 'HEAD', 'master']).trim();
39
+ } catch (_e2) { /* intentional: no merge-base available, fall back to unbounded log */ // NOSONAR S2486
40
+ sinceCommit = null;
41
+ }
42
+ }
43
+ }
44
+
45
+ return threads.map((thread) => {
46
+ const { file, line } = thread;
47
+
48
+ try {
49
+ const logArgs = [
50
+ '-C', projectRoot,
51
+ 'log',
52
+ '--oneline',
53
+ '--follow',
54
+ ];
55
+ if (sinceCommit) {
56
+ logArgs.push(`${sinceCommit}..HEAD`);
57
+ }
58
+ logArgs.push('--', file);
59
+
60
+ const output = exec('git', logArgs);
61
+
62
+ const trimmed = (output || '').trim();
63
+
64
+ if (!trimmed) {
65
+ return { file, line, resolved: false, reason: 'no matching commit' };
66
+ }
67
+
68
+ // git log --oneline format: "<sha> <message>"
69
+ // Take the first (most recent) line
70
+ const firstLine = trimmed.split('\n')[0];
71
+ const sha = firstLine.split(' ')[0];
72
+
73
+ return { file, line, resolved: true, sha };
74
+ } catch (_err) { /* intentional: git log failed (file missing or git unavailable), mark unresolved */ // NOSONAR S2486
75
+ return { file, line, resolved: false, reason: 'no matching commit' };
76
+ }
77
+ });
78
+ }
79
+
80
+ module.exports = { matchThreadsToCommits };
package/lib/reset.js ADDED
@@ -0,0 +1,309 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ /**
5
+ * Known forge-created file patterns.
6
+ * Used to distinguish forge files from user-created files.
7
+ */
8
+ const FORGE_COMMANDS = [
9
+ '.claude/commands/plan.md',
10
+ '.claude/commands/dev.md',
11
+ '.claude/commands/validate.md',
12
+ '.claude/commands/ship.md',
13
+ '.claude/commands/review.md',
14
+ '.claude/commands/premerge.md',
15
+ '.claude/commands/verify.md',
16
+ '.claude/commands/status.md',
17
+ '.claude/commands/preflight.md',
18
+ ];
19
+
20
+ const FORGE_RULES = [
21
+ '.claude/rules/workflow.md',
22
+ '.claude/rules/greptile-review-process.md',
23
+ ];
24
+
25
+ const FORGE_SCRIPTS = [
26
+ '.claude/scripts/greptile-resolve.sh',
27
+ '.claude/scripts/validate.sh',
28
+ ];
29
+
30
+ const FORGE_WORKFLOWS = [
31
+ '.github/workflows/beads-to-github.yml',
32
+ '.github/workflows/github-to-beads.yml',
33
+ ];
34
+
35
+ const FORGE_DOCS = [
36
+ 'docs/forge/TOOLCHAIN.md',
37
+ 'docs/forge/VALIDATION.md',
38
+ ];
39
+
40
+ const FORGE_AGENT_DIRS = [
41
+ '.cursor',
42
+ '.cline',
43
+ '.roo',
44
+ '.codex',
45
+ '.kilocode',
46
+ '.opencode',
47
+ ];
48
+
49
+ const FORGE_SYNC_SCRIPTS_DIR = 'scripts/github-beads-sync';
50
+
51
+ /**
52
+ * Get categorized lists of forge-managed files in a project.
53
+ * Only returns files that actually exist on disk and are known forge templates.
54
+ * User-created files are excluded.
55
+ *
56
+ * @param {string} projectRoot - Project root directory
57
+ * @returns {{
58
+ * config: string[],
59
+ * commands: string[],
60
+ * rules: string[],
61
+ * scripts: string[],
62
+ * docs: string[],
63
+ * agentDirs: string[],
64
+ * workflows: string[],
65
+ * syncScripts: string[]
66
+ * }}
67
+ */
68
+ function getForgeFiles(projectRoot) {
69
+ const result = {
70
+ config: [],
71
+ commands: [],
72
+ rules: [],
73
+ scripts: [],
74
+ docs: [],
75
+ agentDirs: [],
76
+ workflows: [],
77
+ syncScripts: [],
78
+ };
79
+
80
+ // .forge/ directory
81
+ if (fs.existsSync(path.join(projectRoot, '.forge'))) {
82
+ result.config.push('.forge');
83
+ }
84
+
85
+ // Known forge commands (static list)
86
+ for (const f of FORGE_COMMANDS) {
87
+ if (fs.existsSync(path.join(projectRoot, f))) {
88
+ result.commands.push(f);
89
+ }
90
+ }
91
+
92
+ // Dynamic discovery: scan the package's .claude/commands/ directory to find
93
+ // any commands that setup may have copied but aren't in the static list.
94
+ // Only files that exist in both the package source AND the project are included.
95
+ const packageCommandsDir = path.join(__dirname, '..', '.claude', 'commands');
96
+ try {
97
+ const packageEntries = fs.readdirSync(packageCommandsDir);
98
+ for (const entry of packageEntries) {
99
+ if (entry.endsWith('.md')) {
100
+ const relPath = `.claude/commands/${entry}`;
101
+ if (!result.commands.includes(relPath) &&
102
+ fs.existsSync(path.join(projectRoot, relPath))) {
103
+ result.commands.push(relPath);
104
+ }
105
+ }
106
+ }
107
+ } catch (_error) {
108
+ // Package commands directory read failed, skip
109
+ }
110
+
111
+ // Known forge rules
112
+ for (const f of FORGE_RULES) {
113
+ if (fs.existsSync(path.join(projectRoot, f))) {
114
+ result.rules.push(f);
115
+ }
116
+ }
117
+
118
+ // Known forge scripts
119
+ for (const f of FORGE_SCRIPTS) {
120
+ if (fs.existsSync(path.join(projectRoot, f))) {
121
+ result.scripts.push(f);
122
+ }
123
+ }
124
+
125
+ // Known forge docs
126
+ for (const f of FORGE_DOCS) {
127
+ if (fs.existsSync(path.join(projectRoot, f))) {
128
+ result.docs.push(f);
129
+ }
130
+ }
131
+
132
+ // Agent directories (only if they exist)
133
+ for (const dir of FORGE_AGENT_DIRS) {
134
+ if (fs.existsSync(path.join(projectRoot, dir))) {
135
+ result.agentDirs.push(dir);
136
+ }
137
+ }
138
+
139
+ // Beads workflow files
140
+ for (const f of FORGE_WORKFLOWS) {
141
+ if (fs.existsSync(path.join(projectRoot, f))) {
142
+ result.workflows.push(f);
143
+ }
144
+ }
145
+
146
+ // Sync scripts directory
147
+ const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
148
+ if (fs.existsSync(syncDir)) {
149
+ try {
150
+ const entries = fs.readdirSync(syncDir);
151
+ for (const entry of entries) {
152
+ const relPath = [FORGE_SYNC_SCRIPTS_DIR, entry].join('/');
153
+ result.syncScripts.push(relPath);
154
+ }
155
+ } catch (_error) {
156
+ // Directory read failed, skip
157
+ }
158
+ }
159
+
160
+ return result;
161
+ }
162
+
163
+ /**
164
+ * Remove .forge/ directory only (soft reset).
165
+ * Requires force=true to proceed.
166
+ *
167
+ * @param {string} projectRoot - Project root directory
168
+ * @param {{ force?: boolean }} options
169
+ * @returns {{ removed: string[], preserved: string[] }}
170
+ */
171
+ function resetSoft(projectRoot, { force = false } = {}) {
172
+ if (!force) {
173
+ throw new Error('Soft reset requires --force flag. This will remove .forge/ directory.');
174
+ }
175
+
176
+ const removed = [];
177
+ const preserved = [];
178
+
179
+ const forgePath = path.join(projectRoot, '.forge');
180
+ if (fs.existsSync(forgePath)) {
181
+ fs.rmSync(forgePath, { recursive: true, force: true });
182
+ removed.push('.forge');
183
+ }
184
+
185
+ // Record what was preserved
186
+ const inventory = getForgeFiles(projectRoot);
187
+ for (const f of [...inventory.commands, ...inventory.rules, ...inventory.scripts]) {
188
+ preserved.push(f);
189
+ }
190
+ for (const dir of inventory.agentDirs) {
191
+ preserved.push(dir);
192
+ }
193
+
194
+ return { removed, preserved };
195
+ }
196
+
197
+ /**
198
+ * Remove ALL forge-managed files (hard reset).
199
+ * Preserves user-created files not in forge template list.
200
+ * Requires force=true to proceed.
201
+ *
202
+ * @param {string} projectRoot - Project root directory
203
+ * @param {{ force?: boolean }} options
204
+ * @returns {{ removed: string[], preserved: string[] }}
205
+ */
206
+ function resetHard(projectRoot, { force = false } = {}) {
207
+ if (!force) {
208
+ throw new Error('Hard reset requires --force flag. This will remove ALL forge files.');
209
+ }
210
+
211
+ const removed = [];
212
+ const preserved = [];
213
+ const inventory = getForgeFiles(projectRoot);
214
+
215
+ // Remove .forge/ directory
216
+ for (const dir of inventory.config) {
217
+ const fullPath = path.join(projectRoot, dir);
218
+ if (fs.existsSync(fullPath)) {
219
+ fs.rmSync(fullPath, { recursive: true, force: true });
220
+ removed.push(dir);
221
+ }
222
+ }
223
+
224
+ // Remove individual forge files
225
+ const allFiles = [
226
+ ...inventory.commands,
227
+ ...inventory.rules,
228
+ ...inventory.scripts,
229
+ ...inventory.docs,
230
+ ...inventory.workflows,
231
+ ...inventory.syncScripts,
232
+ ];
233
+
234
+ for (const f of allFiles) {
235
+ const fullPath = path.join(projectRoot, f);
236
+ if (fs.existsSync(fullPath)) {
237
+ fs.rmSync(fullPath, { recursive: true, force: true });
238
+ removed.push(f);
239
+ }
240
+ }
241
+
242
+ // Remove sync scripts directory if it still exists (empty after file removal)
243
+ const syncDir = path.join(projectRoot, FORGE_SYNC_SCRIPTS_DIR);
244
+ if (fs.existsSync(syncDir)) {
245
+ fs.rmSync(syncDir, { recursive: true, force: true });
246
+ removed.push(FORGE_SYNC_SCRIPTS_DIR);
247
+ }
248
+
249
+ // Remove docs/forge/ directory if empty after file removal
250
+ const docsForgeDir = path.join(projectRoot, 'docs', 'forge');
251
+ if (fs.existsSync(docsForgeDir)) {
252
+ try {
253
+ const remaining = fs.readdirSync(docsForgeDir);
254
+ if (remaining.length === 0) {
255
+ fs.rmSync(docsForgeDir, { recursive: true, force: true });
256
+ removed.push('docs/forge');
257
+ }
258
+ } catch (_error) {
259
+ // Directory read failed, skip
260
+ }
261
+ }
262
+
263
+ // Remove agent directories
264
+ for (const dir of inventory.agentDirs) {
265
+ const fullPath = path.join(projectRoot, dir);
266
+ if (fs.existsSync(fullPath)) {
267
+ fs.rmSync(fullPath, { recursive: true, force: true });
268
+ removed.push(dir);
269
+ }
270
+ }
271
+
272
+ return { removed, preserved };
273
+ }
274
+
275
+ /**
276
+ * Remove all forge files and re-run setup (reinstall).
277
+ * Accepts a setupFn parameter for testability.
278
+ *
279
+ * @param {string} projectRoot - Project root directory
280
+ * @param {{ force?: boolean, setupFn?: Function }} options
281
+ * @returns {Promise<{ resetResult: object, setupResult: any }>}
282
+ */
283
+ async function reinstall(projectRoot, { force = false, setupFn } = {}) {
284
+ if (!force) {
285
+ throw new Error('Reinstall requires --force flag.');
286
+ }
287
+
288
+ const resetResult = resetHard(projectRoot, { force: true });
289
+
290
+ let setupResult = null;
291
+ if (setupFn) {
292
+ setupResult = await setupFn(projectRoot);
293
+ }
294
+
295
+ return { resetResult, setupResult };
296
+ }
297
+
298
+ module.exports = {
299
+ getForgeFiles,
300
+ resetSoft,
301
+ resetHard,
302
+ reinstall,
303
+ FORGE_COMMANDS,
304
+ FORGE_RULES,
305
+ FORGE_SCRIPTS,
306
+ FORGE_DOCS,
307
+ FORGE_WORKFLOWS,
308
+ FORGE_AGENT_DIRS,
309
+ };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse a task list markdown file and validate file ownership.
5
+ * Within each wave, no two tasks may own the same file.
6
+ * Cross-wave ownership is allowed (sequential execution prevents conflicts).
7
+ *
8
+ * @param {string} content - task list markdown content
9
+ * @returns {{ valid: boolean, violations: Array<{wave: number, task1: number, task2: number, file: string}> }}
10
+ */
11
+ function validateOwnership(content) {
12
+ const lines = content.split('\n');
13
+ const violations = [];
14
+
15
+ const wavePattern = /^## Wave (\d+)/;
16
+ const taskPattern = /^### Task (\d+)/;
17
+ const ownsPattern = /\*\*OWNS\*\*:\s*(.+)/;
18
+
19
+ let currentWave = null;
20
+ let currentTask = null;
21
+ // Map: wave number -> Map of file -> first task number that owns it
22
+ const waveOwnership = new Map();
23
+
24
+ for (const line of lines) {
25
+ const parsed = parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership);
26
+ if (parsed.matched) {
27
+ currentWave = parsed.currentWave;
28
+ currentTask = parsed.currentTask;
29
+ continue;
30
+ }
31
+
32
+ if (currentWave !== null && currentTask !== null) {
33
+ const ownedFiles = extractOwnedFiles(line, ownsPattern);
34
+ if (ownedFiles) {
35
+ checkOwnership(ownedFiles, waveOwnership.get(currentWave), currentWave, currentTask, violations);
36
+ }
37
+ }
38
+ }
39
+
40
+ return {
41
+ valid: violations.length === 0,
42
+ violations,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse a line for wave or task headers and update state accordingly.
48
+ * @param {string} line - Current line of markdown
49
+ * @param {RegExp} wavePattern - Pattern to match wave headers
50
+ * @param {RegExp} taskPattern - Pattern to match task headers
51
+ * @param {number|null} currentWave - Current wave number
52
+ * @param {number|null} currentTask - Current task number
53
+ * @param {Map} waveOwnership - Wave ownership map to initialize new waves
54
+ * @returns {{ matched: boolean, currentWave: number|null, currentTask: number|null }}
55
+ */
56
+ function parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership) {
57
+ const waveMatch = wavePattern.exec(line);
58
+ if (waveMatch) {
59
+ const wave = Number.parseInt(waveMatch[1], 10);
60
+ if (!waveOwnership.has(wave)) {
61
+ waveOwnership.set(wave, new Map());
62
+ }
63
+ return { matched: true, currentWave: wave, currentTask: null };
64
+ }
65
+
66
+ const taskMatch = taskPattern.exec(line);
67
+ if (taskMatch) {
68
+ return { matched: true, currentWave, currentTask: Number.parseInt(taskMatch[1], 10) };
69
+ }
70
+
71
+ return { matched: false, currentWave, currentTask };
72
+ }
73
+
74
+ /**
75
+ * Extract owned file paths from an OWNS line.
76
+ * @param {string} line - Current line of markdown
77
+ * @param {RegExp} ownsPattern - Pattern to match OWNS declarations
78
+ * @returns {string[]|null} Array of file paths, or null if line is not an OWNS line
79
+ */
80
+ function extractOwnedFiles(line, ownsPattern) {
81
+ const ownsMatch = ownsPattern.exec(line);
82
+ if (!ownsMatch) return null;
83
+
84
+ const filesRaw = ownsMatch[1];
85
+ const fileMatches = filesRaw.match(/`([^`]+)`/g);
86
+ if (!fileMatches) return null;
87
+
88
+ return fileMatches.map((f) => f.replaceAll('`', ''));
89
+ }
90
+
91
+ /**
92
+ * Check for ownership violations and record them.
93
+ * @param {string[]} files - Files declared as owned
94
+ * @param {Map<string, number>} ownership - File-to-task ownership map for the current wave
95
+ * @param {number} currentWave - Current wave number
96
+ * @param {number} currentTask - Current task number
97
+ * @param {Array} violations - Array to push violations into
98
+ */
99
+ function checkOwnership(files, ownership, currentWave, currentTask, violations) {
100
+ for (const file of files) {
101
+ if (ownership.has(file)) {
102
+ const firstTask = ownership.get(file);
103
+ if (firstTask !== currentTask) {
104
+ violations.push({
105
+ wave: currentWave,
106
+ task1: firstTask,
107
+ task2: currentTask,
108
+ file,
109
+ });
110
+ }
111
+ } else {
112
+ ownership.set(file, currentTask);
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = { validateOwnership };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "7-stage TDD workflow for ALL AI coding agents (Claude, Cursor, Cline, OpenCode, Copilot, Kilo Code, Roo Code, Codex)",
5
5
  "bin": {
6
6
  "forge": "bin/forge.js",
@@ -10,7 +10,7 @@
10
10
  "packages/*"
11
11
  ],
12
12
  "scripts": {
13
- "test": "bun test",
13
+ "test": "bun test --timeout 15000",
14
14
  "test:setup": "bash test-env/automation/setup-fixtures.sh",
15
15
  "test:all": "bun run test:setup && bun test",
16
16
  "test:coverage": "c8 --check-coverage bun test",