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
@@ -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',
@@ -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
+ };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { secureExecFileSync } = require('../shell-utils.js');
6
+
7
+ function getGitConfig(projectRoot, key) {
8
+ try {
9
+ return secureExecFileSync('git', ['config', key], {
10
+ encoding: 'utf8',
11
+ cwd: projectRoot,
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ }).trim();
14
+ } catch (_error) {
15
+ return '';
16
+ }
17
+ }
18
+
19
+ function getDeveloperIdentity(projectRoot) {
20
+ return {
21
+ email: getGitConfig(projectRoot, 'user.email'),
22
+ name: getGitConfig(projectRoot, 'user.name'),
23
+ };
24
+ }
25
+
26
+ function normalizeEmail(value) {
27
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
28
+ }
29
+
30
+ function readIssues(projectRoot) {
31
+ const issuesPath = path.join(projectRoot, '.beads', 'issues.jsonl');
32
+ if (!fs.existsSync(issuesPath)) {
33
+ return [];
34
+ }
35
+
36
+ const raw = fs.readFileSync(issuesPath, 'utf8');
37
+ const latestById = new Map();
38
+
39
+ for (const line of raw.split(/\r?\n/)) {
40
+ if (!line.trim()) {
41
+ continue;
42
+ }
43
+
44
+ try {
45
+ const issue = JSON.parse(line);
46
+ if (issue && issue.id) {
47
+ latestById.set(issue.id, issue);
48
+ }
49
+ } catch (_error) {
50
+ // Ignore malformed rows so one bad append does not break status.
51
+ }
52
+ }
53
+
54
+ return Array.from(latestById.values());
55
+ }
56
+
57
+ function isAssignedToDeveloper(issue, developer) {
58
+ if (!issue || !developer) {
59
+ return false;
60
+ }
61
+
62
+ if (issue.owner) {
63
+ const developerEmail = normalizeEmail(developer.email);
64
+ return developerEmail !== '' && normalizeEmail(issue.owner) === developerEmail;
65
+ }
66
+
67
+ return developer.name !== '' && issue.created_by === developer.name;
68
+ }
69
+
70
+ function parseTimestampOrZero(value) {
71
+ if (!value) {
72
+ return 0;
73
+ }
74
+
75
+ const parsed = Date.parse(value);
76
+ return Number.isNaN(parsed) ? 0 : parsed;
77
+ }
78
+
79
+ function sortByUpdatedAtDesc(left, right) {
80
+ const leftTime = parseTimestampOrZero(left.updated_at);
81
+ const rightTime = parseTimestampOrZero(right.updated_at);
82
+ return rightTime - leftTime;
83
+ }
84
+
85
+ function readBeadsSnapshot(projectRoot) {
86
+ const developer = getDeveloperIdentity(projectRoot);
87
+ const issues = readIssues(projectRoot);
88
+
89
+ return {
90
+ developer,
91
+ issues,
92
+ activeAssigned: issues.filter(issue => issue.status === 'in_progress' && isAssignedToDeveloper(issue, developer)),
93
+ ready: issues.filter(issue => issue.status === 'open' && Number(issue.dependency_count || 0) === 0),
94
+ recentCompleted: issues.filter(issue => issue.status === 'closed').sort(sortByUpdatedAtDesc),
95
+ };
96
+ }
97
+
98
+ module.exports = {
99
+ getDeveloperIdentity,
100
+ isAssignedToDeveloper,
101
+ readBeadsSnapshot,
102
+ };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_SECTION_LIMIT = 5;
4
+
5
+ function buildSection(title, items, options = {}) {
6
+ const lines = ['', title];
7
+ if (!items || items.length === 0) {
8
+ lines.push(' none', '');
9
+ return lines;
10
+ }
11
+
12
+ const limit = options.limit ?? null;
13
+ const visibleItems = limit ? items.slice(0, limit) : items;
14
+ lines.push(...visibleItems.map(item => ` ${item}`));
15
+
16
+ if (limit && items.length > limit) {
17
+ lines.push(` ...and ${items.length - limit} more`);
18
+ }
19
+
20
+ lines.push('');
21
+ return lines;
22
+ }
23
+
24
+ function formatIssue(issue, options = {}) {
25
+ const status = options.includeStatus && issue.status ? ` [${issue.status}]` : '';
26
+ return `${issue.id} ${issue.title || '(untitled)'}${status}`;
27
+ }
28
+
29
+ function formatWorkflow(workflowResult) {
30
+ if (!workflowResult) {
31
+ return ['No active workflow state detected.'];
32
+ }
33
+
34
+ return [
35
+ `${workflowResult.stageName} (${workflowResult.stageId})`,
36
+ `Run now: /${workflowResult.runCommand}`,
37
+ workflowResult.nextCommand ? `Next after this: /${workflowResult.nextCommand}` : 'Next after this: none',
38
+ ];
39
+ }
40
+
41
+ function formatZeroArgStatus({ context, snapshot, workflowResult = null }) {
42
+ const contextLines = [
43
+ `Branch: ${context.branch}`,
44
+ `Worktree: ${context.inWorktree ? 'linked' : 'main'}`,
45
+ `Path: ${context.worktreePath}`,
46
+ ];
47
+
48
+ if (context.inWorktree && context.mainWorktree) {
49
+ contextLines.push(`Main worktree: ${context.mainWorktree}`);
50
+ }
51
+
52
+ contextLines.push(`Working tree: ${context.workingTree.summary}`);
53
+
54
+ return [
55
+ ...buildSection('Context', contextLines),
56
+ ...buildSection('Active Issues', (snapshot.activeAssigned || []).map(issue => formatIssue(issue, { includeStatus: true }))),
57
+ ...buildSection('Ready', (snapshot.ready || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
58
+ ...buildSection('Recent Completions', (snapshot.recentCompleted || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
59
+ ...buildSection('Workflow', formatWorkflow(workflowResult)),
60
+ ].join('\n');
61
+ }
62
+
63
+ module.exports = {
64
+ formatZeroArgStatus,
65
+ };