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
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  const fs = require('node:fs');
13
+ const os = require('node:os');
13
14
  const path = require('node:path');
14
15
  const readline = require('node:readline');
15
16
  const { execSync, execFileSync } = require('node:child_process');
@@ -46,7 +47,11 @@ const { renderSetupSummary } = require('../setup-summary-renderer');
46
47
  const { smartMergeAgentsMd } = require('../smart-merge');
47
48
  const { checkLefthookStatus } = require('../lefthook-check');
48
49
  const { resolveShellRuntime } = require('../runtime-health');
49
- const { listCodexSkillEntries } = require('../codex-skills');
50
+ const {
51
+ buildCodexSkillInstallPlan,
52
+ formatCodexSkillsInstallDir,
53
+ listCodexSkillEntries,
54
+ } = require('../codex-skills');
50
55
  const {
51
56
  generateCopilotConfig,
52
57
  generateCursorConfig,
@@ -69,6 +74,8 @@ let SYMLINK_ONLY = false;
69
74
  let SYNC_ENABLED = false;
70
75
  let actionLog = new SetupActionLog();
71
76
  let PKG_MANAGER = 'npm';
77
+ let SETUP_NOTES = [];
78
+ let CODEX_SETUP_REPORT = null;
72
79
 
73
80
  /**
74
81
  * Load agent definitions from plugin architecture
@@ -81,7 +88,7 @@ function loadAgentsFromPlugins() {
81
88
  agents[id] = {
82
89
  name: plugin.name,
83
90
  description: plugin.description || '',
84
- dirs: Object.values(plugin.directories || {}),
91
+ dirs: getRepoRelativePluginDirectories(plugin),
85
92
  hasCommands: plugin.capabilities?.commands || plugin.setup?.copyCommands || false,
86
93
  hasSkill: plugin.capabilities?.skills || plugin.setup?.createSkill || false,
87
94
  linkFile: plugin.files?.rootConfig || '',
@@ -95,6 +102,17 @@ function loadAgentsFromPlugins() {
95
102
  return agents;
96
103
  }
97
104
 
105
+ function isRepoRelativePluginPath(candidate) {
106
+ return typeof candidate === 'string'
107
+ && candidate.length > 0
108
+ && !path.isAbsolute(candidate)
109
+ && !/^[~$%]/.test(candidate);
110
+ }
111
+
112
+ function getRepoRelativePluginDirectories(plugin) {
113
+ return Object.values(plugin.directories || {}).filter(isRepoRelativePluginPath);
114
+ }
115
+
98
116
  const AGENTS = loadAgentsFromPlugins();
99
117
  Object.freeze(AGENTS);
100
118
  Object.values(AGENTS).forEach(agent => Object.freeze(agent));
@@ -187,11 +205,112 @@ function validateAgents(agentList) {
187
205
  return valid;
188
206
  }
189
207
 
208
+ function parseDoltRemoteNames(remoteListOutput) {
209
+ const output = String(remoteListOutput || '').trim();
210
+ if (!output || /^No remotes configured\.?$/i.test(output)) {
211
+ return [];
212
+ }
213
+
214
+ return output
215
+ .split(/\r?\n/)
216
+ .map(line => line.trim())
217
+ .filter(Boolean)
218
+ .map(line => line.split(/\s+/)[0])
219
+ .filter(Boolean);
220
+ }
221
+
222
+ function readBeadsSyncRemoteConfig(projectDir = projectRoot) {
223
+ const jsonConfigPath = path.join(projectDir, '.beads', 'config.json');
224
+ if (fs.existsSync(jsonConfigPath)) {
225
+ try {
226
+ const parsed = JSON.parse(fs.readFileSync(jsonConfigPath, 'utf8'));
227
+ if (typeof parsed.sync_remote === 'string' && parsed.sync_remote.trim()) {
228
+ return parsed.sync_remote.trim();
229
+ }
230
+ } catch (_error) {
231
+ // Ignore malformed config and fall back to other sources.
232
+ }
233
+ }
234
+
235
+ const yamlConfigPath = path.join(projectDir, '.beads', 'config.yaml');
236
+ if (fs.existsSync(yamlConfigPath)) {
237
+ const configuredRemote = parseBeadsYamlSyncRemote(fs.readFileSync(yamlConfigPath, 'utf8'));
238
+ if (configuredRemote) {
239
+ return configuredRemote;
240
+ }
241
+ }
242
+
243
+ return '';
244
+ }
245
+
246
+ function parseBeadsYamlSyncRemote(content) {
247
+ for (const rawLine of String(content || '').split(/\r?\n/)) {
248
+ const trimmedLine = rawLine.trimStart();
249
+ if (!trimmedLine.startsWith('sync-remote:')) {
250
+ continue;
251
+ }
252
+
253
+ const rawValue = trimmedLine.slice('sync-remote:'.length).trim();
254
+ if (!rawValue) {
255
+ return '';
256
+ }
257
+
258
+ const quote = rawValue[0];
259
+ if ((quote === '"' || quote === '\'') && rawValue.length > 1) {
260
+ const closingQuoteIndex = rawValue.indexOf(quote, 1);
261
+ if (closingQuoteIndex > 1) {
262
+ return rawValue.slice(1, closingQuoteIndex).trim();
263
+ }
264
+ }
265
+
266
+ const commentIndex = rawValue.indexOf('#');
267
+ const unquotedValue = commentIndex === -1 ? rawValue : rawValue.slice(0, commentIndex);
268
+ return unquotedValue.trim();
269
+ }
270
+
271
+ return '';
272
+ }
273
+
274
+ function resolveExpectedBeadsRemote(options = {}) {
275
+ const projectDir = options.projectDir || projectRoot;
276
+ const env = options.env || process.env;
277
+ const configuredRemote = readBeadsSyncRemoteConfig(projectDir);
278
+ if (configuredRemote) {
279
+ return configuredRemote;
280
+ }
281
+
282
+ if (typeof env.BD_SYNC_REMOTE === 'string' && env.BD_SYNC_REMOTE.trim()) {
283
+ return env.BD_SYNC_REMOTE.trim();
284
+ }
285
+
286
+ const gitRemoteProbe = options.gitRemoteProbe
287
+ || ((remoteName) => safeExec(`git -C "${projectDir}" remote get-url ${remoteName}`));
288
+ if (gitRemoteProbe('upstream')) {
289
+ return 'upstream';
290
+ }
291
+
292
+ return 'origin';
293
+ }
294
+
295
+ function hasBeadsDoltRemote(commandRunner, remoteName = 'origin') {
296
+ const output = commandRunner('bd dolt remote list');
297
+ if (!output) {
298
+ return null;
299
+ }
300
+
301
+ return parseDoltRemoteNames(output).includes(remoteName);
302
+ }
303
+
190
304
  // Prerequisite check function
191
305
  function checkPrerequisites(options = {}) {
192
306
  const requireGithubCli = options.requireGithubCli !== false;
193
307
  const requireBeadsCli = options.requireBeadsCli === true;
194
308
  const requireJq = options.requireJq === true;
309
+ const expectedBeadsRemote = options.expectedBeadsRemote || resolveExpectedBeadsRemote({
310
+ env: options.env,
311
+ gitRemoteProbe: options.gitRemoteProbe,
312
+ projectDir: options.projectDir,
313
+ });
195
314
  const commandRunner = options.commandRunner || safeExec;
196
315
  const errors = [];
197
316
  const warnings = [];
@@ -230,6 +349,20 @@ function checkPrerequisites(options = {}) {
230
349
  const bdVersion = commandRunner('bd --version');
231
350
  if (bdVersion) {
232
351
  console.log(` ✓ ${bdVersion.split('\n')[0]}`);
352
+ if (requireBeadsCli) {
353
+ const hasRemote = hasBeadsDoltRemote(commandRunner, expectedBeadsRemote);
354
+ if (hasRemote === false) {
355
+ warnings.push(
356
+ `Beads Dolt remote '${expectedBeadsRemote}' is not configured. ` +
357
+ `Sync will remain local until you run: bd dolt remote add ${expectedBeadsRemote} <url>`
358
+ );
359
+ } else if (hasRemote === null) {
360
+ warnings.push(
361
+ `Unable to inspect Beads Dolt remotes. ` +
362
+ `Sync may remain local until '${expectedBeadsRemote}' is configured and bd dolt remote list succeeds.`
363
+ );
364
+ }
365
+ }
233
366
  } else if (requireBeadsCli) {
234
367
  errors.push('bd (Beads CLI) - Install from https://github.com/steveyegge/beads');
235
368
  }
@@ -343,12 +476,60 @@ function writeFile(filePath, content) {
343
476
  return fileUtils.writeFile(filePath, content, projectRoot);
344
477
  }
345
478
 
479
+ function writeManagedAbsoluteFile(absolutePath, content, displayPath) {
480
+ try {
481
+ if (!FORCE_MODE && fileMatchesContent(absolutePath, content)) {
482
+ actionLog.add(displayPath, 'skipped', 'identical content');
483
+ return true;
484
+ }
485
+
486
+ const dir = path.dirname(absolutePath);
487
+ if (!fs.existsSync(dir)) {
488
+ fs.mkdirSync(dir, { recursive: true });
489
+ }
490
+
491
+ const existed = fs.existsSync(absolutePath);
492
+ fs.writeFileSync(absolutePath, content, { mode: 0o644 });
493
+ actionLog.add(displayPath, FORCE_MODE ? 'force-created' : (existed ? 'updated' : 'created'));
494
+ return true;
495
+ } catch (err) {
496
+ console.error(` × Failed to write ${displayPath}: ${err.message}`);
497
+ return false;
498
+ }
499
+ }
500
+
346
501
 
347
502
 
348
503
  function readFile(filePath) {
349
504
  return fileUtils.readFile(filePath);
350
505
  }
351
506
 
507
+ function resetSetupNotes() {
508
+ SETUP_NOTES = [];
509
+ CODEX_SETUP_REPORT = null;
510
+ }
511
+
512
+ function addSetupNote(message) {
513
+ if (!message || SETUP_NOTES.includes(message)) {
514
+ return;
515
+ }
516
+ SETUP_NOTES.push(message);
517
+ }
518
+
519
+ function getSetupSummaryStatus() {
520
+ return SETUP_NOTES.length > 0 ? 'partial' : 'complete';
521
+ }
522
+
523
+ function printSetupNotes() {
524
+ if (SETUP_NOTES.length === 0) {
525
+ return;
526
+ }
527
+
528
+ for (const note of SETUP_NOTES) {
529
+ console.log(` Warning: ${note}`);
530
+ }
531
+ }
532
+
352
533
 
353
534
 
354
535
  function copyFile(src, dest) { // NOSONAR — Extracted as-is from bin/forge.js; complexity reduction deferred
@@ -1741,15 +1922,42 @@ function createAgentSkill(agent, agentKey) {
1741
1922
 
1742
1923
  // Helper: Create Codex per-stage skills from canonical commands
1743
1924
  function createCodexSkills() {
1744
- const entries = listCodexSkillEntries(packageDir);
1925
+ const installPlan = buildCodexSkillInstallPlan(packageDir, { env: process.env, homeDir: os.homedir() });
1926
+ const installRoot = formatCodexSkillsInstallDir({ env: process.env, homeDir: os.homedir() });
1927
+
1928
+ CODEX_SETUP_REPORT = {
1929
+ installRoot,
1930
+ skillCount: installPlan.length,
1931
+ status: 'complete',
1932
+ message: '',
1933
+ };
1745
1934
 
1746
- for (const entry of entries) {
1747
- writeFile(path.join(entry.dir, entry.filename), entry.content);
1935
+ if (installPlan.length === 0) {
1936
+ CODEX_SETUP_REPORT.status = 'partial';
1937
+ CODEX_SETUP_REPORT.message = 'Codex setup could not find the packaged stage skill templates.';
1938
+ addSetupNote(CODEX_SETUP_REPORT.message);
1939
+ console.log(' Warning: Codex stage skill templates were not found in the Forge package');
1940
+ return CODEX_SETUP_REPORT;
1748
1941
  }
1749
1942
 
1750
- if (entries.length > 0) {
1751
- console.log(` Created: Codex stage skills (${entries.length})`);
1943
+ let failed = 0;
1944
+ for (const entry of installPlan) {
1945
+ if (!writeManagedAbsoluteFile(entry.absolutePath, entry.content, entry.displayPath)) {
1946
+ failed += 1;
1947
+ }
1948
+ }
1949
+
1950
+ if (failed > 0) {
1951
+ CODEX_SETUP_REPORT.status = 'partial';
1952
+ CODEX_SETUP_REPORT.message = `Codex repo instructions installed, but skills are not discoverable in this environment. Forge could not install ${failed}/${installPlan.length} Codex skills into ${installRoot}. Use \`bunx forge status\`, \`bunx forge plan\`, and the other Forge CLI stages until global Codex skills can be installed.`;
1953
+ addSetupNote(CODEX_SETUP_REPORT.message);
1954
+ console.log(` Warning: Codex skill install incomplete (${installPlan.length - failed}/${installPlan.length}) -> ${installRoot}`);
1955
+ return CODEX_SETUP_REPORT;
1752
1956
  }
1957
+
1958
+ CODEX_SETUP_REPORT.message = `Codex setup complete — installed ${installPlan.length} stage skills to ${installRoot}`;
1959
+ console.log(` Installed: Codex stage skills (${installPlan.length}) -> ${installRoot}`);
1960
+ return CODEX_SETUP_REPORT;
1753
1961
  }
1754
1962
 
1755
1963
  // Helper: Setup MCP config for Claude
@@ -2210,9 +2418,10 @@ async function setupAgentsWithProgress(selectedAgents, claudeCommands, skipFiles
2210
2418
  * Display final setup summary
2211
2419
  */
2212
2420
  function displaySetupSummary(selectedAgents) {
2421
+ const partial = getSetupSummaryStatus() === 'partial';
2213
2422
  console.log('');
2214
2423
  console.log('==============================================');
2215
- console.log(` Forge v${VERSION} Setup Complete!`);
2424
+ console.log(` Forge v${VERSION} Setup ${partial ? 'Partially Complete' : 'Complete'}!`);
2216
2425
  console.log('==============================================');
2217
2426
  console.log('');
2218
2427
  console.log('What\'s installed:');
@@ -2230,8 +2439,9 @@ function displaySetupSummary(selectedAgents) {
2230
2439
  console.log(` - ${agent.dirs[0]}/ (${workflowCount} workflow commands)`);
2231
2440
  }
2232
2441
  if (key === 'codex') {
2233
- const skillCount = listCodexSkillEntries(packageDir).length;
2234
- console.log(` - .codex/skills/<stage>/SKILL.md (${skillCount} stage skills)`);
2442
+ const skillCount = CODEX_SETUP_REPORT?.skillCount ?? listCodexSkillEntries(packageDir).length;
2443
+ const codexRoot = CODEX_SETUP_REPORT?.installRoot || formatCodexSkillsInstallDir({ env: process.env, homeDir: os.homedir() });
2444
+ console.log(` - ${codexRoot}/<stage>/SKILL.md (${skillCount} stage skills)`);
2235
2445
  } else if (agent.hasSkill) {
2236
2446
  const skillDir = agent.dirs.find(d => d.includes('/skills/'));
2237
2447
  if (skillDir) {
@@ -2263,10 +2473,11 @@ function displaySetupSummary(selectedAgents) {
2263
2473
  console.log('');
2264
2474
  console.log('Project Tools Status:');
2265
2475
  console.log('');
2476
+ printSetupNotes();
2266
2477
 
2267
2478
  // Beads status
2268
2479
  if (isBeadsInitialized()) {
2269
- console.log(' ✓ Beads initialized - Track work: bd ready');
2480
+ console.log(' ✓ Beads initialized - Track work: forge ready');
2270
2481
  } else if (checkForBeads()) {
2271
2482
  console.log(' ! Beads available - Run: bd init');
2272
2483
  } else {
@@ -3018,7 +3229,7 @@ async function setupProjectTools(rl, question) {
3018
3229
  console.log('');
3019
3230
  console.log('• Beads - Git-backed issue tracking');
3020
3231
  console.log(' Persists tasks across sessions, tracks dependencies.');
3021
- console.log(' Command: bd ready, bd create, bd close');
3232
+ console.log(' Command: forge ready, forge create, forge close');
3022
3233
  console.log('');
3023
3234
  console.log('• Skills - Universal SKILL.md management');
3024
3235
  console.log(' Manage AI agent skills across all agents.');
@@ -3250,7 +3461,8 @@ async function quickSetup(selectedAgents, skipExternal) {
3250
3461
 
3251
3462
  // Progressive setup summary
3252
3463
  console.log('');
3253
- console.log(renderSetupSummary(actionLog, selectedAgents, VERBOSE_MODE));
3464
+ console.log(renderSetupSummary(actionLog, selectedAgents, VERBOSE_MODE, { status: getSetupSummaryStatus() }));
3465
+ printSetupNotes();
3254
3466
  console.log('');
3255
3467
  }
3256
3468
 
@@ -3752,9 +3964,9 @@ function dryRunSetup(agents) { // NOSONAR — Extracted as-is from bin/forge.js;
3752
3964
 
3753
3965
  // Agent skill
3754
3966
  if (agentKey === 'codex') {
3755
- const skillEntries = listCodexSkillEntries(packageDir);
3967
+ const skillEntries = buildCodexSkillInstallPlan(packageDir, { env: process.env, homeDir: os.homedir() });
3756
3968
  for (const entry of skillEntries) {
3757
- addFileAction(path.join(entry.dir, entry.filename), 'Codex stage skill');
3969
+ addFileAction(entry.displayPath, 'Codex stage skill');
3758
3970
  }
3759
3971
  } else if (agent.hasSkill) {
3760
3972
  const skillDir = agent.dirs.find(d => d.includes('/skills/'));
@@ -3853,7 +4065,8 @@ async function executeSetup(config) {
3853
4065
 
3854
4066
  // Progressive setup summary
3855
4067
  console.log('');
3856
- console.log(renderSetupSummary(actionLog, agents, VERBOSE_MODE));
4068
+ console.log(renderSetupSummary(actionLog, agents, VERBOSE_MODE, { status: getSetupSummaryStatus() }));
4069
+ printSetupNotes();
3857
4070
  console.log('');
3858
4071
  }
3859
4072
 
@@ -3976,7 +4189,7 @@ function detectConfiguredAgents(dir) {
3976
4189
  };
3977
4190
 
3978
4191
  pluginManager.getAllPlugins().forEach((plugin, id) => {
3979
- const dirs = Object.values(plugin.directories || {});
4192
+ const dirs = getRepoRelativePluginDirectories(plugin);
3980
4193
  const files = Object.values(plugin.files || {});
3981
4194
  const markers = [...dirs, ...files].filter(Boolean);
3982
4195
  const isConfigured = markers.some((marker) => fs.existsSync(path.join(dir, marker)));
@@ -4198,6 +4411,7 @@ module.exports = {
4198
4411
  if (flags.symlink) SYMLINK_ONLY = true;
4199
4412
  if (flags.sync) SYNC_ENABLED = true;
4200
4413
  actionLog = new SetupActionLog();
4414
+ resetSetupNotes();
4201
4415
  PKG_MANAGER = detectPackageManager();
4202
4416
 
4203
4417
  // Determine agents to install
@@ -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
  };