forge-workflow 0.0.8 → 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 (135) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/premerge.md +2 -2
  4. package/.claude/commands/review.md +5 -2
  5. package/.claude/commands/ship.md +6 -5
  6. package/.claude/commands/status.md +4 -4
  7. package/.claude/rules/greptile-review-process.md +4 -4
  8. package/.cline/workflows/dev.md +2 -2
  9. package/.cline/workflows/plan.md +2 -2
  10. package/.cline/workflows/premerge.md +2 -2
  11. package/.cline/workflows/review.md +5 -2
  12. package/.cline/workflows/ship.md +6 -5
  13. package/.cline/workflows/status.md +4 -4
  14. package/.codex/skills/dev/SKILL.md +2 -2
  15. package/.codex/skills/plan/SKILL.md +2 -2
  16. package/.codex/skills/premerge/SKILL.md +2 -2
  17. package/.codex/skills/review/SKILL.md +5 -2
  18. package/.codex/skills/ship/SKILL.md +6 -5
  19. package/.codex/skills/status/SKILL.md +4 -4
  20. package/.cursor/commands/dev.md +2 -2
  21. package/.cursor/commands/plan.md +2 -2
  22. package/.cursor/commands/premerge.md +2 -2
  23. package/.cursor/commands/review.md +5 -2
  24. package/.cursor/commands/ship.md +6 -5
  25. package/.cursor/commands/status.md +4 -4
  26. package/.github/prompts/dev.prompt.md +2 -2
  27. package/.github/prompts/plan.prompt.md +2 -2
  28. package/.github/prompts/premerge.prompt.md +2 -2
  29. package/.github/prompts/review.prompt.md +5 -2
  30. package/.github/prompts/ship.prompt.md +6 -5
  31. package/.github/prompts/status.prompt.md +4 -4
  32. package/.github/workflows/beads-to-github.yml +44 -11
  33. package/.github/workflows/github-to-beads.yml +11 -8
  34. package/.kilocode/workflows/dev.md +2 -2
  35. package/.kilocode/workflows/plan.md +2 -2
  36. package/.kilocode/workflows/premerge.md +2 -2
  37. package/.kilocode/workflows/review.md +5 -2
  38. package/.kilocode/workflows/ship.md +6 -5
  39. package/.kilocode/workflows/status.md +4 -4
  40. package/.opencode/commands/dev.md +2 -2
  41. package/.opencode/commands/plan.md +2 -2
  42. package/.opencode/commands/premerge.md +2 -2
  43. package/.opencode/commands/review.md +5 -2
  44. package/.opencode/commands/ship.md +6 -5
  45. package/.opencode/commands/status.md +4 -4
  46. package/.roo/commands/dev.md +2 -2
  47. package/.roo/commands/plan.md +2 -2
  48. package/.roo/commands/premerge.md +2 -2
  49. package/.roo/commands/review.md +5 -2
  50. package/.roo/commands/ship.md +6 -5
  51. package/.roo/commands/status.md +4 -4
  52. package/AGENTS.md +10 -9
  53. package/CLAUDE.md +12 -0
  54. package/README.md +12 -6
  55. package/bin/forge.js +24 -8
  56. package/docs/BEADS_GITHUB_SYNC.md +32 -2
  57. package/docs/EXAMPLES.md +22 -22
  58. package/docs/ROADMAP.md +3 -3
  59. package/docs/TOOLCHAIN.md +150 -160
  60. package/lib/agents/codex.plugin.json +3 -0
  61. package/lib/agents-config.js +18 -12
  62. package/lib/beads-bootstrap.js +225 -0
  63. package/lib/beads-health-check.js +55 -10
  64. package/lib/beads-setup.js +104 -28
  65. package/lib/beads-sync-scaffold.js +11 -6
  66. package/lib/codex-skills.js +54 -1
  67. package/lib/commands/_issue.js +11 -1
  68. package/lib/commands/issues.js +49 -0
  69. package/lib/commands/plan.js +5 -2
  70. package/lib/commands/recommend.js +22 -1
  71. package/lib/commands/setup.js +247 -27
  72. package/lib/commands/ship.js +188 -5
  73. package/lib/commands/status.js +187 -19
  74. package/lib/commands/team.js +11 -1
  75. package/lib/commands/test.js +125 -25
  76. package/lib/commands/validate.js +232 -9
  77. package/lib/commands/worktree.js +27 -54
  78. package/lib/dep-guard/keyword-ripple.js +184 -0
  79. package/lib/detect-worktree.js +9 -10
  80. package/lib/forge-issues.js +326 -0
  81. package/lib/issue-sync/authority.js +100 -0
  82. package/lib/issue-sync/github-pull.js +184 -0
  83. package/lib/issue-sync/import-primitives.js +98 -0
  84. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  85. package/lib/issue-sync/link-store.js +292 -0
  86. package/lib/issue-sync/project-github.js +123 -0
  87. package/lib/issue-sync/reconcile.js +195 -0
  88. package/lib/issue-sync/schema.js +126 -0
  89. package/lib/lefthook-check.js +5 -2
  90. package/lib/project-memory.js +564 -0
  91. package/lib/runtime-health.js +100 -12
  92. package/lib/setup-action-log.js +2 -0
  93. package/lib/setup-summary-renderer.js +15 -11
  94. package/lib/smart-status/conflicts.js +205 -0
  95. package/lib/smart-status/scoring.js +177 -0
  96. package/lib/status/beads-snapshot.js +102 -0
  97. package/lib/status/presenter.js +65 -0
  98. package/lib/workflow/enforce-stage.js +14 -8
  99. package/lib/workflow/state-manager.js +349 -0
  100. package/package.json +12 -4
  101. package/scripts/beads-context.sh +124 -5
  102. package/scripts/beads-context.test.js +21 -4
  103. package/scripts/beads-migrate-to-dolt.sh +7 -0
  104. package/scripts/beads-upgrade-smoke.sh +263 -0
  105. package/scripts/behavioral-judge.sh +115 -11
  106. package/scripts/benchmark.js +349 -63
  107. package/scripts/dep-guard-analyze.js +52 -17
  108. package/scripts/dep-guard-keyword-ripple.js +29 -0
  109. package/scripts/dep-guard-render-review.js +86 -0
  110. package/scripts/dep-guard.sh +45 -222
  111. package/scripts/forge-team/lib/hooks.sh +1 -1
  112. package/scripts/forge-team/lib/sync-github.sh +160 -28
  113. package/scripts/forge-team/lib/verify.sh +1 -1
  114. package/scripts/forge-team/lib/workload.sh +56 -27
  115. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  116. package/scripts/forge-team/tests/workload.test.sh +35 -4
  117. package/scripts/github-beads-sync/index.mjs +122 -98
  118. package/scripts/github-beads-sync/mapping.mjs +54 -0
  119. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  120. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  121. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  122. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  123. package/scripts/preflight.sh +181 -0
  124. package/scripts/smart-status-score.js +31 -0
  125. package/scripts/smart-status-sessions.js +51 -0
  126. package/scripts/smart-status.sh +84 -330
  127. package/scripts/sync-agentic-workflow.js +48 -0
  128. package/scripts/sync-utils.sh +39 -0
  129. package/scripts/test-ci-shard.js +244 -0
  130. package/scripts/test-dashboard.js +188 -52
  131. package/scripts/test-full-suite.js +186 -0
  132. package/scripts/test-profile.js +278 -0
  133. package/scripts/test.js +335 -38
  134. package/scripts/validate.js +143 -0
  135. package/scripts/validate.sh +18 -1
@@ -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
+ }
91
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
+ }
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,111 @@ 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 === '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
+
167
+ if (file.startsWith('test/') && file.endsWith('.test.js')) {
168
+ return [file];
169
+ }
170
+
171
+ if (file.startsWith('lib/') && file.endsWith('.js')) {
172
+ const relative = file.slice('lib/'.length);
173
+ return [`test/${relative.replace(/\.js$/, '.test.js')}`];
174
+ }
175
+
176
+ if (file.startsWith('scripts/') && (file.endsWith('.js') || file.endsWith('.sh'))) {
177
+ const relative = file.slice('scripts/'.length).replace(/\.(js|sh)$/, '.test.js');
178
+ return [
179
+ `test/scripts/${relative}`,
180
+ `test/${relative}`,
181
+ ];
182
+ }
183
+
184
+ if (file.startsWith('.github/workflows/')) {
185
+ const workflowName = path.basename(file, path.extname(file));
186
+ return [
187
+ `test/workflows/${workflowName}.test.js`,
188
+ 'test/ci-workflow.test.js',
189
+ ];
190
+ }
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
+
199
+ if (file.startsWith('.claude/commands/') && file.endsWith('.md')) {
200
+ return [
201
+ 'test/command-sync-check.test.js',
202
+ 'test/structural/command-sync.test.js',
203
+ ];
204
+ }
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
+
222
+ return [];
128
223
  }
129
224
 
130
225
  module.exports = {
226
+ getChangedFiles,
227
+ getAffectedTestFiles,
228
+ getTestCandidatesForChangedFile,
131
229
  name: 'test',
132
230
  description: 'Run tests with smart defaults (timeout, Beads skip, affected-only)',
133
231
  usage: 'forge test [--affected]',
@@ -181,7 +279,9 @@ module.exports = {
181
279
 
182
280
  // 5. --affected flag: find changed test files
183
281
  if (flags['--affected'] || flags.affected) {
184
- const affectedTests = getAffectedTestFiles(projectRoot, execFileSync);
282
+ const affectedTests = getAffectedTestFiles(projectRoot, execFileSync, fs, {
283
+ sinceUpstream: flags.sinceUpstream || flags['--since-upstream'],
284
+ });
185
285
  if (affectedTests.length > 0) {
186
286
  testArgs = ['run', 'test', ...affectedTests];
187
287
  }
@@ -14,19 +14,91 @@ 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
+ 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
+ }
55
+
56
+ function listConflictScanFiles(rootDir) {
57
+ try {
58
+ const output = execFileSync(
59
+ 'git',
60
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard'],
61
+ { encoding: 'utf8', cwd: rootDir, timeout: 120000, maxBuffer: GIT_CONFLICT_SCAN_MAX_BUFFER },
62
+ );
63
+ return output
64
+ .split('\0')
65
+ .filter(file => file.length > 0)
66
+ .map(file => path.join(rootDir, file));
67
+ } catch (error) {
68
+ if (!isGitListingUnavailableError(error)) {
69
+ throw error;
70
+ }
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function shouldSkipConflictScanPath(relativePath) {
76
+ const segments = String(relativePath || '')
77
+ .split(/[\\/]+/)
78
+ .filter(Boolean);
79
+ const directorySegments = segments.slice(0, -1);
80
+ return directorySegments.some(segment => (
81
+ (segment.startsWith('.') && !CONFLICT_MARKER_ALLOWED_DOT_DIRS.has(segment))
82
+ || CONFLICT_MARKER_IGNORE_DIRS.has(segment)
83
+ ));
84
+ }
85
+
23
86
  function getExecOptions() {
24
- return { encoding: 'utf8', cwd: process.cwd(), timeout: 120000 };
87
+ return { encoding: 'utf8', cwd: process.cwd(), timeout: VALIDATION_COMMAND_TIMEOUT_MS };
25
88
  }
26
89
 
27
90
  const ERROR_PATTERNS = {
28
91
  COMMAND_NOT_FOUND: ['ENOENT', 'not found'],
29
92
  NO_LOCK_FILE: ['requires an existing', 'package-lock'],
93
+ GIT_LISTING_UNAVAILABLE: [
94
+ 'not a git repository',
95
+ 'maxBuffer', // git ls-files output exceeds maxBuffer
96
+ 'ENOBUFS', // OS-level buffer overflow
97
+ 'ETIMEDOUT', // subprocess timeout
98
+ 'EPERM', // permission denied
99
+ 'EACCES', // access denied
100
+ 'spawnSync', // general spawn failure
101
+ ],
30
102
  };
31
103
 
32
104
  /**
@@ -39,6 +111,14 @@ function isCommandNotFound(error) {
39
111
  );
40
112
  }
41
113
 
114
+ function isGitListingUnavailableError(error) {
115
+ if (!error || typeof error.message !== 'string') {
116
+ return false;
117
+ }
118
+ return isCommandNotFound(error)
119
+ || ERROR_PATTERNS.GIT_LISTING_UNAVAILABLE.some(pattern => error.message.includes(pattern));
120
+ }
121
+
42
122
  /**
43
123
  * Parse number from regex match
44
124
  * @private
@@ -57,6 +137,131 @@ function getCheckStatus(check) {
57
137
  return check.success ? 'PASS' : 'FAIL';
58
138
  }
59
139
 
140
+ function scanForConflictMarkers(rootDir = process.cwd()) {
141
+ const filesWithMarkers = [];
142
+
143
+ function inspectFile(absolutePath) {
144
+ const relativePath = path.relative(rootDir, absolutePath) || path.basename(absolutePath);
145
+ if (shouldSkipConflictScanPath(relativePath)) {
146
+ return;
147
+ }
148
+
149
+ try {
150
+ if (!fs.lstatSync(absolutePath).isFile()) {
151
+ return;
152
+ }
153
+ } catch (error) {
154
+ if (!canSkipConflictScanStatError(error)) {
155
+ throw error;
156
+ }
157
+ return;
158
+ }
159
+
160
+ const startLine = findConflictMarkerStartLine(readConflictScanFile(absolutePath));
161
+ if (startLine === null) {
162
+ return;
163
+ }
164
+
165
+ filesWithMarkers.push({ path: relativePath, line: startLine });
166
+ }
167
+
168
+ function walk(currentDir) {
169
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
170
+ if (shouldSkipConflictScanEntry(entry)) {
171
+ continue;
172
+ }
173
+
174
+ if (entry.isDirectory()) {
175
+ walk(path.join(currentDir, entry.name));
176
+ continue;
177
+ }
178
+
179
+ if (!entry.isFile()) {
180
+ continue;
181
+ }
182
+
183
+ inspectFile(path.join(currentDir, entry.name));
184
+ }
185
+ }
186
+
187
+ const trackedFiles = listConflictScanFiles(rootDir);
188
+ if (trackedFiles) {
189
+ for (const absolutePath of trackedFiles) {
190
+ inspectFile(absolutePath);
191
+ }
192
+ } else {
193
+ walk(rootDir);
194
+ }
195
+ filesWithMarkers.sort((left, right) => {
196
+ if (left.path === right.path) {
197
+ return left.line - right.line;
198
+ }
199
+ return left.path.localeCompare(right.path);
200
+ });
201
+
202
+ return {
203
+ success: filesWithMarkers.length === 0,
204
+ files: filesWithMarkers,
205
+ message: filesWithMarkers.length === 0
206
+ ? 'No conflict markers found'
207
+ : `Conflict markers found in ${filesWithMarkers.length} file(s)`,
208
+ };
209
+ }
210
+
211
+ function shouldSkipConflictScanEntry(entry) {
212
+ return entry.isDirectory() && (
213
+ (entry.name.startsWith('.') && !CONFLICT_MARKER_ALLOWED_DOT_DIRS.has(entry.name))
214
+ || CONFLICT_MARKER_IGNORE_DIRS.has(entry.name)
215
+ );
216
+ }
217
+
218
+ function canSkipConflictScanStatError(error) {
219
+ return Boolean(error && ['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code));
220
+ }
221
+
222
+ function readConflictScanFile(absolutePath) {
223
+ try {
224
+ return fs.readFileSync(absolutePath, 'utf8');
225
+ } catch (_error) {
226
+ return null;
227
+ }
228
+ }
229
+
230
+ function findConflictMarkerStartLine(content) {
231
+ const lines = String(content || '').split(/\r?\n/);
232
+ let activeStartLine = null;
233
+ let sawDivider = false;
234
+
235
+ for (let index = 0; index < lines.length; index += 1) {
236
+ const line = lines[index];
237
+ const lineNumber = index + 1;
238
+ if (activeStartLine === null) {
239
+ if (CONFLICT_START_PATTERN.test(line)) {
240
+ activeStartLine = lineNumber;
241
+ sawDivider = false;
242
+ } else if (CONFLICT_END_PATTERN.test(line)) {
243
+ return lineNumber;
244
+ }
245
+ continue;
246
+ }
247
+
248
+ if (!sawDivider) {
249
+ if (CONFLICT_DIVIDER_PATTERN.test(line)) {
250
+ sawDivider = true;
251
+ } else if (CONFLICT_START_PATTERN.test(line)) {
252
+ activeStartLine = index + 1;
253
+ }
254
+ continue;
255
+ }
256
+
257
+ if (CONFLICT_END_PATTERN.test(line)) {
258
+ return activeStartLine;
259
+ }
260
+ }
261
+
262
+ return activeStartLine;
263
+ }
264
+
60
265
  /**
61
266
  * Parse vulnerability counts from audit output
62
267
  * @private
@@ -109,7 +314,7 @@ async function runTypeCheck() {
109
314
  return {
110
315
  success: false,
111
316
  duration: Date.now() - startTime,
112
- message: 'Type check timed out after 2 minutes',
317
+ message: validationTimeoutMessage('Type check'),
113
318
  };
114
319
  }
115
320
 
@@ -166,7 +371,7 @@ async function runLint() {
166
371
  return {
167
372
  success: false,
168
373
  duration: Date.now() - startTime,
169
- message: 'Lint check timed out after 2 minutes',
374
+ message: validationTimeoutMessage('Lint check'),
170
375
  };
171
376
  }
172
377
 
@@ -238,7 +443,7 @@ async function runSecurityScan() { // NOSONAR S3776
238
443
  return {
239
444
  success: false,
240
445
  duration: Date.now() - startTime,
241
- message: 'Security audit timed out after 2 minutes',
446
+ message: validationTimeoutMessage('Security audit'),
242
447
  };
243
448
  }
244
449
  // bun audit exits non-zero when vulnerabilities are found; stdout still contains the data
@@ -267,7 +472,7 @@ async function runSecurityScan() { // NOSONAR S3776
267
472
  return {
268
473
  success: false,
269
474
  duration: Date.now() - startTime,
270
- message: 'Security audit timed out after 2 minutes',
475
+ message: validationTimeoutMessage('Security audit'),
271
476
  };
272
477
  }
273
478
  // npm audit exits non-zero when it finds any vulnerability - stdout still has JSON
@@ -322,7 +527,7 @@ async function runSecurityScan() { // NOSONAR S3776
322
527
  return {
323
528
  success: false,
324
529
  duration: Date.now() - startTime,
325
- message: 'Security audit timed out after 2 minutes',
530
+ message: validationTimeoutMessage('Security audit'),
326
531
  };
327
532
  }
328
533
 
@@ -365,7 +570,7 @@ async function runAllTests() {
365
570
  const startTime = Date.now();
366
571
 
367
572
  try {
368
- 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
369
574
 
370
575
  // Parse bun test output
371
576
  const passed = parseNumber(/(\d+) pass/.exec(result)); // NOSONAR S5852 - bounded \d+ pattern
@@ -392,7 +597,7 @@ async function runAllTests() {
392
597
  passed: 0,
393
598
  failed: 0,
394
599
  total: 0,
395
- message: 'Test execution timed out after 2 minutes',
600
+ message: validationTimeoutMessage('Test execution'),
396
601
  };
397
602
  }
398
603
 
@@ -450,13 +655,29 @@ async function runAllTests() {
450
655
  * console.log(result.summary);
451
656
  */
452
657
  async function executeValidate(options = {}) { // NOSONAR S3776
453
- const { skip = [], continueOnError = true } = options || {};
658
+ const { skip = [], continueOnError = true, rootDir = process.cwd() } = options || {};
454
659
 
455
660
  const checks = {};
456
661
  const failedChecks = [];
457
662
  const errors = [];
458
663
  const startTime = Date.now();
459
664
 
665
+ // 0. Conflict markers
666
+ if (!skip.includes(CHECK_TYPES.CONFLICT_MARKERS)) {
667
+ try {
668
+ checks.conflictMarkers = scanForConflictMarkers(rootDir);
669
+ if (!checks.conflictMarkers.success) {
670
+ failedChecks.push(CHECK_TYPES.CONFLICT_MARKERS);
671
+ if (!continueOnError) {
672
+ return buildResult(checks, failedChecks, errors, startTime);
673
+ }
674
+ }
675
+ } catch (error) {
676
+ errors.push(`Conflict marker scan error: ${error.message}`);
677
+ checks.conflictMarkers = { success: false, message: error.message };
678
+ }
679
+ }
680
+
460
681
  // 1. Type checking
461
682
  if (!skip.includes(CHECK_TYPES.TYPE_CHECK)) {
462
683
  try {
@@ -535,6 +756,7 @@ function buildResult(checks, failedChecks, errors, startTime) {
535
756
  // Build summary using getCheckStatus helper
536
757
  const checkResults = [];
537
758
  const checkLabels = {
759
+ conflictMarkers: 'Conflict Markers',
538
760
  typeCheck: 'Type',
539
761
  lint: 'Lint',
540
762
  security: 'Security',
@@ -610,6 +832,7 @@ module.exports = {
610
832
  runLint,
611
833
  runSecurityScan,
612
834
  runAllTests,
835
+ scanForConflictMarkers,
613
836
  executeValidate,
614
837
  executeDebugMode,
615
838
  };
@@ -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);