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
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ const STOP_WORDS = new Set([
4
+ 'the',
5
+ 'a',
6
+ 'an',
7
+ 'and',
8
+ 'or',
9
+ 'is',
10
+ 'in',
11
+ 'to',
12
+ 'for',
13
+ 'of',
14
+ 'with',
15
+ 'on',
16
+ 'at',
17
+ 'by',
18
+ 'from',
19
+ 'add',
20
+ 'fix',
21
+ 'update',
22
+ 'implement',
23
+ 'create',
24
+ 'remove',
25
+ 'delete',
26
+ 'make',
27
+ 'use',
28
+ 'get',
29
+ 'set',
30
+ 'run',
31
+ 'test',
32
+ 'check',
33
+ 'all',
34
+ 'this',
35
+ 'that',
36
+ 'it',
37
+ 'be',
38
+ 'as',
39
+ 'not',
40
+ 'no',
41
+ 'but',
42
+ 'if',
43
+ 'do',
44
+ 'we',
45
+ 'they',
46
+ 'are',
47
+ 'was',
48
+ 'were',
49
+ 'been',
50
+ 'have',
51
+ 'has',
52
+ 'had',
53
+ 'will',
54
+ 'would',
55
+ 'could',
56
+ 'should',
57
+ 'may',
58
+ 'can',
59
+ 'each',
60
+ 'every',
61
+ 'both',
62
+ 'also',
63
+ 'into',
64
+ 'than',
65
+ 'then',
66
+ 'when',
67
+ 'where',
68
+ 'which',
69
+ 'what',
70
+ 'how',
71
+ 'why',
72
+ 'who',
73
+ 'its',
74
+ 'new',
75
+ 'first',
76
+ 'last',
77
+ 'same',
78
+ 'other',
79
+ ]);
80
+
81
+ function tokenizeMeaningfulTerms(title) {
82
+ return [...new Set(
83
+ String(title ?? '')
84
+ .toLowerCase()
85
+ .split(/[^a-z]+/u)
86
+ .filter((term) => term.length >= 3)
87
+ .filter((term) => !STOP_WORDS.has(term)),
88
+ )].sort((left, right) => left.localeCompare(right));
89
+ }
90
+
91
+ function collectSharedTerms(sourceTerms, candidateTerms) {
92
+ const candidateSet = new Set(candidateTerms);
93
+ return sourceTerms.filter((term) => candidateSet.has(term));
94
+ }
95
+
96
+ function isInProgressLine(line) {
97
+ return line.includes('◐') || line.includes('◐');
98
+ }
99
+
100
+ function parseActiveIssueLines(listOutput) {
101
+ return String(listOutput ?? '')
102
+ .split(/\r?\n/u)
103
+ .map((line) => line.trim())
104
+ .filter(Boolean)
105
+ .map((line) => {
106
+ const idMatch = /forge-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*/u.exec(line);
107
+ if (!idMatch) {
108
+ return null;
109
+ }
110
+
111
+ const titleIndex = line.indexOf(' - ');
112
+ const priorityMatch = /P\d+/u.exec(line);
113
+ return {
114
+ id: idMatch[0],
115
+ priority: priorityMatch ? priorityMatch[0] : 'P2',
116
+ status: isInProgressLine(line) ? 'in_progress' : 'open',
117
+ title: titleIndex >= 0 ? line.slice(titleIndex + 3).trim() : '',
118
+ };
119
+ })
120
+ .filter(Boolean);
121
+ }
122
+
123
+ function renderKeywordRippleReport({ issueId, sourceTitle, activeIssues }) {
124
+ const sourceTerms = tokenizeMeaningfulTerms(sourceTitle);
125
+ const overlaps = [];
126
+
127
+ for (const candidate of activeIssues ?? []) {
128
+ if (!candidate || candidate.id === issueId) {
129
+ continue;
130
+ }
131
+
132
+ const sharedTerms = collectSharedTerms(sourceTerms, tokenizeMeaningfulTerms(candidate.title));
133
+ if (sharedTerms.length < 2) {
134
+ continue;
135
+ }
136
+
137
+ overlaps.push({
138
+ id: candidate.id,
139
+ priority: candidate.priority || 'P2',
140
+ status: candidate.status || 'open',
141
+ title: candidate.title || '',
142
+ sharedTerms,
143
+ });
144
+ }
145
+
146
+ if (overlaps.length === 0) {
147
+ return {
148
+ overlapCount: 0,
149
+ overlaps,
150
+ output: 'No conflicts detected',
151
+ };
152
+ }
153
+
154
+ const lines = [`Potential overlap with ${overlaps.length} issue(s):`, ''];
155
+ for (const overlap of overlaps) {
156
+ const renderedSharedTerms = overlap.sharedTerms.map((term) => `"${term}"`).join(', ');
157
+ lines.push(
158
+ ` ${overlap.id} (${overlap.status}, ${overlap.priority}): "${overlap.title}"`,
159
+ ` Overlap: keyword match - ${renderedSharedTerms}`,
160
+ ' Confidence: LOW (keyword only, no contract data)',
161
+ '',
162
+ ' Options:',
163
+ ` (a) Add dependency: bd dep add ${issueId} ${overlap.id}`,
164
+ ' (b) Proceed - no real conflict',
165
+ ` (c) Investigate: bd show ${overlap.id}`,
166
+ '',
167
+ );
168
+ }
169
+
170
+ return {
171
+ overlapCount: overlaps.length,
172
+ overlaps,
173
+ output: lines.join('\n').trimEnd(),
174
+ };
175
+ }
176
+
177
+ module.exports = {
178
+ STOP_WORDS,
179
+ collectSharedTerms,
180
+ isInProgressLine,
181
+ parseActiveIssueLines,
182
+ renderKeywordRippleReport,
183
+ tokenizeMeaningfulTerms,
184
+ };
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  * Uses git rev-parse --git-dir vs --git-common-dir — they differ in worktrees.
9
9
  *
10
10
  * @param {string} [cwd=process.cwd()] - Directory to check
11
- * @returns {{ inWorktree: boolean, branch?: string, mainWorktree?: string }}
11
+ * @returns {{ inWorktree: boolean, branch?: string, mainWorktree?: string, currentWorktree?: string }}
12
12
  */
13
13
  function detectWorktree(cwd = process.cwd()) {
14
14
  try {
@@ -24,20 +24,19 @@ function detectWorktree(cwd = process.cwd()) {
24
24
  const absGitDir = path.resolve(cwd, gitDir);
25
25
  const absCommonDir = path.resolve(cwd, gitCommonDir);
26
26
 
27
+ const branch = execFileSync('git', ['branch', '--show-current'], {
28
+ encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe']
29
+ }).trim();
30
+ const mainWorktree = path.resolve(absCommonDir, '..');
31
+ const currentWorktree = path.resolve(cwd);
32
+
27
33
  // In a worktree, git-dir is like .git/worktrees/<name>
28
34
  // while git-common-dir is the main .git directory
29
35
  if (absGitDir !== absCommonDir) {
30
- const branch = execFileSync('git', ['branch', '--show-current'], {
31
- encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe']
32
- }).trim();
33
-
34
- // Main worktree is one level above the common .git dir
35
- const mainWorktree = path.resolve(absCommonDir, '..');
36
-
37
- return { inWorktree: true, branch, mainWorktree };
36
+ return { inWorktree: true, branch, mainWorktree, currentWorktree };
38
37
  }
39
38
 
40
- return { inWorktree: false };
39
+ return { inWorktree: false, branch, mainWorktree, currentWorktree };
41
40
  } catch (_err) {
42
41
  // Not in a git repo or git not available
43
42
  return { inWorktree: false };
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const { existsSync } = require('node:fs');
5
+ const path = require('node:path');
6
+ const { isBeadsInitialized } = require('./beads-setup');
7
+ const { createGitHubProjectionPlan } = require('./issue-sync/project-github');
8
+
9
+ const OPERATION_TO_BD = {
10
+ create: 'create',
11
+ list: 'list',
12
+ show: 'show',
13
+ close: 'close',
14
+ update: 'update',
15
+ };
16
+
17
+ function getSpawnOptions(projectRoot) {
18
+ return {
19
+ cwd: projectRoot,
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ };
22
+ }
23
+
24
+ function getPathEntries(env = process.env, delimiter = path.delimiter) {
25
+ const rawPath = env.PATH || env.Path || '';
26
+ return rawPath
27
+ .split(delimiter)
28
+ .map(entry => entry.trim().replace(/^"(.*)"$/, '$1'))
29
+ .filter(Boolean);
30
+ }
31
+
32
+ function resolveWindowsCommandCandidates(commandNames, deps = {}) {
33
+ const env = deps.env || process.env;
34
+ const fileExists = deps.existsSync || existsSync;
35
+ const resolved = [];
36
+ const seen = new Set();
37
+ const pathEntries = getPathEntries(env, ';');
38
+
39
+ for (const dir of pathEntries) {
40
+ for (const commandName of commandNames) {
41
+ const fullPath = isWindowsPathEntry(dir)
42
+ ? path.win32.join(dir, commandName)
43
+ : path.posix.join(dir, commandName);
44
+ const dedupeKey = fullPath.toLowerCase();
45
+ if (!seen.has(dedupeKey) && fileExists(fullPath)) {
46
+ seen.add(dedupeKey);
47
+ resolved.push(fullPath);
48
+ }
49
+ }
50
+ }
51
+
52
+ return resolved;
53
+ }
54
+
55
+ function isWindowsPathEntry(entry = '') {
56
+ return /^(?:[a-zA-Z]:[\\/]|\\\\)/.test(entry) || entry.includes('\\');
57
+ }
58
+
59
+ function getBdCommandCandidates(deps = {}) {
60
+ const platform = deps.platform || process.platform;
61
+ if (platform === 'win32') {
62
+ const resolved = resolveWindowsCommandCandidates(['bd.exe', 'bd.cmd'], deps);
63
+ return [...new Set([...resolved, 'bd.exe', 'bd'])];
64
+ }
65
+
66
+ return ['bd'];
67
+ }
68
+
69
+ function normalizeExecOutput(output) {
70
+ if (typeof output === 'string') {
71
+ return output;
72
+ }
73
+
74
+ if (Buffer.isBuffer(output)) {
75
+ return output.toString('utf8');
76
+ }
77
+
78
+ return '';
79
+ }
80
+
81
+ function hasBdSoftFailure(output) {
82
+ return /(^|\n)Error(?: resolving| updating| fetching| adding)?\b/i.test(output);
83
+ }
84
+
85
+ function hasEmptyShowPayload(operation, output) {
86
+ if (operation !== 'show') {
87
+ return false;
88
+ }
89
+
90
+ const trimmedOutput = output.trim();
91
+ return trimmedOutput === '' || trimmedOutput === '[]' || trimmedOutput === 'null';
92
+ }
93
+
94
+ function isHelpInvocation(args = []) {
95
+ return args.includes('--help') || args.includes('-h');
96
+ }
97
+
98
+ function extractErrorMessage(error) {
99
+ if (error?.code === 'ENOENT') {
100
+ return 'Beads (bd) command not found. Install or initialize Beads before using forge issues.';
101
+ }
102
+
103
+ return error?.message?.trim() || 'Beads command failed';
104
+ }
105
+
106
+ function getCommandErrorMessage(result) {
107
+ const output = [result?.stdout, result?.stderr].filter(Boolean).join('\n').trim();
108
+ if (output) {
109
+ return output;
110
+ }
111
+
112
+ if (typeof result?.code === 'number') {
113
+ return `Beads command failed with exit code ${result.code}`;
114
+ }
115
+
116
+ return 'Beads command failed';
117
+ }
118
+
119
+ function buildBdArgs(operation, args) {
120
+ const bdCommand = OPERATION_TO_BD[operation];
121
+ if (!bdCommand) {
122
+ return null;
123
+ }
124
+
125
+ return [bdCommand, ...args];
126
+ }
127
+
128
+ function shouldCaptureOutput(operation, args, deps = {}) {
129
+ if (deps.captureOutput === true) {
130
+ return true;
131
+ }
132
+
133
+ if (isHelpInvocation(args)) {
134
+ return true;
135
+ }
136
+
137
+ return operation === 'update' || operation === 'show';
138
+ }
139
+
140
+ async function runBdCommand(operation, args, projectRoot, deps = {}) {
141
+ const spawnBd = deps.spawn || spawn;
142
+ const stdoutTarget = deps.stdout || process.stdout;
143
+ const stderrTarget = deps.stderr || process.stderr;
144
+ const captureOutput = shouldCaptureOutput(operation, args.slice(1), deps);
145
+ const commandCandidates = deps.commandCandidates || getBdCommandCandidates(deps);
146
+ let lastError;
147
+
148
+ for (const command of commandCandidates) {
149
+ try {
150
+ return await new Promise((resolve, reject) => {
151
+ const child = spawnBd(command, args, getSpawnOptions(projectRoot));
152
+ let stdout = '';
153
+ let stderr = '';
154
+
155
+ child.stdout?.setEncoding?.('utf8');
156
+ child.stderr?.setEncoding?.('utf8');
157
+ child.stdout?.on?.('data', chunk => {
158
+ const normalizedChunk = normalizeExecOutput(chunk);
159
+ if (captureOutput) {
160
+ stdout += normalizedChunk;
161
+ } else {
162
+ stdoutTarget?.write?.(normalizedChunk);
163
+ }
164
+ });
165
+ child.stderr?.on?.('data', chunk => {
166
+ const normalizedChunk = normalizeExecOutput(chunk);
167
+ if (captureOutput) {
168
+ stderr += normalizedChunk;
169
+ }
170
+ stderrTarget?.write?.(normalizedChunk);
171
+ });
172
+
173
+ child.on('error', reject);
174
+ child.on('close', code => {
175
+ resolve({
176
+ code,
177
+ stdout,
178
+ stderr,
179
+ });
180
+ });
181
+ });
182
+ } catch (error) {
183
+ lastError = error;
184
+ // ENOENT/EINVAL means this candidate is not directly spawnable — try next.
185
+ if (error?.code !== 'ENOENT' && error?.code !== 'EINVAL') {
186
+ throw error;
187
+ }
188
+ }
189
+ }
190
+
191
+ throw lastError;
192
+ }
193
+
194
+ async function runBeadsOperation(operation, args, context, deps) {
195
+ const checkInit = deps.isBeadsInitialized || isBeadsInitialized;
196
+ if (!isHelpInvocation(args) && !checkInit(context.projectRoot)) {
197
+ return {
198
+ success: false,
199
+ error: 'Beads is not initialized in this project. Run forge setup before using forge issues.',
200
+ };
201
+ }
202
+ const runCommand = deps.runBdCommand || ((bdArgs, projectRoot) => runBdCommand(operation, bdArgs, projectRoot, deps));
203
+
204
+ try {
205
+ const result = await runCommand(buildBdArgs(operation, args), context.projectRoot);
206
+ const stdout = normalizeExecOutput(result?.stdout);
207
+ const stderr = normalizeExecOutput(result?.stderr);
208
+ const combinedOutput = [stdout, stderr].filter(Boolean).join('\n');
209
+
210
+ if (result?.code !== 0) {
211
+ return {
212
+ success: false,
213
+ error: getCommandErrorMessage(result),
214
+ };
215
+ }
216
+
217
+ if (hasBdSoftFailure(combinedOutput)) {
218
+ return {
219
+ success: false,
220
+ error: combinedOutput.trim() || 'Beads command failed',
221
+ };
222
+ }
223
+
224
+ if (hasEmptyShowPayload(operation, combinedOutput)) {
225
+ return {
226
+ success: false,
227
+ error: `Issue not found: ${args[0] || 'unknown issue'}`,
228
+ };
229
+ }
230
+
231
+ return {
232
+ success: true,
233
+ operation,
234
+ output: stdout,
235
+ stderr,
236
+ };
237
+ } catch (error) {
238
+ return {
239
+ success: false,
240
+ error: extractErrorMessage(error),
241
+ };
242
+ }
243
+ }
244
+
245
+ function createBeadsIssueBackend(deps = {}) {
246
+ return {
247
+ create: async (args, context) => runBeadsOperation('create', args, context, deps),
248
+ list: async (args, context) => runBeadsOperation('list', args, context, deps),
249
+ show: async (args, context) => runBeadsOperation('show', args, context, deps),
250
+ close: async (args, context) => runBeadsOperation('close', args, context, deps),
251
+ update: async (args, context) => runBeadsOperation('update', args, context, deps),
252
+ };
253
+ }
254
+
255
+ function createIssueService({ backend } = {}) {
256
+ const resolvedBackend = backend || createBeadsIssueBackend();
257
+
258
+ return {
259
+ async run(operation, args = [], context = {}) {
260
+ const method = resolvedBackend?.[operation];
261
+ if (typeof method !== 'function') {
262
+ return {
263
+ success: false,
264
+ error: `Unsupported issue operation: ${operation}`,
265
+ };
266
+ }
267
+
268
+ return method.call(resolvedBackend, args, context);
269
+ },
270
+ };
271
+ }
272
+
273
+ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
274
+ const createService = deps.createService || (() => {
275
+ const backendDeps = {
276
+ isBeadsInitialized: deps.isBeadsInitialized,
277
+ runBdCommand: deps.runBdCommand,
278
+ spawn: deps.spawn,
279
+ platform: deps.platform,
280
+ env: deps.env,
281
+ };
282
+
283
+ return createIssueService({
284
+ backend: createBeadsIssueBackend(backendDeps),
285
+ });
286
+ });
287
+
288
+ const service = createService();
289
+ const result = await service.run(operation, rawArgs, {
290
+ projectRoot,
291
+ deps,
292
+ });
293
+
294
+ const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
295
+ if (result?.success && typeof queueGitHubProjection === 'function') {
296
+ const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
297
+ if (projectionPlan) {
298
+ await queueGitHubProjection(projectionPlan, {
299
+ operation,
300
+ args: rawArgs,
301
+ projectRoot,
302
+ result,
303
+ });
304
+ }
305
+ }
306
+
307
+ return result;
308
+ }
309
+
310
+ module.exports = {
311
+ createIssueService,
312
+ createBeadsIssueBackend,
313
+ runIssueOperation,
314
+ buildBdArgs,
315
+ extractErrorMessage,
316
+ getCommandErrorMessage,
317
+ getSpawnOptions,
318
+ hasEmptyShowPayload,
319
+ hasBdSoftFailure,
320
+ isHelpInvocation,
321
+ normalizeExecOutput,
322
+ runBdCommand,
323
+ getBdCommandCandidates,
324
+ getPathEntries,
325
+ resolveWindowsCommandCandidates,
326
+ };
@@ -0,0 +1,100 @@
1
+ const GITHUB_OWNED_FIELDS = new Set([
2
+ 'github.number',
3
+ 'github.nodeId',
4
+ 'github.url',
5
+ 'shared.title',
6
+ 'shared.body',
7
+ 'shared.state',
8
+ 'shared.assignees',
9
+ 'shared.labels',
10
+ 'shared.milestone',
11
+ 'sync.remoteUpdatedAt',
12
+ ]);
13
+
14
+ const FORGE_OWNED_FIELDS = new Set([
15
+ 'forge.issueId',
16
+ 'forge.dependencies',
17
+ 'forge.parentId',
18
+ 'forge.childIds',
19
+ 'forge.workflowStage',
20
+ 'forge.acceptanceCriteria',
21
+ 'forge.progressNotes',
22
+ 'forge.stageTransitions',
23
+ 'forge.decisions',
24
+ 'forge.memory',
25
+ 'sync.lastPulledAt',
26
+ 'sync.lastPushedAt',
27
+ 'sync.pendingOutbound',
28
+ 'sync.drift',
29
+ ]);
30
+
31
+ const CACHE_FIELDS = new Set([
32
+ 'cache.githubSnapshot',
33
+ 'cache.materializedIssue',
34
+ 'cache.legacyLinkHints.mapping',
35
+ 'cache.legacyLinkHints.githubIssue',
36
+ 'cache.legacyLinkHints.syncComments',
37
+ 'cache.legacyLinkHints.externalRef',
38
+ 'cache.legacyLinkHints.descriptionUrl',
39
+ ]);
40
+
41
+ function matchesFieldPath(fieldPath, knownFieldPath) {
42
+ return fieldPath === knownFieldPath || fieldPath.startsWith(`${knownFieldPath}.`);
43
+ }
44
+
45
+ function hasFieldPath(fieldPath, fieldSet) {
46
+ if (typeof fieldPath !== 'string' || fieldPath.length === 0) {
47
+ return false;
48
+ }
49
+
50
+ for (const knownFieldPath of fieldSet) {
51
+ if (matchesFieldPath(fieldPath, knownFieldPath)) {
52
+ return true;
53
+ }
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ function getFieldAuthority(fieldPath) {
60
+ if (hasFieldPath(fieldPath, GITHUB_OWNED_FIELDS)) {
61
+ return 'github';
62
+ }
63
+
64
+ if (hasFieldPath(fieldPath, FORGE_OWNED_FIELDS)) {
65
+ return 'forge';
66
+ }
67
+
68
+ if (hasFieldPath(fieldPath, CACHE_FIELDS)) {
69
+ return 'cache';
70
+ }
71
+
72
+ return null;
73
+ }
74
+
75
+ function isGitHubOwnedField(fieldPath) {
76
+ return getFieldAuthority(fieldPath) === 'github';
77
+ }
78
+
79
+ function isForgeOwnedField(fieldPath) {
80
+ return getFieldAuthority(fieldPath) === 'forge';
81
+ }
82
+
83
+ function isCacheField(fieldPath) {
84
+ return getFieldAuthority(fieldPath) === 'cache';
85
+ }
86
+
87
+ function isSharedField(fieldPath) {
88
+ return typeof fieldPath === 'string' && fieldPath.startsWith('shared.') && isGitHubOwnedField(fieldPath);
89
+ }
90
+
91
+ module.exports = {
92
+ CACHE_FIELDS,
93
+ FORGE_OWNED_FIELDS,
94
+ GITHUB_OWNED_FIELDS,
95
+ getFieldAuthority,
96
+ isCacheField,
97
+ isForgeOwnedField,
98
+ isGitHubOwnedField,
99
+ isSharedField,
100
+ };