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.
- package/.claude/commands/premerge.md +2 -2
- package/.claude/commands/review.md +5 -2
- package/.claude/commands/ship.md +4 -3
- package/.claude/rules/greptile-review-process.md +4 -4
- package/.cline/workflows/premerge.md +2 -2
- package/.cline/workflows/review.md +5 -2
- package/.cline/workflows/ship.md +4 -3
- package/.codex/skills/premerge/SKILL.md +2 -2
- package/.codex/skills/review/SKILL.md +5 -2
- package/.codex/skills/ship/SKILL.md +4 -3
- package/.cursor/commands/premerge.md +2 -2
- package/.cursor/commands/review.md +5 -2
- package/.cursor/commands/ship.md +4 -3
- package/.github/prompts/premerge.prompt.md +2 -2
- package/.github/prompts/review.prompt.md +5 -2
- package/.github/prompts/ship.prompt.md +4 -3
- package/.github/workflows/beads-to-github.yml +1 -1
- package/.github/workflows/github-to-beads.yml +1 -1
- package/.kilocode/workflows/premerge.md +2 -2
- package/.kilocode/workflows/review.md +5 -2
- package/.kilocode/workflows/ship.md +4 -3
- package/.opencode/commands/premerge.md +2 -2
- package/.opencode/commands/review.md +5 -2
- package/.opencode/commands/ship.md +4 -3
- package/.roo/commands/premerge.md +2 -2
- package/.roo/commands/review.md +5 -2
- package/.roo/commands/ship.md +4 -3
- package/AGENTS.md +9 -9
- package/README.md +12 -6
- package/bin/forge.js +21 -3
- package/docs/BEADS_GITHUB_SYNC.md +6 -2
- package/docs/EXAMPLES.md +22 -22
- package/docs/ROADMAP.md +3 -3
- package/docs/TOOLCHAIN.md +60 -52
- package/lib/agents/codex.plugin.json +3 -0
- package/lib/agents-config.js +18 -12
- package/lib/codex-skills.js +54 -1
- package/lib/commands/_issue.js +172 -0
- package/lib/commands/claim.js +5 -0
- package/lib/commands/close.js +5 -0
- package/lib/commands/create.js +5 -0
- package/lib/commands/issue.js +5 -0
- package/lib/commands/list.js +5 -0
- package/lib/commands/plan.js +5 -2
- package/lib/commands/ready.js +5 -0
- package/lib/commands/setup.js +231 -17
- package/lib/commands/ship.js +188 -5
- package/lib/commands/show.js +5 -0
- package/lib/commands/status.js +20 -33
- package/lib/commands/sync.js +3 -1
- package/lib/commands/test.js +90 -25
- package/lib/commands/update.js +5 -0
- package/lib/commands/validate.js +218 -1
- package/lib/setup-action-log.js +2 -0
- package/lib/setup-summary-renderer.js +15 -11
- package/lib/workflow/enforce-stage.js +12 -8
- package/lib/workflow/state-manager.js +193 -0
- package/package.json +1 -1
- package/scripts/dep-guard.sh +11 -1
- package/scripts/forge-team/lib/hooks.sh +1 -1
- package/scripts/forge-team/lib/verify.sh +1 -1
- package/scripts/forge-team/lib/workload.sh +56 -27
- package/scripts/forge-team/tests/workload.test.sh +35 -4
- package/scripts/github-beads-sync/run-bd.mjs +4 -2
- package/scripts/lib/eval-runner.js +50 -0
- package/scripts/smart-status.sh +10 -1
- package/scripts/sync-utils.sh +39 -0
- package/scripts/test.js +144 -38
package/lib/commands/validate.js
CHANGED
|
@@ -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
|
};
|
package/lib/setup-action-log.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
+
const inlineOrFlag = workflowState
|
|
80
78
|
|| flags.workflowState
|
|
81
79
|
|| flags['--workflow-state']
|
|
82
|
-
|| getCliFlagValue('--workflow-state', args)
|
|
83
|
-
|
|
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
package/scripts/dep-guard.sh
CHANGED
|
@@ -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-
|
|
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-
|
|
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 ───────────────────────────────────────────────────────────
|