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
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  const fs = require('node:fs');
11
+ const path = require('node:path');
11
12
  const { execFileSync: defaultExecFileSync } = require('node:child_process');
12
13
 
13
14
  const { checkLefthookStatus } = require('./lefthook-check');
@@ -76,31 +77,115 @@ function checkHookInstallation(projectRoot, options = {}) {
76
77
  const exec = options._exec || defaultExecFileSync;
77
78
  const platform = options.platform || process.platform;
78
79
  const expectedRelativeHooksPath = normalizeHooksPath('.lefthook/hooks', platform);
79
- const expectedAbsoluteHooksPath = normalizeHooksPath(`${projectRoot}/${expectedRelativeHooksPath}`, platform);
80
+ const requiredHooks = ['pre-commit', 'pre-push'];
80
81
 
82
+ function resolveGitRoot() {
83
+ try {
84
+ const output = exec('git', ['rev-parse', '--show-toplevel'], {
85
+ cwd: projectRoot,
86
+ encoding: 'utf8'
87
+ });
88
+ return toText(output).trim() || projectRoot;
89
+ } catch {
90
+ return projectRoot;
91
+ }
92
+ }
93
+
94
+ const gitRoot = resolveGitRoot();
95
+ const expectedAbsoluteHooksPath = normalizeHooksPath(`${gitRoot}/${expectedRelativeHooksPath}`, platform);
96
+
97
+ function resolveGitHooksDir() {
98
+ try {
99
+ const output = exec('git', ['rev-parse', '--git-path', 'hooks'], {
100
+ cwd: projectRoot,
101
+ encoding: 'utf8'
102
+ });
103
+ const rawPath = toText(output).trim();
104
+ if (!rawPath) return null;
105
+ return path.isAbsolute(rawPath) ? rawPath : path.resolve(projectRoot, rawPath);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function fileLooksLikeActiveHook(filePath) {
112
+ try {
113
+ const stat = fs.statSync(filePath);
114
+ const executable = platform === 'win32'
115
+ || (typeof options._canExecuteHook === 'function'
116
+ ? Boolean(options._canExecuteHook(filePath, stat))
117
+ : (stat.mode & 0o111) !== 0);
118
+ if (!stat.isFile() || stat.size === 0 || !executable) return false;
119
+ return /\blefthook\b/i.test(fs.readFileSync(filePath, 'utf8'));
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ let hooksPath = null;
81
126
  try {
82
127
  const output = exec('git', ['config', '--get', 'core.hooksPath'], {
83
128
  cwd: projectRoot,
84
129
  encoding: 'utf8'
85
130
  });
86
131
 
87
- const hooksPath = normalizeHooksPath(output, platform);
132
+ hooksPath = normalizeHooksPath(output, platform);
88
133
  const active = hooksPath === expectedRelativeHooksPath || hooksPath === expectedAbsoluteHooksPath;
89
134
 
135
+ if (active) {
136
+ const hooksDir = path.join(gitRoot, '.lefthook', 'hooks');
137
+ const missingHooks = requiredHooks.filter(hook => !fileLooksLikeActiveHook(path.join(hooksDir, hook)));
138
+ const verifiedActive = missingHooks.length === 0;
139
+ return {
140
+ active: verifiedActive,
141
+ state: verifiedActive ? 'active' : 'inactive',
142
+ verification: 'core.hooksPath',
143
+ hooksPath: hooksPath || null,
144
+ hooksDir,
145
+ missingHooks,
146
+ message: verifiedActive
147
+ ? ''
148
+ : `Git hooks directory is missing required hooks: ${missingHooks.join(', ')}.`
149
+ };
150
+ }
151
+
152
+ if (hooksPath) {
153
+ return {
154
+ active: false,
155
+ state: 'inactive',
156
+ verification: 'core.hooksPath',
157
+ hooksPath,
158
+ message: `Git core.hooksPath is set to "${hooksPath}", not ".lefthook/hooks".`
159
+ };
160
+ }
161
+ } catch {
162
+ // Fall through to git-path fallback for worktree-safe verification
163
+ }
164
+
165
+ const hooksDir = resolveGitHooksDir();
166
+ if (hooksDir) {
167
+ const missingHooks = requiredHooks.filter(hook => !fileLooksLikeActiveHook(path.join(hooksDir, hook)));
168
+ const active = missingHooks.length === 0;
90
169
  return {
91
170
  active,
92
171
  state: active ? 'active' : 'inactive',
172
+ verification: 'git-path-hooks',
93
173
  hooksPath: hooksPath || null,
94
- message: active ? '' : 'Git hooks are not pointed at .lefthook/hooks.'
95
- };
96
- } catch {
97
- return {
98
- active: false,
99
- state: 'unverified',
100
- hooksPath: null,
101
- message: 'Git hooks could not be verified.'
174
+ hooksDir,
175
+ missingHooks,
176
+ message: active
177
+ ? ''
178
+ : `Git hooks directory is missing required hooks: ${missingHooks.join(', ')}.`
102
179
  };
103
180
  }
181
+
182
+ return {
183
+ active: false,
184
+ state: 'unverified',
185
+ verification: 'unverified',
186
+ hooksPath: hooksPath || null,
187
+ message: 'Git hooks could not be verified.'
188
+ };
104
189
  }
105
190
 
106
191
  function checkCommandAvailability(command, projectRoot, options = {}) {
@@ -219,8 +304,8 @@ function checkRuntimeHealth(projectRoot, options = {}) {
219
304
  const platform = options.platform || process.platform;
220
305
  const root = normalizeProjectRoot(projectRoot);
221
306
 
222
- const lefthook = checkLefthookStatus(root);
223
307
  const hooks = checkHookInstallation(root, options);
308
+ const lefthook = checkLefthookStatus(root);
224
309
  const bd = checkCommandAvailability('bd', root, options);
225
310
  const gh = checkCommandAvailability('gh', root, options);
226
311
  const jq = checkCommandAvailability('jq', root, options);
@@ -228,7 +313,10 @@ function checkRuntimeHealth(projectRoot, options = {}) {
228
313
 
229
314
  const diagnostics = [];
230
315
 
231
- if (lefthook.state !== 'installed') {
316
+ const lefthookMissing = lefthook.state === 'missing-dependency'
317
+ || lefthook.state === 'missing-binary';
318
+
319
+ if (lefthookMissing) {
232
320
  diagnostics.push(createDiagnostic(
233
321
  'LEFTHOOK_MISSING',
234
322
  'lefthook',
@@ -14,6 +14,8 @@ const AGENT_PREFIXES = {
14
14
  '.windsurf/': 'Windsurf',
15
15
  '.cline/': 'Cline',
16
16
  '.codex/': 'Codex',
17
+ '~/.codex/': 'Codex',
18
+ '$CODEX_HOME/': 'Codex',
17
19
  '.opencode/': 'OpenCode',
18
20
  '.kilocode/': 'Kilocode',
19
21
  '.roo/': 'Roo Code',
@@ -26,11 +26,11 @@ function capitalize(str) {
26
26
  * @param {boolean} verbose - Whether to show file-by-file detail
27
27
  * @returns {string} The formatted summary output
28
28
  */
29
- function renderSetupSummary(actionLog, agentNames, verbose) {
29
+ function renderSetupSummary(actionLog, agentNames, verbose, options = {}) {
30
30
  if (verbose) {
31
- return renderVerbose(actionLog, agentNames);
31
+ return renderVerbose(actionLog, agentNames, options);
32
32
  }
33
- return renderDefault(actionLog, agentNames);
33
+ return renderDefault(actionLog, agentNames, options);
34
34
  }
35
35
 
36
36
  /**
@@ -40,10 +40,11 @@ function renderSetupSummary(actionLog, agentNames, verbose) {
40
40
  * @param {string[]} agentNames
41
41
  * @returns {string}
42
42
  */
43
- function renderDefault(actionLog, agentNames) {
43
+ function renderDefault(actionLog, agentNames, options = {}) {
44
44
  const agentCount = agentNames.length;
45
45
  const agentLabel = agentCount === 1 ? '1 agent' : `${agentCount} agents`;
46
46
  const agentList = agentNames.length > 0 ? ` (${agentNames.join(', ')})` : '';
47
+ const status = options.status === 'partial' ? 'partially complete' : 'complete';
47
48
 
48
49
  const summary = actionLog.getSummary();
49
50
 
@@ -63,7 +64,7 @@ function renderDefault(actionLog, agentNames) {
63
64
  }
64
65
 
65
66
  const lines = [];
66
- lines.push(`Forge setup complete — ${agentLabel} configured${agentList}`);
67
+ lines.push(`Forge setup ${status} — ${agentLabel} configured${agentList}`);
67
68
 
68
69
  if (parts.length > 0) {
69
70
  lines.push(` ${parts.join(' | ')}`);
@@ -83,10 +84,18 @@ function renderDefault(actionLog, agentNames) {
83
84
  * @param {string[]} _agentNames - Not used in verbose (agents come from log data)
84
85
  * @returns {string}
85
86
  */
86
- function renderVerbose(actionLog, _agentNames) {
87
+ function renderVerbose(actionLog, _agentNames, options = {}) {
87
88
  const agentSummary = actionLog.getAgentSummary();
89
+ const status = options.status === 'partial' ? 'partially complete' : 'complete';
88
90
  const lines = [];
89
91
 
92
+ if (Object.keys(agentSummary).length === 0) {
93
+ return 'No file operations recorded.';
94
+ }
95
+
96
+ lines.push(`Forge setup ${status}`);
97
+ lines.push('');
98
+
90
99
  for (const [agent, actions] of Object.entries(agentSummary)) {
91
100
  for (const [action, files] of Object.entries(actions)) {
92
101
  const fileCount = files.length;
@@ -95,11 +104,6 @@ function renderVerbose(actionLog, _agentNames) {
95
104
  lines.push(`${agent}: ${fileList} (${fileLabel}) [${action}]`);
96
105
  }
97
106
  }
98
-
99
- if (lines.length === 0) {
100
- return 'No file operations recorded.';
101
- }
102
-
103
107
  return lines.join('\n');
104
108
  }
105
109
 
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ function splitLines(value) {
4
+ if (!value) {
5
+ return [];
6
+ }
7
+
8
+ return String(value)
9
+ .replaceAll('\r', '')
10
+ .split('\n');
11
+ }
12
+
13
+ function uniqueStrings(values) {
14
+ const result = [];
15
+ const seen = new Set();
16
+
17
+ for (const value of values || []) {
18
+ if (typeof value !== 'string') {
19
+ continue;
20
+ }
21
+ const normalized = value.trim();
22
+ if (!normalized || seen.has(normalized)) {
23
+ continue;
24
+ }
25
+ seen.add(normalized);
26
+ result.push(normalized);
27
+ }
28
+
29
+ return result;
30
+ }
31
+
32
+ function parseWorktreePorcelain(porcelainText, baseBranch) {
33
+ const worktrees = [];
34
+ let currentPath = '';
35
+ let currentBranch = '';
36
+
37
+ for (const line of splitLines(porcelainText)) {
38
+ if (line.startsWith('worktree ')) {
39
+ currentPath = line.slice('worktree '.length);
40
+ continue;
41
+ }
42
+
43
+ if (line.startsWith('branch refs/heads/')) {
44
+ currentBranch = line.slice('branch refs/heads/'.length);
45
+ continue;
46
+ }
47
+
48
+ if (line !== '') {
49
+ continue;
50
+ }
51
+
52
+ if (currentBranch && currentBranch !== baseBranch) {
53
+ worktrees.push({ branch: currentBranch, path: currentPath });
54
+ }
55
+ currentPath = '';
56
+ currentBranch = '';
57
+ }
58
+
59
+ if (currentBranch && currentBranch !== baseBranch) {
60
+ worktrees.push({ branch: currentBranch, path: currentPath });
61
+ }
62
+
63
+ return worktrees;
64
+ }
65
+
66
+ function branchSlugToSearch(branch) {
67
+ const slug = String(branch || '').includes('/')
68
+ ? String(branch).slice(String(branch).lastIndexOf('/') + 1)
69
+ : String(branch || '');
70
+ return slug.replaceAll('-', ' ').toLowerCase();
71
+ }
72
+
73
+ function matchInProgressIssues(worktrees, inProgressIssues) {
74
+ const issues = Array.isArray(inProgressIssues) ? inProgressIssues : [];
75
+
76
+ return (Array.isArray(worktrees) ? worktrees : []).map((worktree) => {
77
+ const slug = branchSlugToSearch(worktree.branch);
78
+ if (!slug) {
79
+ return {
80
+ branch: worktree.branch,
81
+ path: worktree.path,
82
+ issue_ids: [],
83
+ issue_count: 0,
84
+ };
85
+ }
86
+ const issueIds = issues
87
+ .filter((issue) => typeof issue?.title === 'string' && issue.title.toLowerCase().includes(slug))
88
+ .map((issue) => issue?.id)
89
+ .filter((issueId) => typeof issueId === 'string' && issueId);
90
+
91
+ return {
92
+ branch: worktree.branch,
93
+ path: worktree.path,
94
+ issue_ids: issueIds,
95
+ issue_count: issueIds.length,
96
+ };
97
+ });
98
+ }
99
+
100
+ function normalizeBranchFiles(branchFiles) {
101
+ if (Array.isArray(branchFiles)) {
102
+ return branchFiles
103
+ .filter((entry) => entry && typeof entry.branch === 'string')
104
+ .map((entry) => ({
105
+ branch: entry.branch,
106
+ files: Array.isArray(entry.files) ? uniqueStrings(entry.files) : [],
107
+ }));
108
+ }
109
+
110
+ if (branchFiles && typeof branchFiles === 'object') {
111
+ return Object.entries(branchFiles).map(([branch, files]) => ({
112
+ branch,
113
+ files: Array.isArray(files) ? uniqueStrings(files) : [],
114
+ }));
115
+ }
116
+
117
+ return [];
118
+ }
119
+
120
+ function computeFileConflicts(sessions, branchFiles) {
121
+ const branchFileEntries = normalizeBranchFiles(branchFiles);
122
+ const filesByBranch = new Map(branchFileEntries.map((entry) => [entry.branch, entry.files]));
123
+
124
+ return (Array.isArray(sessions) ? sessions : []).map((session) => {
125
+ const changedFiles = filesByBranch.get(session.branch) || [];
126
+ const conflicts = [];
127
+
128
+ for (const other of branchFileEntries) {
129
+ if (other.branch === session.branch || other.files.length === 0 || changedFiles.length === 0) {
130
+ continue;
131
+ }
132
+
133
+ const otherSet = new Set(other.files);
134
+ const overlap = uniqueStrings(changedFiles.filter((file) => otherSet.has(file)));
135
+ if (overlap.length > 0) {
136
+ conflicts.push({ branch: other.branch, files: overlap });
137
+ }
138
+ }
139
+
140
+ return {
141
+ ...session,
142
+ changed_files: changedFiles,
143
+ conflicts,
144
+ };
145
+ });
146
+ }
147
+
148
+ function parseMergeTreeNameOnly(stdout) {
149
+ const lines = splitLines(stdout).filter((line, index) => index > 0 && line.trim().length > 0);
150
+ return uniqueStrings(lines);
151
+ }
152
+
153
+ function applyMergeTreeConflicts(sessions, mergeTreeResults) {
154
+ const nextSessions = (Array.isArray(sessions) ? sessions : []).map((session) => ({
155
+ ...session,
156
+ merge_conflicts: Array.isArray(session.merge_conflicts) ? [...session.merge_conflicts] : [],
157
+ }));
158
+ const sessionIndexes = new Map(nextSessions.map((session, index) => [session.branch, index]));
159
+
160
+ for (const result of Array.isArray(mergeTreeResults) ? mergeTreeResults : []) {
161
+ if (!result || typeof result.left !== 'string' || typeof result.right !== 'string') {
162
+ continue;
163
+ }
164
+
165
+ const exitCode = Number(result.exitCode);
166
+ if (exitCode !== 1) {
167
+ continue;
168
+ }
169
+
170
+ const files = parseMergeTreeNameOnly(result.output);
171
+ if (files.length === 0) {
172
+ continue;
173
+ }
174
+
175
+ for (const [targetBranch, otherBranch] of [[result.left, result.right], [result.right, result.left]]) {
176
+ const index = sessionIndexes.get(targetBranch);
177
+ if (index === undefined) {
178
+ continue;
179
+ }
180
+
181
+ nextSessions[index].merge_conflicts.push({
182
+ branch: otherBranch,
183
+ files,
184
+ });
185
+ }
186
+ }
187
+
188
+ return nextSessions.map((session) => {
189
+ if (session.merge_conflicts.length === 0) {
190
+ const nextSession = { ...session };
191
+ delete nextSession.merge_conflicts;
192
+ return nextSession;
193
+ }
194
+
195
+ return session;
196
+ });
197
+ }
198
+
199
+ module.exports = {
200
+ applyMergeTreeConflicts,
201
+ computeFileConflicts,
202
+ matchInProgressIssues,
203
+ parseMergeTreeNameOnly,
204
+ parseWorktreePorcelain,
205
+ };
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ function roundToTwo(value) {
4
+ return Math.round(value * 100) / 100;
5
+ }
6
+
7
+ function normalizePriorityNumber(priority) {
8
+ if (priority === undefined || priority === null) {
9
+ return 2;
10
+ }
11
+
12
+ if (typeof priority === 'string') {
13
+ switch (priority) {
14
+ case 'P0':
15
+ return 0;
16
+ case 'P1':
17
+ return 1;
18
+ case 'P2':
19
+ return 2;
20
+ case 'P3':
21
+ return 3;
22
+ case 'P4':
23
+ return 4;
24
+ default:
25
+ return 5;
26
+ }
27
+ }
28
+
29
+ return priority;
30
+ }
31
+
32
+ function getPriorityWeight(priority) {
33
+ switch (normalizePriorityNumber(priority)) {
34
+ case 0:
35
+ return 5;
36
+ case 1:
37
+ return 4;
38
+ case 2:
39
+ return 3;
40
+ case 3:
41
+ return 2;
42
+ case 4:
43
+ return 1;
44
+ default:
45
+ return 1;
46
+ }
47
+ }
48
+
49
+ function getTypeWeight(type = 'task') {
50
+ const normalizedType = type ?? 'task';
51
+ if (normalizedType === 'bug') {
52
+ return 1.2;
53
+ }
54
+ if (normalizedType === 'feature') {
55
+ return 1;
56
+ }
57
+ if (normalizedType === 'task') {
58
+ return 0.8;
59
+ }
60
+ return 1;
61
+ }
62
+
63
+ function getStatusBoost(status) {
64
+ if (status === 'in_progress') {
65
+ return 1.5;
66
+ }
67
+ if (status === 'open') {
68
+ return 1;
69
+ }
70
+ return 1;
71
+ }
72
+
73
+ function parseUpdatedAtLikeShell(updatedAt) {
74
+ if (!updatedAt) {
75
+ return null;
76
+ }
77
+
78
+ const normalized = `${String(updatedAt).slice(0, 19)}Z`;
79
+ const timestamp = Date.parse(normalized);
80
+ return Number.isFinite(timestamp) ? timestamp : null;
81
+ }
82
+
83
+ function getStalenessBoost(updatedAt, now = Date.now()) {
84
+ const timestamp = parseUpdatedAtLikeShell(updatedAt);
85
+ if (timestamp === null) {
86
+ return 1;
87
+ }
88
+
89
+ const days = (now - timestamp) / 86400000;
90
+ if (days >= 30) {
91
+ return 1.5;
92
+ }
93
+ if (days >= 14) {
94
+ return 1.2;
95
+ }
96
+ if (days >= 7) {
97
+ return 1.1;
98
+ }
99
+ return 1;
100
+ }
101
+
102
+ function buildDependentsMap(issues) {
103
+ const dependentsMap = new Map();
104
+
105
+ for (const issue of issues) {
106
+ for (const dependency of issue.dependencies ?? []) {
107
+ const dependencyId = dependency?.depends_on_id;
108
+ if (!dependencyId) {
109
+ continue;
110
+ }
111
+ if (!dependentsMap.has(dependencyId)) {
112
+ dependentsMap.set(dependencyId, []);
113
+ }
114
+ dependentsMap.get(dependencyId).push(issue.id);
115
+ }
116
+ }
117
+
118
+ return dependentsMap;
119
+ }
120
+
121
+ function getEpicProximity(issue, epicStats) {
122
+ if (!issue.parent_id || !epicStats?.[issue.parent_id]) {
123
+ return 1;
124
+ }
125
+
126
+ const stats = epicStats[issue.parent_id];
127
+ if (stats.total <= 0) {
128
+ return 1;
129
+ }
130
+
131
+ return 1 + ((stats.closed / stats.total) * 0.5);
132
+ }
133
+
134
+ function scoreIssues(issues, options = {}) {
135
+ const epicStats = options.epicStats ?? {};
136
+ const now = options.now ?? Date.now();
137
+ const dependentsMap = buildDependentsMap(issues);
138
+
139
+ return [...issues]
140
+ .map((issue) => {
141
+ const priorityWeight = getPriorityWeight(issue.priority);
142
+ const directDependents = issue.dependent_count ?? dependentsMap.get(issue.id)?.length ?? 0;
143
+ const unblockChain = Math.max(directDependents + 1, 1);
144
+ const typeWeight = getTypeWeight(issue.type);
145
+ const statusBoost = getStatusBoost(issue.status);
146
+ const epicProximity = getEpicProximity(issue, epicStats);
147
+ const stalenessBoost = getStalenessBoost(issue.updated_at, now);
148
+ const dependents = dependentsMap.get(issue.id) ?? [];
149
+ const score = priorityWeight
150
+ * unblockChain
151
+ * typeWeight
152
+ * statusBoost
153
+ * epicProximity
154
+ * stalenessBoost;
155
+
156
+ return {
157
+ ...issue,
158
+ score: roundToTwo(score),
159
+ priority_weight: priorityWeight,
160
+ unblock_chain: unblockChain,
161
+ type_weight: typeWeight,
162
+ status_boost: statusBoost,
163
+ epic_proximity: roundToTwo(epicProximity),
164
+ staleness_boost: stalenessBoost,
165
+ dependents,
166
+ };
167
+ })
168
+ .sort((left, right) => right.score - left.score);
169
+ }
170
+
171
+ module.exports = {
172
+ getPriorityWeight,
173
+ getStalenessBoost,
174
+ getStatusBoost,
175
+ getTypeWeight,
176
+ scoreIssues,
177
+ };