forge-workflow 0.0.9 → 0.0.10

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 (102) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/ship.md +2 -2
  4. package/.claude/commands/status.md +4 -4
  5. package/.cline/workflows/dev.md +2 -2
  6. package/.cline/workflows/plan.md +2 -2
  7. package/.cline/workflows/ship.md +2 -2
  8. package/.cline/workflows/status.md +4 -4
  9. package/.codex/skills/dev/SKILL.md +2 -2
  10. package/.codex/skills/plan/SKILL.md +2 -2
  11. package/.codex/skills/ship/SKILL.md +2 -2
  12. package/.codex/skills/status/SKILL.md +4 -4
  13. package/.cursor/commands/dev.md +2 -2
  14. package/.cursor/commands/plan.md +2 -2
  15. package/.cursor/commands/ship.md +2 -2
  16. package/.cursor/commands/status.md +4 -4
  17. package/.github/prompts/dev.prompt.md +2 -2
  18. package/.github/prompts/plan.prompt.md +2 -2
  19. package/.github/prompts/ship.prompt.md +2 -2
  20. package/.github/prompts/status.prompt.md +4 -4
  21. package/.github/workflows/beads-to-github.yml +43 -10
  22. package/.github/workflows/github-to-beads.yml +10 -7
  23. package/.kilocode/workflows/dev.md +2 -2
  24. package/.kilocode/workflows/plan.md +2 -2
  25. package/.kilocode/workflows/ship.md +2 -2
  26. package/.kilocode/workflows/status.md +4 -4
  27. package/.opencode/commands/dev.md +2 -2
  28. package/.opencode/commands/plan.md +2 -2
  29. package/.opencode/commands/ship.md +2 -2
  30. package/.opencode/commands/status.md +4 -4
  31. package/.roo/commands/dev.md +2 -2
  32. package/.roo/commands/plan.md +2 -2
  33. package/.roo/commands/ship.md +2 -2
  34. package/.roo/commands/status.md +4 -4
  35. package/AGENTS.md +1 -0
  36. package/CLAUDE.md +12 -0
  37. package/bin/forge.js +10 -5
  38. package/docs/BEADS_GITHUB_SYNC.md +26 -0
  39. package/docs/TOOLCHAIN.md +130 -148
  40. package/lib/beads-bootstrap.js +225 -0
  41. package/lib/beads-health-check.js +55 -10
  42. package/lib/beads-setup.js +104 -28
  43. package/lib/beads-sync-scaffold.js +11 -6
  44. package/lib/commands/_issue.js +11 -1
  45. package/lib/commands/issues.js +49 -0
  46. package/lib/commands/recommend.js +22 -1
  47. package/lib/commands/setup.js +16 -10
  48. package/lib/commands/status.js +181 -0
  49. package/lib/commands/team.js +11 -1
  50. package/lib/commands/test.js +37 -2
  51. package/lib/commands/validate.js +14 -8
  52. package/lib/commands/worktree.js +27 -54
  53. package/lib/dep-guard/keyword-ripple.js +184 -0
  54. package/lib/detect-worktree.js +9 -10
  55. package/lib/forge-issues.js +326 -0
  56. package/lib/issue-sync/authority.js +100 -0
  57. package/lib/issue-sync/github-pull.js +184 -0
  58. package/lib/issue-sync/import-primitives.js +98 -0
  59. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  60. package/lib/issue-sync/link-store.js +292 -0
  61. package/lib/issue-sync/project-github.js +123 -0
  62. package/lib/issue-sync/reconcile.js +195 -0
  63. package/lib/issue-sync/schema.js +126 -0
  64. package/lib/lefthook-check.js +5 -2
  65. package/lib/project-memory.js +564 -0
  66. package/lib/runtime-health.js +100 -12
  67. package/lib/smart-status/conflicts.js +205 -0
  68. package/lib/smart-status/scoring.js +177 -0
  69. package/lib/status/beads-snapshot.js +102 -0
  70. package/lib/status/presenter.js +65 -0
  71. package/lib/workflow/enforce-stage.js +3 -1
  72. package/lib/workflow/state-manager.js +164 -8
  73. package/package.json +12 -4
  74. package/scripts/beads-context.sh +124 -5
  75. package/scripts/beads-context.test.js +21 -4
  76. package/scripts/beads-migrate-to-dolt.sh +7 -0
  77. package/scripts/beads-upgrade-smoke.sh +263 -0
  78. package/scripts/behavioral-judge.sh +115 -11
  79. package/scripts/benchmark.js +349 -63
  80. package/scripts/dep-guard-analyze.js +52 -17
  81. package/scripts/dep-guard-keyword-ripple.js +29 -0
  82. package/scripts/dep-guard-render-review.js +86 -0
  83. package/scripts/dep-guard.sh +45 -232
  84. package/scripts/forge-team/lib/sync-github.sh +160 -28
  85. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  86. package/scripts/github-beads-sync/index.mjs +122 -98
  87. package/scripts/github-beads-sync/mapping.mjs +54 -0
  88. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  89. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  90. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  91. package/scripts/preflight.sh +181 -0
  92. package/scripts/smart-status-score.js +31 -0
  93. package/scripts/smart-status-sessions.js +51 -0
  94. package/scripts/smart-status.sh +74 -329
  95. package/scripts/sync-agentic-workflow.js +48 -0
  96. package/scripts/test-ci-shard.js +244 -0
  97. package/scripts/test-dashboard.js +188 -52
  98. package/scripts/test-full-suite.js +186 -0
  99. package/scripts/test-profile.js +278 -0
  100. package/scripts/test.js +219 -28
  101. package/scripts/validate.js +143 -0
  102. package/scripts/validate.sh +18 -1
@@ -116,4 +116,25 @@ function handleRecommend(flags, projectPath) {
116
116
  return { budgetMode, recommendations };
117
117
  }
118
118
 
119
- module.exports = { formatRecommendations, handleRecommend };
119
+ async function handler(_args = [], flags = {}, projectRoot) {
120
+ const result = handleRecommend(flags, projectRoot);
121
+ if (result.error) {
122
+ return { success: false, error: result.error };
123
+ }
124
+
125
+ const output = [
126
+ '',
127
+ `🔧 Forge Tool Recommendations (Budget: ${result.budgetMode})`,
128
+ formatRecommendations(result.recommendations),
129
+ ].join('\n');
130
+
131
+ return { success: true, output };
132
+ }
133
+
134
+ module.exports = {
135
+ name: 'recommend',
136
+ description: 'Show tool recommendations for the current project',
137
+ handler,
138
+ formatRecommendations,
139
+ handleRecommend
140
+ };
@@ -36,7 +36,7 @@ const { createSymlinkOrCopy: libCreateSymlinkOrCopy } = require(path.join(packag
36
36
  const beadsSetupLib = require(path.join(packageDir, 'lib', 'beads-setup'));
37
37
  const { beadsHealthCheck } = require(path.join(packageDir, 'lib', 'beads-health-check'));
38
38
  const { setupPAT } = require(path.join(packageDir, 'lib', 'pat-setup'));
39
- const { detectDefaultBranch, detectBeadsVersion, templateWorkflows, scaffoldBeadsSync } = require(path.join(packageDir, 'lib', 'beads-sync-scaffold'));
39
+ const { DEFAULT_BEADS_VERSION, detectDefaultBranch, templateWorkflows, scaffoldBeadsSync } = require(path.join(packageDir, 'lib', 'beads-sync-scaffold'));
40
40
 
41
41
  // Load incremental setup modules
42
42
  const { detectEnvironment } = require('../detect-agent');
@@ -2734,13 +2734,15 @@ function installGitHooks() { // NOSONAR — Extracted as-is from bin/forge.js; c
2734
2734
  execFileSync('lefthook', ['install'], { stdio: 'inherit', cwd: projectRoot });
2735
2735
  console.log(' ✓ Lefthook hooks installed (global)');
2736
2736
  }
2737
- } catch (err) {
2738
- console.warn('Lefthook installation failed:', err.message);
2739
- console.log(' Lefthook not found. Install it:');
2740
- console.log(' bun add -d lefthook (recommended)');
2741
- console.log(' OR: bun add -g lefthook (global)');
2742
- console.log(' Then run: bunx lefthook install');
2743
- }
2737
+ } catch (err) {
2738
+ console.warn('Lefthook installation failed:', err.message);
2739
+ console.warn(' Lefthook hooks were not installed; raw git push remains unsafe in this worktree.');
2740
+ console.log(' Lefthook not found. Install it:');
2741
+ console.log(' bun add -d lefthook (recommended)');
2742
+ console.log(' OR: bun add -g lefthook (global)');
2743
+ console.log(' Then run: bunx lefthook install');
2744
+ console.log(` Run ${PKG_MANAGER} install in this worktree, then rerun setup.`);
2745
+ }
2744
2746
 
2745
2747
  console.log('');
2746
2748
 
@@ -2785,7 +2787,9 @@ function repairDeclaredLefthookDependency(selectedAgents) {
2785
2787
  return { attempted: true, repaired: true };
2786
2788
  } catch (err) {
2787
2789
  console.warn('Lefthook install failed:', err.message);
2790
+ console.warn(' ⚠ Lefthook repair failed; raw git push remains unsafe in this worktree.');
2788
2791
  console.log(` ⚠ ${status.message}`);
2792
+ console.log(` Run ${PKG_MANAGER} install in this worktree, then rerun setup.`);
2789
2793
  console.log('');
2790
2794
  return { attempted: true, repaired: false, error: err };
2791
2795
  }
@@ -3314,7 +3318,9 @@ function autoInstallLefthook() { // NOSONAR — Extracted as-is from bin/forge.j
3314
3318
  console.log(' ✓ Lefthook binary restored');
3315
3319
  } catch (err) {
3316
3320
  console.warn('Lefthook install failed:', err.message);
3321
+ console.warn(' ⚠ Lefthook repair failed; raw git push remains unsafe in this worktree.');
3317
3322
  console.log(` ⚠ ${status.message}`);
3323
+ console.log(` Run ${PKG_MANAGER} install in this worktree, then rerun setup.`);
3318
3324
  }
3319
3325
  console.log('');
3320
3326
  return;
@@ -4087,9 +4093,9 @@ async function handleSyncScaffold() {
4087
4093
  console.log(` Skipped: ${f} (already exists)`);
4088
4094
  }
4089
4095
 
4090
- // Detect default branch and Beads version, then template workflows
4096
+ // Detect default branch and pin forge-managed workflows to the repo baseline Beads version.
4091
4097
  const branch = detectDefaultBranch(projectRoot);
4092
- const beadsVersion = detectBeadsVersion();
4098
+ const beadsVersion = DEFAULT_BEADS_VERSION;
4093
4099
  const workflowDir = path.join(projectRoot, '.github', 'workflows');
4094
4100
  templateWorkflows(workflowDir, branch, beadsVersion, result.filesCreated || []);
4095
4101
  console.log(` Branch: ${branch}, Beads version: ${beadsVersion}`);
@@ -8,11 +8,15 @@ const {
8
8
  getAllowedTransitionsForWorkflowState,
9
9
  } = require('../workflow/state');
10
10
  const { STAGE_IDS } = require('../workflow/stages');
11
+ const { detectWorktree } = require('../detect-worktree');
12
+ const { readBeadsSnapshot } = require('../status/beads-snapshot');
13
+ const { formatZeroArgStatus } = require('../status/presenter');
11
14
  const {
12
15
  loadState,
13
16
  extractWorkflowStateFromComments,
14
17
  readWorkflowStateFromBeads,
15
18
  } = require('../workflow/state-manager');
19
+ const { secureExecFileSync } = require('../shell-utils.js');
16
20
 
17
21
  const WORKFLOW_STAGES = {
18
22
  1: { name: 'Fresh Start', nextCommand: 'research' },
@@ -346,6 +350,138 @@ function parseStatusInputs(args = [], flags = {}) {
346
350
  };
347
351
  }
348
352
 
353
+ function runGitCommand(projectRoot, args) {
354
+ if (!projectRoot) {
355
+ return '';
356
+ }
357
+
358
+ try {
359
+ return secureExecFileSync('git', args, {
360
+ encoding: 'utf8',
361
+ cwd: projectRoot,
362
+ stdio: ['pipe', 'pipe', 'pipe'],
363
+ }).trim();
364
+ } catch (_error) {
365
+ return '';
366
+ }
367
+ }
368
+
369
+ function detectRepoContext(projectRoot = process.cwd()) {
370
+ const worktree = detectWorktree(projectRoot);
371
+ const branch = worktree.branch || runGitCommand(projectRoot, ['branch', '--show-current']) || 'unknown';
372
+ const statusOutput = runGitCommand(projectRoot, ['status', '--short']);
373
+ const changedEntries = statusOutput ? statusOutput.split(/\r?\n/).filter(Boolean) : [];
374
+ const clean = changedEntries.length === 0;
375
+ const summary = clean ? 'clean' : `${changedEntries.length} uncommitted change${changedEntries.length === 1 ? '' : 's'}`;
376
+
377
+ return {
378
+ branch,
379
+ inWorktree: worktree.inWorktree === true,
380
+ worktreePath: worktree.currentWorktree || projectRoot,
381
+ mainWorktree: worktree.mainWorktree || projectRoot,
382
+ workingTree: {
383
+ clean,
384
+ summary,
385
+ },
386
+ };
387
+ }
388
+
389
+ function readIssueDetails(issueId, projectRoot = process.cwd()) {
390
+ if (!issueId) {
391
+ return null;
392
+ }
393
+
394
+ try {
395
+ const raw = secureExecFileSync('bd', ['show', issueId, '--json'], {
396
+ encoding: 'utf8',
397
+ cwd: projectRoot,
398
+ stdio: ['pipe', 'pipe', 'pipe'],
399
+ }).trim();
400
+ if (!raw) {
401
+ return null;
402
+ }
403
+
404
+ const parsed = JSON.parse(raw);
405
+ return Array.isArray(parsed) ? parsed[0] || null : parsed;
406
+ } catch (_error) {
407
+ return null;
408
+ }
409
+ }
410
+
411
+ function extractDesignSlugs(design) {
412
+ if (typeof design !== 'string' || design.trim() === '') {
413
+ return [];
414
+ }
415
+
416
+ const slugs = new Set();
417
+ const pattern = /docs[\\/]+plans[\\/]+\d{4}-\d{2}-\d{2}-([a-z0-9-]+?)-(?:design|tasks|decisions)\.md/g;
418
+ let match = pattern.exec(design);
419
+ while (match) {
420
+ slugs.add(match[1]);
421
+ match = pattern.exec(design);
422
+ }
423
+
424
+ return [...slugs];
425
+ }
426
+
427
+ function designMatchesFeatureSlug(issue, featureSlug) {
428
+ if (!issue || !featureSlug) {
429
+ return false;
430
+ }
431
+
432
+ return extractDesignSlugs(issue.design).includes(featureSlug);
433
+ }
434
+
435
+ function discoverCurrentIssue(snapshot, context, projectRoot = process.cwd()) {
436
+ const activeAssigned = snapshot.activeAssigned || [];
437
+ if (activeAssigned.length === 0) {
438
+ return null;
439
+ }
440
+
441
+ const branchInfo = analyzeBranch(context.branch || 'master');
442
+ const hydratedIssues = new Map();
443
+
444
+ function hydrateIssue(issue) {
445
+ if (!issue || !issue.id) {
446
+ return issue;
447
+ }
448
+
449
+ if (hydratedIssues.has(issue.id)) {
450
+ return hydratedIssues.get(issue.id);
451
+ }
452
+
453
+ const details = issue.design && issue.comments ? issue : readIssueDetails(issue.id, projectRoot);
454
+ const hydratedIssue = details ? { ...issue, ...details } : issue;
455
+ hydratedIssues.set(issue.id, hydratedIssue);
456
+ return hydratedIssue;
457
+ }
458
+
459
+ if (branchInfo.featureSlug) {
460
+ const slugMatches = [];
461
+
462
+ for (const issue of activeAssigned) {
463
+ const candidate = issue.design ? issue : hydrateIssue(issue);
464
+ if (designMatchesFeatureSlug(candidate, branchInfo.featureSlug)) {
465
+ slugMatches.push(candidate);
466
+ }
467
+ }
468
+
469
+ if (slugMatches.length === 1) {
470
+ return hydrateIssue(slugMatches[0]);
471
+ }
472
+
473
+ if (slugMatches.length > 1) {
474
+ return null;
475
+ }
476
+ }
477
+
478
+ if (activeAssigned.length === 1) {
479
+ return hydrateIssue(activeAssigned[0]);
480
+ }
481
+
482
+ return null;
483
+ }
484
+
349
485
  function resolveWorkflowState(inputs) {
350
486
  try {
351
487
  if (inputs.workflowState) {
@@ -354,7 +490,9 @@ function resolveWorkflowState(inputs) {
354
490
 
355
491
  const { state } = loadState(inputs.projectRoot, {
356
492
  issueId: inputs.issueId,
493
+ issue: inputs.issue,
357
494
  comments: inputs.bdComments,
495
+ preferBeads: inputs.preferBeads,
358
496
  });
359
497
 
360
498
  return { workflowState: state, fallbackReason: null };
@@ -498,11 +636,51 @@ module.exports = {
498
636
  description: 'Intelligent stage detection with confidence scoring',
499
637
  handler: async (args, flags, projectRoot) => {
500
638
  const inputs = parseStatusInputs(args, flags);
639
+ const isZeroArgStatus = !inputs.issueId && !inputs.workflowState && !inputs.bdComments;
640
+ inputs.preferBeads = !isZeroArgStatus && Boolean(inputs.issueId || inputs.bdComments);
501
641
  if (!inputs.projectRoot && projectRoot) {
502
642
  inputs.projectRoot = projectRoot;
503
643
  }
644
+ const effectiveProjectRoot = inputs.projectRoot || process.cwd();
645
+ let context = null;
646
+ let snapshot = null;
647
+
648
+ if (isZeroArgStatus) {
649
+ context = detectRepoContext(effectiveProjectRoot);
650
+ snapshot = readBeadsSnapshot(effectiveProjectRoot);
651
+ const discoveredIssue = discoverCurrentIssue(snapshot, context, effectiveProjectRoot);
652
+ if (discoveredIssue) {
653
+ inputs.issueId = discoveredIssue.id;
654
+ inputs.issue = discoveredIssue;
655
+ }
656
+ }
657
+
504
658
  const { workflowState, fallbackReason } = resolveWorkflowState(inputs);
505
659
 
660
+ if (isZeroArgStatus) {
661
+ if (workflowState) {
662
+ const result = buildAuthoritativeStatus(workflowState);
663
+ return {
664
+ success: true,
665
+ context,
666
+ snapshot,
667
+ issueId: inputs.issueId,
668
+ output: formatZeroArgStatus({ context, snapshot, workflowResult: result }),
669
+ ...result,
670
+ };
671
+ }
672
+
673
+ return {
674
+ success: true,
675
+ context,
676
+ snapshot,
677
+ issueId: inputs.issueId,
678
+ missingWorkflowState: true,
679
+ output: formatZeroArgStatus({ context, snapshot }),
680
+ ...(fallbackReason ? { fallbackReason } : {}),
681
+ };
682
+ }
683
+
506
684
  if (workflowState) {
507
685
  const result = buildAuthoritativeStatus(workflowState);
508
686
  return { success: true, output: formatStatus(result), ...result };
@@ -521,6 +699,8 @@ module.exports = {
521
699
  parseStatusInputs,
522
700
  readWorkflowStateFromBeads,
523
701
  detectStage,
702
+ detectRepoContext,
703
+ discoverCurrentIssue,
524
704
  analyzeBranch,
525
705
  analyzeFiles,
526
706
  analyzePR,
@@ -528,4 +708,5 @@ module.exports = {
528
708
  analyzeBeads,
529
709
  calculateConfidence,
530
710
  formatStatus,
711
+ extractDesignSlugs,
531
712
  };
@@ -34,4 +34,14 @@ function handleTeam(args) {
34
34
  }
35
35
  }
36
36
 
37
- module.exports = { handleTeam };
37
+ async function handler(args = []) {
38
+ handleTeam(args);
39
+ return { success: true };
40
+ }
41
+
42
+ module.exports = {
43
+ name: 'team',
44
+ description: 'Team coordination workflows and sync utilities',
45
+ handler,
46
+ handleTeam
47
+ };
@@ -152,6 +152,18 @@ function getAffectedTestFiles(projectRoot, execFileSync, fs = defaultFs, options
152
152
  function getTestCandidatesForChangedFile(file) {
153
153
  if (!file) return [];
154
154
 
155
+ if (file === 'lib/lefthook-check.js') {
156
+ return ['test/lefthook-check.test.js', 'test/runtime-health.test.js'];
157
+ }
158
+
159
+ if (file === 'lib/runtime-health.js') {
160
+ return ['test/runtime-health.test.js'];
161
+ }
162
+
163
+ if (file === 'scripts/test.js') {
164
+ return ['test/scripts/test-runner.test.js'];
165
+ }
166
+
155
167
  if (file.startsWith('test/') && file.endsWith('.test.js')) {
156
168
  return [file];
157
169
  }
@@ -161,8 +173,8 @@ function getTestCandidatesForChangedFile(file) {
161
173
  return [`test/${relative.replace(/\.js$/, '.test.js')}`];
162
174
  }
163
175
 
164
- if (file.startsWith('scripts/') && file.endsWith('.js')) {
165
- const relative = file.slice('scripts/'.length).replace(/\.js$/, '.test.js');
176
+ if (file.startsWith('scripts/') && (file.endsWith('.js') || file.endsWith('.sh'))) {
177
+ const relative = file.slice('scripts/'.length).replace(/\.(js|sh)$/, '.test.js');
166
178
  return [
167
179
  `test/scripts/${relative}`,
168
180
  `test/${relative}`,
@@ -177,6 +189,13 @@ function getTestCandidatesForChangedFile(file) {
177
189
  ];
178
190
  }
179
191
 
192
+ if (file.startsWith('.github/agentic-workflows/') || file === '.github/behavioral-test-scores.json') {
193
+ return [
194
+ 'test/scripts/behavioral-judge.test.js',
195
+ 'test/structural/agentic-workflow-sync.test.js',
196
+ ];
197
+ }
198
+
180
199
  if (file.startsWith('.claude/commands/') && file.endsWith('.md')) {
181
200
  return [
182
201
  'test/command-sync-check.test.js',
@@ -184,6 +203,22 @@ function getTestCandidatesForChangedFile(file) {
184
203
  ];
185
204
  }
186
205
 
206
+ if (file.startsWith('.forge/')
207
+ || file.startsWith('.cursor/')
208
+ || file.startsWith('.cline/')
209
+ || file.startsWith('.roo/')
210
+ || file.startsWith('.kilocode/')
211
+ || file.startsWith('.opencode/')
212
+ || file.startsWith('.codex/skills/')
213
+ || file.startsWith('.github/prompts/')) {
214
+ return [
215
+ 'test/agent-gaps.test.js',
216
+ 'test/command-sync-check.test.js',
217
+ 'test/scripts/check-agents.test.js',
218
+ 'test/structural/command-sync.test.js',
219
+ ];
220
+ }
221
+
187
222
  return [];
188
223
  }
189
224
 
@@ -46,6 +46,12 @@ const CONFLICT_START_PATTERN = /^<<<<<<<(?: .*)?$/;
46
46
  const CONFLICT_DIVIDER_PATTERN = /^=======$/;
47
47
  const CONFLICT_END_PATTERN = /^>>>>>>>.*$/;
48
48
  const GIT_CONFLICT_SCAN_MAX_BUFFER = 16 * 1024 * 1024;
49
+ const VALIDATION_COMMAND_TIMEOUT_MS = 600000;
50
+ const VALIDATION_COMMAND_TIMEOUT_MINUTES = Math.round(VALIDATION_COMMAND_TIMEOUT_MS / 60000);
51
+
52
+ function validationTimeoutMessage(label) {
53
+ return `${label} timed out after ${VALIDATION_COMMAND_TIMEOUT_MINUTES} minutes`;
54
+ }
49
55
 
50
56
  function listConflictScanFiles(rootDir) {
51
57
  try {
@@ -78,7 +84,7 @@ function shouldSkipConflictScanPath(relativePath) {
78
84
  }
79
85
 
80
86
  function getExecOptions() {
81
- return { encoding: 'utf8', cwd: process.cwd(), timeout: 120000 };
87
+ return { encoding: 'utf8', cwd: process.cwd(), timeout: VALIDATION_COMMAND_TIMEOUT_MS };
82
88
  }
83
89
 
84
90
  const ERROR_PATTERNS = {
@@ -308,7 +314,7 @@ async function runTypeCheck() {
308
314
  return {
309
315
  success: false,
310
316
  duration: Date.now() - startTime,
311
- message: 'Type check timed out after 2 minutes',
317
+ message: validationTimeoutMessage('Type check'),
312
318
  };
313
319
  }
314
320
 
@@ -365,7 +371,7 @@ async function runLint() {
365
371
  return {
366
372
  success: false,
367
373
  duration: Date.now() - startTime,
368
- message: 'Lint check timed out after 2 minutes',
374
+ message: validationTimeoutMessage('Lint check'),
369
375
  };
370
376
  }
371
377
 
@@ -437,7 +443,7 @@ async function runSecurityScan() { // NOSONAR S3776
437
443
  return {
438
444
  success: false,
439
445
  duration: Date.now() - startTime,
440
- message: 'Security audit timed out after 2 minutes',
446
+ message: validationTimeoutMessage('Security audit'),
441
447
  };
442
448
  }
443
449
  // bun audit exits non-zero when vulnerabilities are found; stdout still contains the data
@@ -466,7 +472,7 @@ async function runSecurityScan() { // NOSONAR S3776
466
472
  return {
467
473
  success: false,
468
474
  duration: Date.now() - startTime,
469
- message: 'Security audit timed out after 2 minutes',
475
+ message: validationTimeoutMessage('Security audit'),
470
476
  };
471
477
  }
472
478
  // npm audit exits non-zero when it finds any vulnerability - stdout still has JSON
@@ -521,7 +527,7 @@ async function runSecurityScan() { // NOSONAR S3776
521
527
  return {
522
528
  success: false,
523
529
  duration: Date.now() - startTime,
524
- message: 'Security audit timed out after 2 minutes',
530
+ message: validationTimeoutMessage('Security audit'),
525
531
  };
526
532
  }
527
533
 
@@ -564,7 +570,7 @@ async function runAllTests() {
564
570
  const startTime = Date.now();
565
571
 
566
572
  try {
567
- const result = execFileSync('bun', ['test'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
573
+ const result = execFileSync('bun', ['test', '--timeout', '30000'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
568
574
 
569
575
  // Parse bun test output
570
576
  const passed = parseNumber(/(\d+) pass/.exec(result)); // NOSONAR S5852 - bounded \d+ pattern
@@ -591,7 +597,7 @@ async function runAllTests() {
591
597
  passed: 0,
592
598
  failed: 0,
593
599
  total: 0,
594
- message: 'Test execution timed out after 2 minutes',
600
+ message: validationTimeoutMessage('Test execution'),
595
601
  };
596
602
  }
597
603
 
@@ -3,6 +3,7 @@
3
3
  const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const { bootstrapBeads } = require('../beads-bootstrap');
6
7
 
7
8
  /**
8
9
  * Forge Worktree Command
@@ -10,8 +11,8 @@ const path = require('node:path');
10
11
  * Uses execFileSync (not execSync) to prevent command injection (OWASP A03).
11
12
  *
12
13
  * Subcommands:
13
- * create <slug> Create worktree at .worktrees/<slug> with branch + beads + install
14
- * remove <slug> Remove worktree via git worktree remove
14
+ * create <slug> - Create worktree at .worktrees/<slug> with branch + beads + install
15
+ * remove <slug> - Remove worktree via git worktree remove
15
16
  *
16
17
  * @module commands/worktree
17
18
  */
@@ -31,25 +32,10 @@ function detectPackageManager(projectRoot, fsApi) {
31
32
  return null;
32
33
  }
33
34
 
34
- /**
35
- * Copy .beads/ directory excluding daemon.lock.
36
- * @param {string} source - Source .beads path
37
- * @param {string} dest - Destination .beads path
38
- * @param {object} fsApi - fs module (for DI)
39
- */
40
- function copyBeadsDir(source, dest, fsApi) {
41
- fsApi.mkdirSync(dest, { recursive: true });
42
- const entries = fsApi.readdirSync(source);
43
- for (const entry of entries) {
44
- if (entry === 'daemon.lock') continue;
45
- fsApi.cpSync(path.join(source, entry), path.join(dest, entry), { recursive: true });
46
- }
47
- }
48
-
49
35
  /**
50
36
  * Stop a Dolt server running in a worktree to release file locks.
51
37
  * Tries graceful `bd dolt stop` first, then falls back to PID-based kill
52
- * from lock files. Never kills ALL Dolt processes only the specific PID.
38
+ * from lock files. Never kills ALL Dolt processes - only the specific PID.
53
39
  *
54
40
  * @param {string} worktreePath - Absolute path to the worktree
55
41
  * @param {object} [opts] - Options for dependency injection
@@ -106,46 +92,29 @@ function branchExists(branchName, runFile) {
106
92
  }
107
93
  }
108
94
 
109
- /**
110
- * Set up Beads integration for a new worktree (symlink/junction/copy).
111
- * @param {string} worktreePath - Absolute path to the new worktree
112
- * @param {string} projectRoot - Absolute path to the project root
113
- * @param {string} platform - OS platform string (e.g. 'win32')
114
- * @param {object} fsApi - fs module (for DI)
115
- * @param {Function} exec - execFileSync-compatible function
116
- * @returns {string|null} Warning message if beads setup had issues, null otherwise
117
- */
118
- function setupBeads(worktreePath, projectRoot, platform, fsApi, exec) {
119
- const beadsSource = path.resolve(projectRoot, '.beads');
120
- const beadsDest = path.resolve(worktreePath, '.beads');
121
-
122
- if (!fsApi.existsSync(beadsSource)) {
123
- return 'Beads not installed — skipping .beads setup';
95
+ function setupBeadsWithBootstrap(worktreePath, projectRoot, platform, fsApi, exec, options = {}) {
96
+ if (!fsApi.existsSync(path.resolve(projectRoot, '.beads'))) {
97
+ return 'Beads not installed - skipping .beads setup';
124
98
  }
125
99
 
126
- // Try symlink first
127
- try {
128
- if (platform === 'win32') {
129
- fsApi.symlinkSync(beadsSource, beadsDest, 'junction');
130
- } else {
131
- fsApi.symlinkSync(beadsSource, beadsDest);
132
- }
133
- } catch (symlinkErr) {
134
- if (symlinkErr.code === 'EPERM') {
135
- copyBeadsDir(beadsSource, beadsDest, fsApi);
136
- } else {
137
- throw symlinkErr;
138
- }
139
- }
100
+ const bootstrapResult = bootstrapBeads(worktreePath, {
101
+ _exec: exec,
102
+ _fs: fsApi,
103
+ _platform: platform,
104
+ mainProjectRoot: projectRoot,
105
+ _safeBeadsInit: options._safeBeadsInit,
106
+ });
140
107
 
141
- // Verify beads accessible (warn if fails, don't block)
142
108
  try {
143
109
  exec('bd', ['--version'], { cwd: worktreePath, timeout: 5000, stdio: 'pipe' });
144
110
  } catch (_bdErr) { /* intentional: bd may not be on PATH */ // NOSONAR S2486
145
- return 'Beads verification failed .beads linked but bd may not work';
111
+ if (bootstrapResult.success === false && bootstrapResult.warning) {
112
+ return bootstrapResult.warning;
113
+ }
114
+ return 'Beads verification failed - .beads linked but bd may not work';
146
115
  }
147
116
 
148
- return null;
117
+ return bootstrapResult.warning;
149
118
  }
150
119
 
151
120
  /**
@@ -179,13 +148,17 @@ async function handleCreate(slug, flags, projectRoot, opts) {
179
148
  const branchName = flags['--branch'] || flags.branch || `feat/${slug}`;
180
149
  const worktreesDir = path.resolve(projectRoot, '.worktrees');
181
150
 
182
- // Guard: Detect bare repo state worktrees created from bare repos produce broken branches
151
+ // Guard: Detect bare repo state - worktrees created from bare repos produce broken branches
183
152
  // git rev-parse --show-toplevel throws in a bare repo (no working tree)
184
153
  try {
185
154
  runFile('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
186
- } catch (_e) { // NOSONAR S2486
155
+ } catch (error) {
187
156
  /* intentional: show-toplevel fails in bare repos */
188
- return { success: false, error: 'Main repo is in bare state (no working tree). Clone fresh instead: git clone <url> /tmp/forge-<slug>' };
157
+ const errMsg = String(error?.stderr || error?.message || '').trim();
158
+ if (/must be run in a work tree|this operation must be run in a work tree/i.test(errMsg)) {
159
+ return { success: false, error: 'bare repo detected' };
160
+ }
161
+ return { success: false, error: errMsg || 'git rev-parse --show-toplevel failed' };
189
162
  }
190
163
 
191
164
  // Security: Validate slug doesn't contain path traversal (OWASP A01/A03)
@@ -217,7 +190,7 @@ async function handleCreate(slug, flags, projectRoot, opts) {
217
190
  }
218
191
 
219
192
  // Step 3: Beads integration
220
- const beadsWarning = setupBeads(worktreePath, projectRoot, platform, fsApi, runFile);
193
+ const beadsWarning = setupBeadsWithBootstrap(worktreePath, projectRoot, platform, fsApi, runFile, opts);
221
194
 
222
195
  // Step 4: Run package install
223
196
  runInstall(worktreePath, projectRoot, runSpawn, fsApi);