forge-workflow 0.0.7 → 0.0.9

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 (68) hide show
  1. package/.claude/commands/premerge.md +2 -2
  2. package/.claude/commands/review.md +5 -2
  3. package/.claude/commands/ship.md +4 -3
  4. package/.claude/rules/greptile-review-process.md +4 -4
  5. package/.cline/workflows/premerge.md +2 -2
  6. package/.cline/workflows/review.md +5 -2
  7. package/.cline/workflows/ship.md +4 -3
  8. package/.codex/skills/premerge/SKILL.md +2 -2
  9. package/.codex/skills/review/SKILL.md +5 -2
  10. package/.codex/skills/ship/SKILL.md +4 -3
  11. package/.cursor/commands/premerge.md +2 -2
  12. package/.cursor/commands/review.md +5 -2
  13. package/.cursor/commands/ship.md +4 -3
  14. package/.github/prompts/premerge.prompt.md +2 -2
  15. package/.github/prompts/review.prompt.md +5 -2
  16. package/.github/prompts/ship.prompt.md +4 -3
  17. package/.github/workflows/beads-to-github.yml +1 -1
  18. package/.github/workflows/github-to-beads.yml +1 -1
  19. package/.kilocode/workflows/premerge.md +2 -2
  20. package/.kilocode/workflows/review.md +5 -2
  21. package/.kilocode/workflows/ship.md +4 -3
  22. package/.opencode/commands/premerge.md +2 -2
  23. package/.opencode/commands/review.md +5 -2
  24. package/.opencode/commands/ship.md +4 -3
  25. package/.roo/commands/premerge.md +2 -2
  26. package/.roo/commands/review.md +5 -2
  27. package/.roo/commands/ship.md +4 -3
  28. package/AGENTS.md +9 -9
  29. package/README.md +12 -6
  30. package/bin/forge.js +21 -3
  31. package/docs/BEADS_GITHUB_SYNC.md +6 -2
  32. package/docs/EXAMPLES.md +22 -22
  33. package/docs/ROADMAP.md +3 -3
  34. package/docs/TOOLCHAIN.md +60 -52
  35. package/lib/agents/codex.plugin.json +3 -0
  36. package/lib/agents-config.js +18 -12
  37. package/lib/codex-skills.js +54 -1
  38. package/lib/commands/_issue.js +172 -0
  39. package/lib/commands/claim.js +5 -0
  40. package/lib/commands/close.js +5 -0
  41. package/lib/commands/create.js +5 -0
  42. package/lib/commands/issue.js +5 -0
  43. package/lib/commands/list.js +5 -0
  44. package/lib/commands/plan.js +5 -2
  45. package/lib/commands/ready.js +5 -0
  46. package/lib/commands/setup.js +231 -17
  47. package/lib/commands/ship.js +188 -5
  48. package/lib/commands/show.js +5 -0
  49. package/lib/commands/status.js +20 -33
  50. package/lib/commands/sync.js +3 -1
  51. package/lib/commands/test.js +90 -25
  52. package/lib/commands/update.js +5 -0
  53. package/lib/commands/validate.js +218 -1
  54. package/lib/setup-action-log.js +2 -0
  55. package/lib/setup-summary-renderer.js +15 -11
  56. package/lib/workflow/enforce-stage.js +12 -8
  57. package/lib/workflow/state-manager.js +193 -0
  58. package/package.json +1 -1
  59. package/scripts/dep-guard.sh +11 -1
  60. package/scripts/forge-team/lib/hooks.sh +1 -1
  61. package/scripts/forge-team/lib/verify.sh +1 -1
  62. package/scripts/forge-team/lib/workload.sh +56 -27
  63. package/scripts/forge-team/tests/workload.test.sh +35 -4
  64. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  65. package/scripts/lib/eval-runner.js +50 -0
  66. package/scripts/smart-status.sh +10 -1
  67. package/scripts/sync-utils.sh +39 -0
  68. package/scripts/test.js +144 -38
@@ -14,12 +14,69 @@ const path = require('node:path');
14
14
 
15
15
  // Constants
16
16
  const CHECK_TYPES = {
17
+ CONFLICT_MARKERS: 'conflictMarkers',
17
18
  TYPE_CHECK: 'typeCheck',
18
19
  LINT: 'lint',
19
20
  SECURITY: 'security',
20
21
  TESTS: 'tests',
21
22
  };
22
23
 
24
+ const CONFLICT_MARKER_IGNORE_DIRS = new Set([
25
+ '.beads',
26
+ '.git',
27
+ '.next',
28
+ 'coverage',
29
+ 'dist',
30
+ 'node_modules',
31
+ ]);
32
+ const CONFLICT_MARKER_ALLOWED_DOT_DIRS = new Set([
33
+ '.claude',
34
+ '.cline',
35
+ '.codex',
36
+ '.cursor',
37
+ '.forge',
38
+ '.github',
39
+ '.husky',
40
+ '.kilocode',
41
+ '.opencode',
42
+ '.roo',
43
+ '.sonarlint',
44
+ ]);
45
+ const CONFLICT_START_PATTERN = /^<<<<<<<(?: .*)?$/;
46
+ const CONFLICT_DIVIDER_PATTERN = /^=======$/;
47
+ const CONFLICT_END_PATTERN = /^>>>>>>>.*$/;
48
+ const GIT_CONFLICT_SCAN_MAX_BUFFER = 16 * 1024 * 1024;
49
+
50
+ function listConflictScanFiles(rootDir) {
51
+ try {
52
+ const output = execFileSync(
53
+ 'git',
54
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard'],
55
+ { encoding: 'utf8', cwd: rootDir, timeout: 120000, maxBuffer: GIT_CONFLICT_SCAN_MAX_BUFFER },
56
+ );
57
+ return output
58
+ .split('\0')
59
+ .filter(file => file.length > 0)
60
+ .map(file => path.join(rootDir, file));
61
+ } catch (error) {
62
+ if (!isGitListingUnavailableError(error)) {
63
+ throw error;
64
+ }
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function shouldSkipConflictScanPath(relativePath) {
70
+ const segments = String(relativePath || '')
71
+ .split(/[\\/]+/)
72
+ .filter(Boolean);
73
+ const directorySegments = segments.slice(0, -1);
74
+ return directorySegments.some(segment => (
75
+ (segment.startsWith('.') && !CONFLICT_MARKER_ALLOWED_DOT_DIRS.has(segment))
76
+ || CONFLICT_MARKER_IGNORE_DIRS.has(segment)
77
+ ));
78
+ }
79
+
23
80
  function getExecOptions() {
24
81
  return { encoding: 'utf8', cwd: process.cwd(), timeout: 120000 };
25
82
  }
@@ -27,6 +84,15 @@ function getExecOptions() {
27
84
  const ERROR_PATTERNS = {
28
85
  COMMAND_NOT_FOUND: ['ENOENT', 'not found'],
29
86
  NO_LOCK_FILE: ['requires an existing', 'package-lock'],
87
+ GIT_LISTING_UNAVAILABLE: [
88
+ 'not a git repository',
89
+ 'maxBuffer', // git ls-files output exceeds maxBuffer
90
+ 'ENOBUFS', // OS-level buffer overflow
91
+ 'ETIMEDOUT', // subprocess timeout
92
+ 'EPERM', // permission denied
93
+ 'EACCES', // access denied
94
+ 'spawnSync', // general spawn failure
95
+ ],
30
96
  };
31
97
 
32
98
  /**
@@ -39,6 +105,14 @@ function isCommandNotFound(error) {
39
105
  );
40
106
  }
41
107
 
108
+ function isGitListingUnavailableError(error) {
109
+ if (!error || typeof error.message !== 'string') {
110
+ return false;
111
+ }
112
+ return isCommandNotFound(error)
113
+ || ERROR_PATTERNS.GIT_LISTING_UNAVAILABLE.some(pattern => error.message.includes(pattern));
114
+ }
115
+
42
116
  /**
43
117
  * Parse number from regex match
44
118
  * @private
@@ -57,6 +131,131 @@ function getCheckStatus(check) {
57
131
  return check.success ? 'PASS' : 'FAIL';
58
132
  }
59
133
 
134
+ function scanForConflictMarkers(rootDir = process.cwd()) {
135
+ const filesWithMarkers = [];
136
+
137
+ function inspectFile(absolutePath) {
138
+ const relativePath = path.relative(rootDir, absolutePath) || path.basename(absolutePath);
139
+ if (shouldSkipConflictScanPath(relativePath)) {
140
+ return;
141
+ }
142
+
143
+ try {
144
+ if (!fs.lstatSync(absolutePath).isFile()) {
145
+ return;
146
+ }
147
+ } catch (error) {
148
+ if (!canSkipConflictScanStatError(error)) {
149
+ throw error;
150
+ }
151
+ return;
152
+ }
153
+
154
+ const startLine = findConflictMarkerStartLine(readConflictScanFile(absolutePath));
155
+ if (startLine === null) {
156
+ return;
157
+ }
158
+
159
+ filesWithMarkers.push({ path: relativePath, line: startLine });
160
+ }
161
+
162
+ function walk(currentDir) {
163
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
164
+ if (shouldSkipConflictScanEntry(entry)) {
165
+ continue;
166
+ }
167
+
168
+ if (entry.isDirectory()) {
169
+ walk(path.join(currentDir, entry.name));
170
+ continue;
171
+ }
172
+
173
+ if (!entry.isFile()) {
174
+ continue;
175
+ }
176
+
177
+ inspectFile(path.join(currentDir, entry.name));
178
+ }
179
+ }
180
+
181
+ const trackedFiles = listConflictScanFiles(rootDir);
182
+ if (trackedFiles) {
183
+ for (const absolutePath of trackedFiles) {
184
+ inspectFile(absolutePath);
185
+ }
186
+ } else {
187
+ walk(rootDir);
188
+ }
189
+ filesWithMarkers.sort((left, right) => {
190
+ if (left.path === right.path) {
191
+ return left.line - right.line;
192
+ }
193
+ return left.path.localeCompare(right.path);
194
+ });
195
+
196
+ return {
197
+ success: filesWithMarkers.length === 0,
198
+ files: filesWithMarkers,
199
+ message: filesWithMarkers.length === 0
200
+ ? 'No conflict markers found'
201
+ : `Conflict markers found in ${filesWithMarkers.length} file(s)`,
202
+ };
203
+ }
204
+
205
+ function shouldSkipConflictScanEntry(entry) {
206
+ return entry.isDirectory() && (
207
+ (entry.name.startsWith('.') && !CONFLICT_MARKER_ALLOWED_DOT_DIRS.has(entry.name))
208
+ || CONFLICT_MARKER_IGNORE_DIRS.has(entry.name)
209
+ );
210
+ }
211
+
212
+ function canSkipConflictScanStatError(error) {
213
+ return Boolean(error && ['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code));
214
+ }
215
+
216
+ function readConflictScanFile(absolutePath) {
217
+ try {
218
+ return fs.readFileSync(absolutePath, 'utf8');
219
+ } catch (_error) {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ function findConflictMarkerStartLine(content) {
225
+ const lines = String(content || '').split(/\r?\n/);
226
+ let activeStartLine = null;
227
+ let sawDivider = false;
228
+
229
+ for (let index = 0; index < lines.length; index += 1) {
230
+ const line = lines[index];
231
+ const lineNumber = index + 1;
232
+ if (activeStartLine === null) {
233
+ if (CONFLICT_START_PATTERN.test(line)) {
234
+ activeStartLine = lineNumber;
235
+ sawDivider = false;
236
+ } else if (CONFLICT_END_PATTERN.test(line)) {
237
+ return lineNumber;
238
+ }
239
+ continue;
240
+ }
241
+
242
+ if (!sawDivider) {
243
+ if (CONFLICT_DIVIDER_PATTERN.test(line)) {
244
+ sawDivider = true;
245
+ } else if (CONFLICT_START_PATTERN.test(line)) {
246
+ activeStartLine = index + 1;
247
+ }
248
+ continue;
249
+ }
250
+
251
+ if (CONFLICT_END_PATTERN.test(line)) {
252
+ return activeStartLine;
253
+ }
254
+ }
255
+
256
+ return activeStartLine;
257
+ }
258
+
60
259
  /**
61
260
  * Parse vulnerability counts from audit output
62
261
  * @private
@@ -450,13 +649,29 @@ async function runAllTests() {
450
649
  * console.log(result.summary);
451
650
  */
452
651
  async function executeValidate(options = {}) { // NOSONAR S3776
453
- const { skip = [], continueOnError = true } = options || {};
652
+ const { skip = [], continueOnError = true, rootDir = process.cwd() } = options || {};
454
653
 
455
654
  const checks = {};
456
655
  const failedChecks = [];
457
656
  const errors = [];
458
657
  const startTime = Date.now();
459
658
 
659
+ // 0. Conflict markers
660
+ if (!skip.includes(CHECK_TYPES.CONFLICT_MARKERS)) {
661
+ try {
662
+ checks.conflictMarkers = scanForConflictMarkers(rootDir);
663
+ if (!checks.conflictMarkers.success) {
664
+ failedChecks.push(CHECK_TYPES.CONFLICT_MARKERS);
665
+ if (!continueOnError) {
666
+ return buildResult(checks, failedChecks, errors, startTime);
667
+ }
668
+ }
669
+ } catch (error) {
670
+ errors.push(`Conflict marker scan error: ${error.message}`);
671
+ checks.conflictMarkers = { success: false, message: error.message };
672
+ }
673
+ }
674
+
460
675
  // 1. Type checking
461
676
  if (!skip.includes(CHECK_TYPES.TYPE_CHECK)) {
462
677
  try {
@@ -535,6 +750,7 @@ function buildResult(checks, failedChecks, errors, startTime) {
535
750
  // Build summary using getCheckStatus helper
536
751
  const checkResults = [];
537
752
  const checkLabels = {
753
+ conflictMarkers: 'Conflict Markers',
538
754
  typeCheck: 'Type',
539
755
  lint: 'Lint',
540
756
  security: 'Security',
@@ -610,6 +826,7 @@ module.exports = {
610
826
  runLint,
611
827
  runSecurityScan,
612
828
  runAllTests,
829
+ scanForConflictMarkers,
613
830
  executeValidate,
614
831
  executeDebugMode,
615
832
  };
@@ -14,6 +14,8 @@ const AGENT_PREFIXES = {
14
14
  '.windsurf/': 'Windsurf',
15
15
  '.cline/': 'Cline',
16
16
  '.codex/': 'Codex',
17
+ '~/.codex/': 'Codex',
18
+ '$CODEX_HOME/': 'Codex',
17
19
  '.opencode/': 'OpenCode',
18
20
  '.kilocode/': 'Kilocode',
19
21
  '.roo/': 'Roo Code',
@@ -26,11 +26,11 @@ function capitalize(str) {
26
26
  * @param {boolean} verbose - Whether to show file-by-file detail
27
27
  * @returns {string} The formatted summary output
28
28
  */
29
- function renderSetupSummary(actionLog, agentNames, verbose) {
29
+ function renderSetupSummary(actionLog, agentNames, verbose, options = {}) {
30
30
  if (verbose) {
31
- return renderVerbose(actionLog, agentNames);
31
+ return renderVerbose(actionLog, agentNames, options);
32
32
  }
33
- return renderDefault(actionLog, agentNames);
33
+ return renderDefault(actionLog, agentNames, options);
34
34
  }
35
35
 
36
36
  /**
@@ -40,10 +40,11 @@ function renderSetupSummary(actionLog, agentNames, verbose) {
40
40
  * @param {string[]} agentNames
41
41
  * @returns {string}
42
42
  */
43
- function renderDefault(actionLog, agentNames) {
43
+ function renderDefault(actionLog, agentNames, options = {}) {
44
44
  const agentCount = agentNames.length;
45
45
  const agentLabel = agentCount === 1 ? '1 agent' : `${agentCount} agents`;
46
46
  const agentList = agentNames.length > 0 ? ` (${agentNames.join(', ')})` : '';
47
+ const status = options.status === 'partial' ? 'partially complete' : 'complete';
47
48
 
48
49
  const summary = actionLog.getSummary();
49
50
 
@@ -63,7 +64,7 @@ function renderDefault(actionLog, agentNames) {
63
64
  }
64
65
 
65
66
  const lines = [];
66
- lines.push(`Forge setup complete — ${agentLabel} configured${agentList}`);
67
+ lines.push(`Forge setup ${status} — ${agentLabel} configured${agentList}`);
67
68
 
68
69
  if (parts.length > 0) {
69
70
  lines.push(` ${parts.join(' | ')}`);
@@ -83,10 +84,18 @@ function renderDefault(actionLog, agentNames) {
83
84
  * @param {string[]} _agentNames - Not used in verbose (agents come from log data)
84
85
  * @returns {string}
85
86
  */
86
- function renderVerbose(actionLog, _agentNames) {
87
+ function renderVerbose(actionLog, _agentNames, options = {}) {
87
88
  const agentSummary = actionLog.getAgentSummary();
89
+ const status = options.status === 'partial' ? 'partially complete' : 'complete';
88
90
  const lines = [];
89
91
 
92
+ if (Object.keys(agentSummary).length === 0) {
93
+ return 'No file operations recorded.';
94
+ }
95
+
96
+ lines.push(`Forge setup ${status}`);
97
+ lines.push('');
98
+
90
99
  for (const [agent, actions] of Object.entries(agentSummary)) {
91
100
  for (const [action, files] of Object.entries(actions)) {
92
101
  const fileCount = files.length;
@@ -95,11 +104,6 @@ function renderVerbose(actionLog, _agentNames) {
95
104
  lines.push(`${agent}: ${fileList} (${fileLabel}) [${action}]`);
96
105
  }
97
106
  }
98
-
99
- if (lines.length === 0) {
100
- return 'No file operations recorded.';
101
- }
102
-
103
107
  return lines.join('\n');
104
108
  }
105
109
 
@@ -1,8 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
-
6
3
  const { repairWorkflowRuntimeAssets } = require('../commands/setup');
7
4
  const { checkRuntimeHealth } = require('../runtime-health');
8
5
  const { normalizeStageId } = require('./stages');
@@ -11,8 +8,7 @@ const {
11
8
  normalizeOverrideRecord,
12
9
  readWorkflowState,
13
10
  } = require('./state');
14
-
15
- const WORKFLOW_STATE_FILENAME = '.forge-state.json';
11
+ const { loadState, WORKFLOW_STATE_FILENAME } = require('./state-manager');
16
12
 
17
13
  function getOverrideInput(flags = {}) {
18
14
  if (Object.hasOwn(flags, 'overrideStage')) {
@@ -67,6 +63,8 @@ function readWorkflowStateFile(projectRoot) {
67
63
  return null;
68
64
  }
69
65
 
66
+ const fs = require('node:fs');
67
+ const path = require('node:path');
70
68
  const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
71
69
  if (!fs.existsSync(statePath)) {
72
70
  return null;
@@ -76,11 +74,17 @@ function readWorkflowStateFile(projectRoot) {
76
74
  }
77
75
 
78
76
  function resolveWorkflowStateInput(workflowState, flags = {}, args = [], projectRoot) {
79
- return workflowState
77
+ const inlineOrFlag = workflowState
80
78
  || flags.workflowState
81
79
  || flags['--workflow-state']
82
- || getCliFlagValue('--workflow-state', args)
83
- || readWorkflowStateFile(projectRoot);
80
+ || getCliFlagValue('--workflow-state', args);
81
+
82
+ if (inlineOrFlag) {
83
+ return inlineOrFlag;
84
+ }
85
+
86
+ const { state } = loadState(projectRoot);
87
+ return state;
84
88
  }
85
89
 
86
90
  function readWorkflowStateInput(input) {
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { secureExecFileSync } = require('../shell-utils.js');
7
+ const { readWorkflowState, serializeWorkflowState, WORKFLOW_STATE_SCHEMA_VERSION, getAllowedTransitionsForWorkflowState } = require('./state.js');
8
+ const { getWorkflowPath, WORKFLOW_CLASSIFICATIONS, normalizeStageId } = require('./stages.js');
9
+
10
+ const WORKFLOW_STATE_FILENAME = '.forge-state.json';
11
+
12
+ function extractWorkflowStateFromComments(comments = '') {
13
+ const matches = String(comments).match(/^WorkflowState:\s*(\{.*\})$/gm);
14
+ if (!matches || matches.length === 0) {
15
+ return null;
16
+ }
17
+
18
+ const latest = matches.at(-1).replace(/^WorkflowState:\s*/, '');
19
+ return readWorkflowState(latest);
20
+ }
21
+
22
+ function readWorkflowStateFromBeads(issueId, options = {}) {
23
+ if (!issueId) {
24
+ return null;
25
+ }
26
+
27
+ const comments = options.comments || secureExecFileSync('bd', ['comments', 'list', issueId], {
28
+ encoding: 'utf8',
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ }).trim();
31
+
32
+ if (!comments) {
33
+ return null;
34
+ }
35
+
36
+ return extractWorkflowStateFromComments(comments);
37
+ }
38
+
39
+ function loadStateFromBeads(options) {
40
+ if (options.comments) {
41
+ const state = extractWorkflowStateFromComments(options.comments);
42
+ if (state) {
43
+ return { state, source: 'beads' };
44
+ }
45
+ }
46
+
47
+ if (options.issueId) {
48
+ const state = readWorkflowStateFromBeads(options.issueId, { comments: options.comments });
49
+ if (state) {
50
+ return { state, source: 'beads' };
51
+ }
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ function loadState(projectRoot, options = {}) {
58
+ if (!projectRoot) {
59
+ const beadsResult = loadStateFromBeads(options);
60
+ return beadsResult || { state: null, source: null };
61
+ }
62
+
63
+ const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
64
+ if (fs.existsSync(statePath)) {
65
+ try {
66
+ const raw = fs.readFileSync(statePath, 'utf8');
67
+ return { state: readWorkflowState(raw), source: 'file' };
68
+ } catch (_parseError) {
69
+ // File is malformed — fall through to Beads fallback
70
+ }
71
+ }
72
+
73
+ const beadsResult = loadStateFromBeads(options);
74
+ if (beadsResult) {
75
+ return beadsResult;
76
+ }
77
+
78
+ return { state: null, source: null };
79
+ }
80
+
81
+ function saveState(projectRoot, state) {
82
+ if (!projectRoot || typeof projectRoot !== 'string') {
83
+ throw new Error('saveState requires a valid projectRoot path');
84
+ }
85
+
86
+ const normalized = serializeWorkflowState(state);
87
+ const json = JSON.stringify(normalized, null, 2);
88
+ const tmpPath = path.join(projectRoot, `${WORKFLOW_STATE_FILENAME}.tmp`);
89
+ const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
90
+
91
+ fs.writeFileSync(tmpPath, json, 'utf8');
92
+ fs.renameSync(tmpPath, statePath);
93
+
94
+ return normalized;
95
+ }
96
+
97
+ function initializeState(projectRoot, classification, firstStage) {
98
+ if (!WORKFLOW_CLASSIFICATIONS.includes(classification)) {
99
+ throw new Error(`Invalid classification: ${classification}. Expected one of: ${WORKFLOW_CLASSIFICATIONS.join(', ')}`);
100
+ }
101
+
102
+ const workflowPath = getWorkflowPath(classification);
103
+ const currentStage = firstStage || workflowPath[0];
104
+
105
+ const state = {
106
+ schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
107
+ currentStage,
108
+ completedStages: [],
109
+ skippedStages: [],
110
+ workflowDecisions: {
111
+ classification,
112
+ reason: 'initialized',
113
+ userOverride: false,
114
+ overrides: [],
115
+ },
116
+ parallelTracks: [],
117
+ };
118
+
119
+ return saveState(projectRoot, state);
120
+ }
121
+
122
+ function transitionStage(projectRoot, toStage, options = {}) {
123
+ const targetStage = normalizeStageId(toStage);
124
+ if (!targetStage) {
125
+ throw new Error(`Invalid target stage: ${toStage}`);
126
+ }
127
+
128
+ const { state: currentState } = loadState(projectRoot, options);
129
+ if (!currentState) {
130
+ throw new Error('No workflow state found. Initialize state first with initializeState().');
131
+ }
132
+
133
+ const previousState = JSON.parse(JSON.stringify(currentState));
134
+ const allowed = getAllowedTransitionsForWorkflowState(currentState);
135
+
136
+ if (!allowed.includes(targetStage)) {
137
+ if (!options.override) {
138
+ throw new Error(
139
+ `Transition from ${currentState.currentStage} to ${targetStage} is not allowed. ` +
140
+ `Allowed transitions: ${allowed.join(', ') || 'none'}. Provide an override to force.`
141
+ );
142
+ }
143
+ }
144
+
145
+ const completedStages = [...currentState.completedStages];
146
+ if (!completedStages.includes(currentState.currentStage)) {
147
+ completedStages.push(currentState.currentStage);
148
+ }
149
+
150
+ const overrides = [...(currentState.workflowDecisions.overrides || [])];
151
+ if (options.override) {
152
+ overrides.push({
153
+ type: options.override.type || 'manual',
154
+ fromStage: currentState.currentStage,
155
+ toStage: targetStage,
156
+ reason: options.override.reason || '',
157
+ actor: options.override.actor || 'unknown',
158
+ userOverride: true,
159
+ recordedAt: new Date().toISOString(),
160
+ });
161
+ }
162
+
163
+ const newStateInput = {
164
+ schemaVersion: currentState.schemaVersion || WORKFLOW_STATE_SCHEMA_VERSION,
165
+ currentStage: targetStage,
166
+ completedStages,
167
+ skippedStages: currentState.skippedStages || [],
168
+ workflowDecisions: {
169
+ ...currentState.workflowDecisions,
170
+ overrides,
171
+ userOverride: overrides.length > 0,
172
+ },
173
+ parallelTracks: currentState.parallelTracks || [],
174
+ };
175
+
176
+ const newState = saveState(projectRoot, newStateInput);
177
+
178
+ return {
179
+ previousState,
180
+ newState,
181
+ transitioned: true,
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ WORKFLOW_STATE_FILENAME,
187
+ extractWorkflowStateFromComments,
188
+ initializeState,
189
+ loadState,
190
+ readWorkflowStateFromBeads,
191
+ saveState,
192
+ transitionStage,
193
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-workflow",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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",
@@ -426,7 +426,7 @@ cmd_check_ripple_keyword_v1() {
426
426
 
427
427
  # Extract issue ID (forge-xxx pattern)
428
428
  local cand_id=""
429
- cand_id="$(printf '%s' "$line" | grep -oE 'forge-[a-z0-9]+' | head -1)" || continue
429
+ cand_id="$(printf '%s' "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)" || continue
430
430
  [[ -z "$cand_id" ]] && continue
431
431
 
432
432
  # Skip the source issue itself
@@ -561,6 +561,16 @@ cmd_check_ripple() {
561
561
 
562
562
  local tmp_dir
563
563
  tmp_dir="$(mktemp -d)"
564
+ # On Windows/Git Bash, normalize to a mixed-mode path so both bash file I/O
565
+ # and Node.js resolve to the same physical location (cygpath -m → C:/...).
566
+ # Preserve the original POSIX path if conversion fails or returns empty.
567
+ if command -v cygpath &>/dev/null; then
568
+ local mixed_tmp_dir
569
+ mixed_tmp_dir="$(cygpath -m "$tmp_dir" 2>/dev/null)" || true
570
+ if [[ -n "$mixed_tmp_dir" && -d "$mixed_tmp_dir" ]]; then
571
+ tmp_dir="$mixed_tmp_dir"
572
+ fi
573
+ fi
564
574
  trap 'rm -rf "$tmp_dir"' RETURN
565
575
 
566
576
  printf '%s' "$src_json" > "${tmp_dir}/current.json"
@@ -148,7 +148,7 @@ forge_team_sync() {
148
148
 
149
149
  # Extract issue id
150
150
  local issue_id
151
- issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+' | head -1)"
151
+ issue_id="$(echo "$line" | grep -oE 'forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*' | head -1)"
152
152
  [[ -z "$issue_id" ]] && continue
153
153
 
154
154
  # Get details to check for github_issue state
@@ -75,7 +75,7 @@ _extract_beads_ids() {
75
75
  if [[ -z "$input" ]]; then
76
76
  return 0
77
77
  fi
78
- printf '%s\n' "$input" | grep -oP '(forge-[a-z0-9]+)' || true
78
+ printf '%s\n' "$input" | grep -oP '(forge-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)' || true
79
79
  }
80
80
 
81
81
  # ── Public API ───────────────────────────────────────────────────────────