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.
- package/.claude/commands/dev.md +2 -2
- package/.claude/commands/plan.md +2 -2
- package/.claude/commands/ship.md +2 -2
- package/.claude/commands/status.md +4 -4
- package/.cline/workflows/dev.md +2 -2
- package/.cline/workflows/plan.md +2 -2
- package/.cline/workflows/ship.md +2 -2
- package/.cline/workflows/status.md +4 -4
- package/.codex/skills/dev/SKILL.md +2 -2
- package/.codex/skills/plan/SKILL.md +2 -2
- package/.codex/skills/ship/SKILL.md +2 -2
- package/.codex/skills/status/SKILL.md +4 -4
- package/.cursor/commands/dev.md +2 -2
- package/.cursor/commands/plan.md +2 -2
- package/.cursor/commands/ship.md +2 -2
- package/.cursor/commands/status.md +4 -4
- package/.github/prompts/dev.prompt.md +2 -2
- package/.github/prompts/plan.prompt.md +2 -2
- package/.github/prompts/ship.prompt.md +2 -2
- package/.github/prompts/status.prompt.md +4 -4
- package/.github/workflows/beads-to-github.yml +43 -10
- package/.github/workflows/github-to-beads.yml +10 -7
- package/.kilocode/workflows/dev.md +2 -2
- package/.kilocode/workflows/plan.md +2 -2
- package/.kilocode/workflows/ship.md +2 -2
- package/.kilocode/workflows/status.md +4 -4
- package/.opencode/commands/dev.md +2 -2
- package/.opencode/commands/plan.md +2 -2
- package/.opencode/commands/ship.md +2 -2
- package/.opencode/commands/status.md +4 -4
- package/.roo/commands/dev.md +2 -2
- package/.roo/commands/plan.md +2 -2
- package/.roo/commands/ship.md +2 -2
- package/.roo/commands/status.md +4 -4
- package/AGENTS.md +1 -0
- package/CLAUDE.md +12 -0
- package/bin/forge.js +10 -5
- package/docs/BEADS_GITHUB_SYNC.md +26 -0
- package/docs/TOOLCHAIN.md +130 -148
- package/lib/beads-bootstrap.js +225 -0
- package/lib/beads-health-check.js +55 -10
- package/lib/beads-setup.js +104 -28
- package/lib/beads-sync-scaffold.js +11 -6
- package/lib/commands/_issue.js +11 -1
- package/lib/commands/issues.js +49 -0
- package/lib/commands/recommend.js +22 -1
- package/lib/commands/setup.js +16 -10
- package/lib/commands/status.js +181 -0
- package/lib/commands/team.js +11 -1
- package/lib/commands/test.js +37 -2
- package/lib/commands/validate.js +14 -8
- package/lib/commands/worktree.js +27 -54
- package/lib/dep-guard/keyword-ripple.js +184 -0
- package/lib/detect-worktree.js +9 -10
- package/lib/forge-issues.js +326 -0
- package/lib/issue-sync/authority.js +100 -0
- package/lib/issue-sync/github-pull.js +184 -0
- package/lib/issue-sync/import-primitives.js +98 -0
- package/lib/issue-sync/legacy-link-bridge.js +436 -0
- package/lib/issue-sync/link-store.js +292 -0
- package/lib/issue-sync/project-github.js +123 -0
- package/lib/issue-sync/reconcile.js +195 -0
- package/lib/issue-sync/schema.js +126 -0
- package/lib/lefthook-check.js +5 -2
- package/lib/project-memory.js +564 -0
- package/lib/runtime-health.js +100 -12
- package/lib/smart-status/conflicts.js +205 -0
- package/lib/smart-status/scoring.js +177 -0
- package/lib/status/beads-snapshot.js +102 -0
- package/lib/status/presenter.js +65 -0
- package/lib/workflow/enforce-stage.js +3 -1
- package/lib/workflow/state-manager.js +164 -8
- package/package.json +12 -4
- package/scripts/beads-context.sh +124 -5
- package/scripts/beads-context.test.js +21 -4
- package/scripts/beads-migrate-to-dolt.sh +7 -0
- package/scripts/beads-upgrade-smoke.sh +263 -0
- package/scripts/behavioral-judge.sh +115 -11
- package/scripts/benchmark.js +349 -63
- package/scripts/dep-guard-analyze.js +52 -17
- package/scripts/dep-guard-keyword-ripple.js +29 -0
- package/scripts/dep-guard-render-review.js +86 -0
- package/scripts/dep-guard.sh +45 -232
- package/scripts/forge-team/lib/sync-github.sh +160 -28
- package/scripts/forge-team/tests/sync-github.test.sh +195 -58
- package/scripts/github-beads-sync/index.mjs +122 -98
- package/scripts/github-beads-sync/mapping.mjs +54 -0
- package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
- package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
- package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
- package/scripts/preflight.sh +181 -0
- package/scripts/smart-status-score.js +31 -0
- package/scripts/smart-status-sessions.js +51 -0
- package/scripts/smart-status.sh +74 -329
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/test-ci-shard.js +244 -0
- package/scripts/test-dashboard.js +188 -52
- package/scripts/test-full-suite.js +186 -0
- package/scripts/test-profile.js +278 -0
- package/scripts/test.js +219 -28
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
package/lib/runtime-health.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
+
};
|