forge-workflow 0.0.8 → 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 (57) 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 +14 -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/plan.js +5 -2
  39. package/lib/commands/setup.js +231 -17
  40. package/lib/commands/ship.js +188 -5
  41. package/lib/commands/status.js +20 -33
  42. package/lib/commands/test.js +90 -25
  43. package/lib/commands/validate.js +218 -1
  44. package/lib/setup-action-log.js +2 -0
  45. package/lib/setup-summary-renderer.js +15 -11
  46. package/lib/workflow/enforce-stage.js +12 -8
  47. package/lib/workflow/state-manager.js +193 -0
  48. package/package.json +1 -1
  49. package/scripts/dep-guard.sh +11 -1
  50. package/scripts/forge-team/lib/hooks.sh +1 -1
  51. package/scripts/forge-team/lib/verify.sh +1 -1
  52. package/scripts/forge-team/lib/workload.sh +56 -27
  53. package/scripts/forge-team/tests/workload.test.sh +35 -4
  54. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  55. package/scripts/smart-status.sh +10 -1
  56. package/scripts/sync-utils.sh +39 -0
  57. package/scripts/test.js +144 -38
@@ -3,12 +3,16 @@
3
3
  * Detects workflow stage (1-9) with confidence scoring
4
4
  */
5
5
 
6
- const { secureExecFileSync } = require('../shell-utils');
7
6
  const {
8
7
  readWorkflowState,
9
8
  getAllowedTransitionsForWorkflowState,
10
9
  } = require('../workflow/state');
11
10
  const { STAGE_IDS } = require('../workflow/stages');
11
+ const {
12
+ loadState,
13
+ extractWorkflowStateFromComments,
14
+ readWorkflowStateFromBeads,
15
+ } = require('../workflow/state-manager');
12
16
 
13
17
  const WORKFLOW_STAGES = {
14
18
  1: { name: 'Fresh Start', nextCommand: 'research' },
@@ -338,42 +342,22 @@ function parseStatusInputs(args = [], flags = {}) {
338
342
  issueId: flags.issueId || flags['--issue-id'] || getInlineValue('--issue-id') || getNextValue('--issue-id'),
339
343
  workflowState: flags.workflowState || flags['--workflow-state'] || getInlineValue('--workflow-state') || getNextValue('--workflow-state'),
340
344
  bdComments: flags.bdComments || flags['--bd-comments'] || getInlineValue('--bd-comments') || getNextValue('--bd-comments'),
345
+ projectRoot: flags.projectRoot || flags['--project-root'] || getInlineValue('--project-root') || getNextValue('--project-root') || null,
341
346
  };
342
347
  }
343
348
 
344
- function extractWorkflowStateFromComments(comments = '') {
345
- const matches = String(comments).match(/^WorkflowState:\s*(\{.*\})$/gm);
346
- if (!matches || matches.length === 0) {
347
- return null;
348
- }
349
-
350
- const latest = matches.at(-1).replace(/^WorkflowState:\s*/, '');
351
- return readWorkflowState(latest);
352
- }
353
-
354
- function readWorkflowStateFromBeads(issueId, options = {}) {
355
- if (!issueId) {
356
- return null;
357
- }
358
-
359
- const comments = options.comments || secureExecFileSync('bd', ['comments', 'list', issueId], {
360
- encoding: 'utf8',
361
- stdio: ['pipe', 'pipe', 'pipe'],
362
- }).trim();
363
-
364
- if (!comments) {
365
- return null;
366
- }
367
-
368
- return extractWorkflowStateFromComments(comments);
369
- }
370
-
371
349
  function resolveWorkflowState(inputs) {
372
350
  try {
373
- const workflowState = inputs.workflowState
374
- ? readWorkflowState(inputs.workflowState)
375
- : readWorkflowStateFromBeads(inputs.issueId, { comments: inputs.bdComments });
376
- return { workflowState, fallbackReason: null };
351
+ if (inputs.workflowState) {
352
+ return { workflowState: readWorkflowState(inputs.workflowState), fallbackReason: null };
353
+ }
354
+
355
+ const { state } = loadState(inputs.projectRoot, {
356
+ issueId: inputs.issueId,
357
+ comments: inputs.bdComments,
358
+ });
359
+
360
+ return { workflowState: state, fallbackReason: null };
377
361
  } catch (error) {
378
362
  return {
379
363
  workflowState: null,
@@ -512,8 +496,11 @@ function formatStatus(result) {
512
496
  module.exports = {
513
497
  name: 'status',
514
498
  description: 'Intelligent stage detection with confidence scoring',
515
- handler: async (args, flags, _projectRoot) => {
499
+ handler: async (args, flags, projectRoot) => {
516
500
  const inputs = parseStatusInputs(args, flags);
501
+ if (!inputs.projectRoot && projectRoot) {
502
+ inputs.projectRoot = projectRoot;
503
+ }
517
504
  const { workflowState, fallbackReason } = resolveWorkflowState(inputs);
518
505
 
519
506
  if (workflowState) {
@@ -60,19 +60,7 @@ function checkBeadsConnectivity(execFileSync) {
60
60
  }
61
61
  }
62
62
 
63
- /**
64
- * Get changed files relative to main branch, mapped to test file paths.
65
- *
66
- * Falls back to `git diff --name-only HEAD` if merge-base fails.
67
- *
68
- * @param {string} _projectRoot - Project root (unused, git uses cwd)
69
- * @param {Function} execFileSync - Injected execFileSync
70
- * @returns {string[]} Array of test file paths (e.g. ['test/foo.test.js'])
71
- */
72
- function getAffectedTestFiles(_projectRoot, execFileSync) {
73
- let diffRef;
74
-
75
- // Detect default branch, then try merge-base
63
+ function resolveBaseBranch(execFileSync) {
76
64
  let baseBranch = 'main';
77
65
  try {
78
66
  baseBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'origin/HEAD'], {
@@ -88,17 +76,39 @@ function getAffectedTestFiles(_projectRoot, execFileSync) {
88
76
  } catch (_e2) { /* intentional: branch doesn't exist, try next name */ } // NOSONAR S2486
89
77
  }
90
78
  }
79
+ return baseBranch;
80
+ }
81
+
82
+ function resolveDiffRef(execFileSync, options = {}) {
83
+ if (options.sinceUpstream) {
84
+ try {
85
+ const upstreamRef = execFileSync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
86
+ encoding: 'utf8',
87
+ stdio: 'pipe',
88
+ timeout: 3000,
89
+ }).trim();
90
+ if (upstreamRef) {
91
+ return `${upstreamRef}...HEAD`;
92
+ }
93
+ } catch (_e) { // NOSONAR S2486
94
+ /* intentional: branch may not track a remote yet, fall back to base branch */
95
+ }
96
+ }
91
97
 
98
+ const baseBranch = resolveBaseBranch(execFileSync);
92
99
  try {
93
100
  const mergeBase = execFileSync('git', ['merge-base', 'HEAD', baseBranch], {
94
101
  encoding: 'utf8',
95
102
  timeout: 5000,
96
103
  }).trim();
97
- diffRef = `${mergeBase}...HEAD`;
104
+ return `${mergeBase}...HEAD`;
98
105
  } catch (_e) { /* intentional: merge-base failed, fallback to diff against HEAD */ // NOSONAR S2486
99
- diffRef = 'HEAD';
106
+ return 'HEAD';
100
107
  }
108
+ }
101
109
 
110
+ function getChangedFiles(execFileSync, options = {}) {
111
+ const diffRef = resolveDiffRef(execFileSync, options);
102
112
  let output;
103
113
  try {
104
114
  output = execFileSync('git', ['diff', '--name-only', diffRef], {
@@ -111,23 +121,76 @@ function getAffectedTestFiles(_projectRoot, execFileSync) {
111
121
  }
112
122
 
113
123
  if (!output) return [];
124
+ return output.split('\n').filter(Boolean);
125
+ }
114
126
 
115
- const changedFiles = output.split('\n').filter(Boolean);
116
-
117
- // Map lib/*.js files to test/*.test.js
118
- const testFiles = [];
127
+ /**
128
+ * Get changed files relative to main branch, mapped to test file paths.
129
+ *
130
+ * Falls back to `git diff --name-only HEAD` if merge-base fails.
131
+ *
132
+ * @param {string} projectRoot - Project root for test file existence checks
133
+ * @param {Function} execFileSync - Injected execFileSync
134
+ * @param {Object} [fs] - Injected fs module
135
+ * @param {Object} [options] - Diff selection options
136
+ * @returns {string[]} Array of test file paths (e.g. ['test/foo.test.js'])
137
+ */
138
+ function getAffectedTestFiles(projectRoot, execFileSync, fs = defaultFs, options = {}) {
139
+ const changedFiles = getChangedFiles(execFileSync, options);
140
+ const testFiles = new Set();
119
141
  for (const file of changedFiles) {
120
- if (file.startsWith('lib/') && file.endsWith('.js')) {
121
- const relative = file.slice('lib/'.length);
122
- const testFile = `test/${relative.replace(/\.js$/, '.test.js')}`;
123
- testFiles.push(testFile);
142
+ for (const candidate of getTestCandidatesForChangedFile(file)) {
143
+ if (fs.existsSync(path.join(projectRoot, candidate))) {
144
+ testFiles.add(candidate);
145
+ }
124
146
  }
125
147
  }
126
148
 
127
- return testFiles;
149
+ return Array.from(testFiles).sort((left, right) => left.localeCompare(right));
150
+ }
151
+
152
+ function getTestCandidatesForChangedFile(file) {
153
+ if (!file) return [];
154
+
155
+ if (file.startsWith('test/') && file.endsWith('.test.js')) {
156
+ return [file];
157
+ }
158
+
159
+ if (file.startsWith('lib/') && file.endsWith('.js')) {
160
+ const relative = file.slice('lib/'.length);
161
+ return [`test/${relative.replace(/\.js$/, '.test.js')}`];
162
+ }
163
+
164
+ if (file.startsWith('scripts/') && file.endsWith('.js')) {
165
+ const relative = file.slice('scripts/'.length).replace(/\.js$/, '.test.js');
166
+ return [
167
+ `test/scripts/${relative}`,
168
+ `test/${relative}`,
169
+ ];
170
+ }
171
+
172
+ if (file.startsWith('.github/workflows/')) {
173
+ const workflowName = path.basename(file, path.extname(file));
174
+ return [
175
+ `test/workflows/${workflowName}.test.js`,
176
+ 'test/ci-workflow.test.js',
177
+ ];
178
+ }
179
+
180
+ if (file.startsWith('.claude/commands/') && file.endsWith('.md')) {
181
+ return [
182
+ 'test/command-sync-check.test.js',
183
+ 'test/structural/command-sync.test.js',
184
+ ];
185
+ }
186
+
187
+ return [];
128
188
  }
129
189
 
130
190
  module.exports = {
191
+ getChangedFiles,
192
+ getAffectedTestFiles,
193
+ getTestCandidatesForChangedFile,
131
194
  name: 'test',
132
195
  description: 'Run tests with smart defaults (timeout, Beads skip, affected-only)',
133
196
  usage: 'forge test [--affected]',
@@ -181,7 +244,9 @@ module.exports = {
181
244
 
182
245
  // 5. --affected flag: find changed test files
183
246
  if (flags['--affected'] || flags.affected) {
184
- const affectedTests = getAffectedTestFiles(projectRoot, execFileSync);
247
+ const affectedTests = getAffectedTestFiles(projectRoot, execFileSync, fs, {
248
+ sinceUpstream: flags.sinceUpstream || flags['--since-upstream'],
249
+ });
185
250
  if (affectedTests.length > 0) {
186
251
  testArgs = ['run', 'test', ...affectedTests];
187
252
  }
@@ -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) {