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
@@ -29,6 +29,178 @@ function isCommandNotFound(error) {
29
29
  return error.message.includes('ENOENT') || error.message.includes('not found');
30
30
  }
31
31
 
32
+ function getGitExecOptions(cwd = process.cwd()) {
33
+ return { ...getExecOptions(), cwd };
34
+ }
35
+
36
+ function getQuietGitExecOptions(cwd = process.cwd()) {
37
+ return { ...getGitExecOptions(cwd), stdio: 'pipe' };
38
+ }
39
+
40
+ function resolveRemoteHeadTarget(exec = execFileSync, cwd = process.cwd(), remoteName) {
41
+ try {
42
+ const symbolicRef = exec('git', ['symbolic-ref', `refs/remotes/${remoteName}/HEAD`], getQuietGitExecOptions(cwd)).trim();
43
+ if (!symbolicRef) {
44
+ return null;
45
+ }
46
+ exec('git', ['rev-parse', '--verify', symbolicRef], getQuietGitExecOptions(cwd));
47
+ return symbolicRef;
48
+ } catch (_error) {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function remoteHasTrackingBase(exec = execFileSync, cwd = process.cwd(), remoteName) {
54
+ if (resolveRemoteHeadTarget(exec, cwd, remoteName)) {
55
+ return true;
56
+ }
57
+
58
+ for (const candidate of ['main', 'master']) {
59
+ try {
60
+ exec('git', ['rev-parse', '--verify', `refs/remotes/${remoteName}/${candidate}`], getQuietGitExecOptions(cwd));
61
+ return true;
62
+ } catch (_error) {
63
+ // Probe next candidate.
64
+ }
65
+ }
66
+
67
+ return false;
68
+ }
69
+
70
+ function resolveBaseRemote(exec = execFileSync, cwd = process.cwd()) {
71
+ for (const candidate of ['upstream', 'origin']) {
72
+ try {
73
+ exec('git', ['remote', 'get-url', candidate], getQuietGitExecOptions(cwd));
74
+ if (remoteHasTrackingBase(exec, cwd, candidate)) {
75
+ return candidate;
76
+ }
77
+ } catch (_error) {
78
+ // Probe next candidate.
79
+ }
80
+ }
81
+
82
+ return 'origin';
83
+ }
84
+
85
+ function resolveBaseBranch(exec = execFileSync, cwd = process.cwd(), remoteName = resolveBaseRemote(exec, cwd)) {
86
+ const symbolicRef = resolveRemoteHeadTarget(exec, cwd, remoteName);
87
+ if (symbolicRef) {
88
+ const match = new RegExp(`^refs/remotes/${remoteName}/(.+)$`).exec(symbolicRef);
89
+ if (match && match[1]) {
90
+ return match[1];
91
+ }
92
+ }
93
+
94
+ for (const candidate of ['main', 'master']) {
95
+ try {
96
+ exec('git', ['rev-parse', '--verify', `refs/remotes/${remoteName}/${candidate}`], getQuietGitExecOptions(cwd));
97
+ return candidate;
98
+ } catch (_error) {
99
+ // Probe next candidate.
100
+ }
101
+ }
102
+
103
+ return 'master';
104
+ }
105
+
106
+ function refreshBaseReference(exec = execFileSync, cwd = process.cwd(), remoteName, baseBranch) {
107
+ exec(
108
+ 'git',
109
+ ['fetch', '--quiet', '--no-tags', remoteName, `refs/heads/${baseBranch}:refs/remotes/${remoteName}/${baseBranch}`],
110
+ getQuietGitExecOptions(cwd),
111
+ );
112
+ }
113
+
114
+ function getBranchReadiness(options = {}) {
115
+ const exec = options.exec || execFileSync;
116
+ const cwd = options.cwd || process.cwd();
117
+ const baseRemote = options.baseRemote || resolveBaseRemote(exec, cwd);
118
+ const baseBranch = options.baseBranch || resolveBaseBranch(exec, cwd, baseRemote);
119
+ const baseRef = `${baseRemote}/${baseBranch}`;
120
+ const branchName = exec('git', ['rev-parse', '--abbrev-ref', 'HEAD'], getGitExecOptions(cwd)).trim();
121
+
122
+ if (!branchName || branchName === 'HEAD') {
123
+ return {
124
+ ready: false,
125
+ branchName: branchName || 'HEAD',
126
+ baseRemote,
127
+ baseBranch,
128
+ error: 'Current HEAD is detached. Check out a feature branch before running /ship.',
129
+ };
130
+ }
131
+
132
+ try {
133
+ refreshBaseReference(exec, cwd, baseRemote, baseBranch);
134
+ } catch (error) {
135
+ return {
136
+ ready: false,
137
+ branchName,
138
+ baseRemote,
139
+ baseBranch,
140
+ error: `Unable to refresh ${baseRef} before comparing branch readiness. Verify that remote '${baseRemote}' and branch '${baseBranch}' can be fetched. Git said: ${error.message}`,
141
+ };
142
+ }
143
+
144
+ let counts;
145
+ try {
146
+ counts = exec('git', ['rev-list', '--left-right', '--count', `${baseRef}...HEAD`], getGitExecOptions(cwd)).trim();
147
+ } catch (error) {
148
+ return {
149
+ ready: false,
150
+ branchName,
151
+ baseRemote,
152
+ baseBranch,
153
+ error: `Unable to compare the current branch against ${baseRef}. Verify that remote '${baseRemote}' and branch '${baseBranch}' exist. Git said: ${error.message}`,
154
+ };
155
+ }
156
+
157
+ const [behindRaw = '0', aheadRaw = '0'] = counts.split(/\s+/);
158
+ const behind = Number.parseInt(behindRaw, 10) || 0;
159
+ const ahead = Number.parseInt(aheadRaw, 10) || 0;
160
+
161
+ let hasDiff = false;
162
+ try {
163
+ exec('git', ['diff', '--quiet', `${baseRef}...HEAD`, '--'], getGitExecOptions(cwd));
164
+ } catch (error) {
165
+ if (error.status === 1) {
166
+ hasDiff = true;
167
+ } else {
168
+ return {
169
+ ready: false,
170
+ branchName,
171
+ baseRemote,
172
+ baseBranch,
173
+ ahead,
174
+ behind,
175
+ error: `Unable to inspect the tree diff against ${baseRef}. Git said: ${error.message}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ if (!hasDiff) {
181
+ return {
182
+ ready: false,
183
+ branchName,
184
+ baseRemote,
185
+ baseBranch,
186
+ ahead,
187
+ behind,
188
+ error:
189
+ `Current branch ${branchName} has no diff against ${baseRef}. ` +
190
+ 'It is not PR-ready because all changes are already upstream or the branch collapsed onto the base during rebase.',
191
+ };
192
+ }
193
+
194
+ return {
195
+ ready: true,
196
+ branchName,
197
+ baseRemote,
198
+ baseBranch,
199
+ ahead,
200
+ behind,
201
+ };
202
+ }
203
+
32
204
  /**
33
205
  * Validate feature slug format
34
206
  * Ensures slug matches expected pattern and doesn't contain path traversal
@@ -256,11 +428,11 @@ function validatePRTitle(title) {
256
428
  }
257
429
 
258
430
  async function createPR(options) { // NOSONAR S3776
259
- const { title, body, dryRun = false } = options;
431
+ const { title, body, dryRun = false, exec = execFileSync, cwd = process.cwd() } = options;
260
432
  const titleValidation = validatePRTitle(title);
261
433
  if (!titleValidation.valid) return { success: false, error: titleValidation.error };
262
434
  try {
263
- execFileSync('gh', ['--version'], getGhCheckOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
435
+ exec('gh', ['--version'], { ...getGhCheckOptions(), cwd }); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
264
436
  } catch (error) {
265
437
  if (isCommandNotFound(error)) {
266
438
  return { success: false, error: 'GitHub CLI (gh) not found. Install from: https://cli.github.com/' };
@@ -272,22 +444,30 @@ async function createPR(options) { // NOSONAR S3776
272
444
  return { success: false, error: `GitHub CLI check failed: ${error.message}` };
273
445
  }
274
446
  try {
275
- execFileSync('git', ['rev-parse', '--git-dir'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
447
+ exec('git', ['rev-parse', '--git-dir'], getGitExecOptions(cwd)); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
276
448
  } catch (error_) { // NOSONAR S2486 - intentional: not-a-git-repo is the expected failure signal
277
449
  void error_;
278
450
  return { success: false, error: 'Not in a git repository. Initialize with: git init' };
279
451
  }
280
452
  try {
281
- execFileSync('git', ['remote', 'get-url', 'origin'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
453
+ exec('git', ['remote', 'get-url', 'origin'], getGitExecOptions(cwd)); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
282
454
  } catch (error_) { // NOSONAR S2486 - intentional: no-remote is the expected failure signal
283
455
  void error_;
284
456
  return { success: false, error: 'No git remote configured. Add with: git remote add origin <url>' };
285
457
  }
458
+ try {
459
+ const readiness = getBranchReadiness({ exec, cwd });
460
+ if (!readiness.ready) {
461
+ return { success: false, error: readiness.error };
462
+ }
463
+ } catch (error) {
464
+ return { success: false, error: `Failed to verify branch readiness: ${error.message}` };
465
+ }
286
466
  if (dryRun) {
287
467
  return { success: true, message: '[DRY RUN] Would create PR with title: ' + title, prUrl: 'https://github.com/owner/repo/pull/1' };
288
468
  }
289
469
  try {
290
- const result = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
470
+ const result = exec('gh', ['pr', 'create', '--title', title, '--body', body], getGitExecOptions(cwd)); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
291
471
  const urlMatch = /https:\/\/github\.com\/[^\s]+/.exec(result);
292
472
  const prUrl = urlMatch ? urlMatch[0] : null;
293
473
  const numberMatch = /\/pull\/(\d+)/.exec(result);
@@ -394,4 +574,7 @@ module.exports = {
394
574
  validatePRTitle,
395
575
  createPR,
396
576
  executeShip,
577
+ getBranchReadiness,
578
+ resolveBaseRemote,
579
+ resolveBaseBranch,
397
580
  };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { makeAliasCommand } = require('./_issue');
4
+
5
+ module.exports = makeAliasCommand('show');
@@ -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) {
@@ -7,7 +7,9 @@ function isRecoverableBeadsSyncError(error) {
7
7
  return (
8
8
  message.includes('failed to open database') ||
9
9
  message.includes('database not found') ||
10
- message.includes('no beads configuration found')
10
+ message.includes('no beads configuration found') ||
11
+ message.includes('remote \'origin\' not found') ||
12
+ message.includes('remote "origin" not found')
11
13
  );
12
14
  }
13
15
 
@@ -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
  }
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ const { makeAliasCommand } = require('./_issue');
4
+
5
+ module.exports = makeAliasCommand('update');