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.
- package/.claude/commands/dev.md +2 -2
- package/.claude/commands/plan.md +2 -2
- package/.claude/commands/premerge.md +2 -2
- package/.claude/commands/review.md +5 -2
- package/.claude/commands/ship.md +6 -5
- package/.claude/commands/status.md +4 -4
- package/.claude/rules/greptile-review-process.md +4 -4
- package/.cline/workflows/dev.md +2 -2
- package/.cline/workflows/plan.md +2 -2
- package/.cline/workflows/premerge.md +2 -2
- package/.cline/workflows/review.md +5 -2
- package/.cline/workflows/ship.md +6 -5
- 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/premerge/SKILL.md +2 -2
- package/.codex/skills/review/SKILL.md +5 -2
- package/.codex/skills/ship/SKILL.md +6 -5
- 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/premerge.md +2 -2
- package/.cursor/commands/review.md +5 -2
- package/.cursor/commands/ship.md +6 -5
- 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/premerge.prompt.md +2 -2
- package/.github/prompts/review.prompt.md +5 -2
- package/.github/prompts/ship.prompt.md +6 -5
- package/.github/prompts/status.prompt.md +4 -4
- package/.github/workflows/beads-to-github.yml +44 -11
- package/.github/workflows/github-to-beads.yml +11 -8
- package/.kilocode/workflows/dev.md +2 -2
- package/.kilocode/workflows/plan.md +2 -2
- package/.kilocode/workflows/premerge.md +2 -2
- package/.kilocode/workflows/review.md +5 -2
- package/.kilocode/workflows/ship.md +6 -5
- package/.kilocode/workflows/status.md +4 -4
- package/.opencode/commands/dev.md +2 -2
- package/.opencode/commands/plan.md +2 -2
- package/.opencode/commands/premerge.md +2 -2
- package/.opencode/commands/review.md +5 -2
- package/.opencode/commands/ship.md +6 -5
- package/.opencode/commands/status.md +4 -4
- package/.roo/commands/dev.md +2 -2
- package/.roo/commands/plan.md +2 -2
- package/.roo/commands/premerge.md +2 -2
- package/.roo/commands/review.md +5 -2
- package/.roo/commands/ship.md +6 -5
- package/.roo/commands/status.md +4 -4
- package/AGENTS.md +10 -9
- package/CLAUDE.md +12 -0
- package/README.md +12 -6
- package/bin/forge.js +24 -8
- package/docs/BEADS_GITHUB_SYNC.md +32 -2
- package/docs/EXAMPLES.md +22 -22
- package/docs/ROADMAP.md +3 -3
- package/docs/TOOLCHAIN.md +150 -160
- package/lib/agents/codex.plugin.json +3 -0
- package/lib/agents-config.js +18 -12
- 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/codex-skills.js +54 -1
- package/lib/commands/_issue.js +11 -1
- package/lib/commands/issues.js +49 -0
- package/lib/commands/plan.js +5 -2
- package/lib/commands/recommend.js +22 -1
- package/lib/commands/setup.js +247 -27
- package/lib/commands/ship.js +188 -5
- package/lib/commands/status.js +187 -19
- package/lib/commands/team.js +11 -1
- package/lib/commands/test.js +125 -25
- package/lib/commands/validate.js +232 -9
- 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/setup-action-log.js +2 -0
- package/lib/setup-summary-renderer.js +15 -11
- 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 +14 -8
- package/lib/workflow/state-manager.js +349 -0
- 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 -222
- package/scripts/forge-team/lib/hooks.sh +1 -1
- package/scripts/forge-team/lib/sync-github.sh +160 -28
- package/scripts/forge-team/lib/verify.sh +1 -1
- package/scripts/forge-team/lib/workload.sh +56 -27
- package/scripts/forge-team/tests/sync-github.test.sh +195 -58
- package/scripts/forge-team/tests/workload.test.sh +35 -4
- 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/github-beads-sync/run-bd.mjs +4 -2
- 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 +84 -330
- package/scripts/sync-agentic-workflow.js +48 -0
- package/scripts/sync-utils.sh +39 -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 +335 -38
- package/scripts/validate.js +143 -0
- package/scripts/validate.sh +18 -1
|
@@ -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
|
+
};
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
|
|
6
3
|
const { repairWorkflowRuntimeAssets } = require('../commands/setup');
|
|
7
4
|
const { checkRuntimeHealth } = require('../runtime-health');
|
|
8
5
|
const { normalizeStageId } = require('./stages');
|
|
@@ -11,8 +8,9 @@ const {
|
|
|
11
8
|
normalizeOverrideRecord,
|
|
12
9
|
readWorkflowState,
|
|
13
10
|
} = require('./state');
|
|
11
|
+
const { loadState, WORKFLOW_STATE_FILENAME } = require('./state-manager');
|
|
14
12
|
|
|
15
|
-
const
|
|
13
|
+
const STATELESS_ENTRY_STAGES = new Set(['plan', 'dev', 'validate', 'verify']);
|
|
16
14
|
|
|
17
15
|
function getOverrideInput(flags = {}) {
|
|
18
16
|
if (Object.hasOwn(flags, 'overrideStage')) {
|
|
@@ -67,6 +65,8 @@ function readWorkflowStateFile(projectRoot) {
|
|
|
67
65
|
return null;
|
|
68
66
|
}
|
|
69
67
|
|
|
68
|
+
const fs = require('node:fs');
|
|
69
|
+
const path = require('node:path');
|
|
70
70
|
const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
|
|
71
71
|
if (!fs.existsSync(statePath)) {
|
|
72
72
|
return null;
|
|
@@ -76,11 +76,17 @@ function readWorkflowStateFile(projectRoot) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
function resolveWorkflowStateInput(workflowState, flags = {}, args = [], projectRoot) {
|
|
79
|
-
|
|
79
|
+
const inlineOrFlag = workflowState
|
|
80
80
|
|| flags.workflowState
|
|
81
81
|
|| flags['--workflow-state']
|
|
82
|
-
|| getCliFlagValue('--workflow-state', args)
|
|
83
|
-
|
|
82
|
+
|| getCliFlagValue('--workflow-state', args);
|
|
83
|
+
|
|
84
|
+
if (inlineOrFlag) {
|
|
85
|
+
return inlineOrFlag;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { state } = loadState(projectRoot);
|
|
89
|
+
return state;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
function readWorkflowStateInput(input) {
|
|
@@ -127,7 +133,7 @@ async function enforceStageEntry({ commandName, args = [], flags = {}, projectRo
|
|
|
127
133
|
const stateInput = resolveWorkflowStateInput(workflowState, flags, args, projectRoot);
|
|
128
134
|
const currentState = readWorkflowStateInput(stateInput);
|
|
129
135
|
if (!currentState) {
|
|
130
|
-
if (stageId
|
|
136
|
+
if (STATELESS_ENTRY_STAGES.has(stageId)) {
|
|
131
137
|
return { allowed: true, stage: stageId, workflowState: null };
|
|
132
138
|
}
|
|
133
139
|
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { secureExecFileSync } = require('../shell-utils.js');
|
|
7
|
+
const { readWorkflowState, serializeWorkflowState, WORKFLOW_STATE_SCHEMA_VERSION, getAllowedTransitionsForWorkflowState } = require('./state.js');
|
|
8
|
+
const { getWorkflowPath, WORKFLOW_CLASSIFICATIONS, normalizeStageId } = require('./stages.js');
|
|
9
|
+
|
|
10
|
+
const WORKFLOW_STATE_FILENAME = '.forge-state.json';
|
|
11
|
+
|
|
12
|
+
function parseRelaxedWorkflowStatePayload(payload = '') {
|
|
13
|
+
const currentStage = String(payload).match(/currentStage:([a-z]+)/)?.[1];
|
|
14
|
+
if (!currentStage) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const completedStages = (String(payload).match(/completedStages:\[([^\]]*)\]/)?.[1] || '')
|
|
19
|
+
.split(',')
|
|
20
|
+
.map(stage => stage.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
const skippedStages = (String(payload).match(/skippedStages:\[([^\]]*)\]/)?.[1] || '')
|
|
23
|
+
.split(',')
|
|
24
|
+
.map(stage => stage.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const classification = String(payload).match(/classification:([a-z]+)/)?.[1] || 'standard';
|
|
27
|
+
const reason = String(payload).match(/reason:([^,}\]]+)/)?.[1]?.trim() || 'legacy-comment';
|
|
28
|
+
|
|
29
|
+
return readWorkflowState(JSON.stringify({
|
|
30
|
+
currentStage,
|
|
31
|
+
completedStages,
|
|
32
|
+
skippedStages,
|
|
33
|
+
workflowDecisions: {
|
|
34
|
+
classification,
|
|
35
|
+
reason,
|
|
36
|
+
userOverride: false,
|
|
37
|
+
overrides: [],
|
|
38
|
+
},
|
|
39
|
+
parallelTracks: [],
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractWorkflowStateFromComments(comments = '') {
|
|
44
|
+
const matches = String(comments)
|
|
45
|
+
.split(/\r?\n/)
|
|
46
|
+
.filter(line => line.startsWith('WorkflowState:'));
|
|
47
|
+
if (!matches || matches.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const match of [...matches].reverse()) {
|
|
52
|
+
const payload = match.replace(/^WorkflowState:\s*/, '');
|
|
53
|
+
try {
|
|
54
|
+
return readWorkflowState(payload);
|
|
55
|
+
} catch (_strictError) {
|
|
56
|
+
try {
|
|
57
|
+
const relaxedState = parseRelaxedWorkflowStatePayload(payload);
|
|
58
|
+
if (relaxedState) {
|
|
59
|
+
return relaxedState;
|
|
60
|
+
}
|
|
61
|
+
} catch (_relaxedError) {
|
|
62
|
+
// Continue to any earlier payloads.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function extractWorkflowStateFromIssue(issue = {}) {
|
|
71
|
+
if (!issue || typeof issue !== 'object') {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof issue.comments === 'string') {
|
|
76
|
+
return extractWorkflowStateFromComments(issue.comments);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!Array.isArray(issue.comments) || issue.comments.length === 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const mergedComments = issue.comments
|
|
84
|
+
.map(comment => {
|
|
85
|
+
if (typeof comment === 'string') {
|
|
86
|
+
return comment;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return comment && typeof comment === 'object' ? comment.text || '' : '';
|
|
90
|
+
})
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n');
|
|
93
|
+
|
|
94
|
+
if (!mergedComments) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return extractWorkflowStateFromComments(mergedComments);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBeadsIssue(issueId, options = {}) {
|
|
102
|
+
if (!issueId) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const { projectRoot, ...execOptions } = options;
|
|
108
|
+
const raw = secureExecFileSync('bd', ['show', issueId, '--json'], {
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
cwd: projectRoot,
|
|
111
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
112
|
+
...execOptions,
|
|
113
|
+
}).trim();
|
|
114
|
+
|
|
115
|
+
if (!raw) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
if (Array.isArray(parsed)) {
|
|
121
|
+
return parsed[0] || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return parsed;
|
|
125
|
+
} catch (_error) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readWorkflowStateFromBeads(issueId, options = {}) {
|
|
131
|
+
if (!issueId) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.issue) {
|
|
136
|
+
const state = extractWorkflowStateFromIssue(options.issue);
|
|
137
|
+
if (state) {
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let comments = options.comments;
|
|
143
|
+
if (!comments) {
|
|
144
|
+
try {
|
|
145
|
+
const issue = readBeadsIssue(issueId, { projectRoot: options.projectRoot });
|
|
146
|
+
const state = extractWorkflowStateFromIssue(issue);
|
|
147
|
+
if (state) {
|
|
148
|
+
return state;
|
|
149
|
+
}
|
|
150
|
+
} catch (_error) {
|
|
151
|
+
// Fall through to comments-list fallback.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
comments = comments || secureExecFileSync('bd', ['comments', 'list', issueId], {
|
|
156
|
+
encoding: 'utf8',
|
|
157
|
+
cwd: options.projectRoot,
|
|
158
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
159
|
+
}).trim();
|
|
160
|
+
|
|
161
|
+
if (!comments) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return extractWorkflowStateFromComments(comments);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function loadStateFromBeads(options, projectRoot) {
|
|
169
|
+
if (options.comments) {
|
|
170
|
+
const state = extractWorkflowStateFromComments(options.comments);
|
|
171
|
+
if (state) {
|
|
172
|
+
return { state, source: 'beads' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (options.issue) {
|
|
177
|
+
const state = extractWorkflowStateFromIssue(options.issue);
|
|
178
|
+
if (state) {
|
|
179
|
+
return { state, source: 'beads' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (options.issueId) {
|
|
184
|
+
const state = readWorkflowStateFromBeads(options.issueId, {
|
|
185
|
+
comments: options.comments,
|
|
186
|
+
issue: options.issue,
|
|
187
|
+
projectRoot,
|
|
188
|
+
});
|
|
189
|
+
if (state) {
|
|
190
|
+
return { state, source: 'beads' };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function loadState(projectRoot, options = {}) {
|
|
198
|
+
if (!projectRoot) {
|
|
199
|
+
const beadsResult = loadStateFromBeads(options, null);
|
|
200
|
+
return beadsResult || { state: null, source: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (options.preferBeads && (options.comments || options.issue || options.issueId)) {
|
|
204
|
+
try {
|
|
205
|
+
const beadsResult = loadStateFromBeads(options, projectRoot);
|
|
206
|
+
if (beadsResult) {
|
|
207
|
+
return beadsResult;
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (typeof options.onBeadsError === 'function') {
|
|
211
|
+
options.onBeadsError(error);
|
|
212
|
+
}
|
|
213
|
+
// Fall through to the on-disk state file when Beads cannot be read.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
|
|
218
|
+
if (fs.existsSync(statePath)) {
|
|
219
|
+
try {
|
|
220
|
+
const raw = fs.readFileSync(statePath, 'utf8');
|
|
221
|
+
return { state: readWorkflowState(raw), source: 'file' };
|
|
222
|
+
} catch (_parseError) {
|
|
223
|
+
// File is malformed — fall through to Beads fallback
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const beadsResult = loadStateFromBeads(options, projectRoot);
|
|
228
|
+
if (beadsResult) {
|
|
229
|
+
return beadsResult;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { state: null, source: null };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function saveState(projectRoot, state) {
|
|
236
|
+
if (!projectRoot || typeof projectRoot !== 'string') {
|
|
237
|
+
throw new Error('saveState requires a valid projectRoot path');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const normalized = serializeWorkflowState(state);
|
|
241
|
+
const json = JSON.stringify(normalized, null, 2);
|
|
242
|
+
const tmpPath = path.join(projectRoot, `${WORKFLOW_STATE_FILENAME}.tmp`);
|
|
243
|
+
const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
|
|
244
|
+
|
|
245
|
+
fs.writeFileSync(tmpPath, json, 'utf8');
|
|
246
|
+
fs.renameSync(tmpPath, statePath);
|
|
247
|
+
|
|
248
|
+
return normalized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function initializeState(projectRoot, classification, firstStage) {
|
|
252
|
+
if (!WORKFLOW_CLASSIFICATIONS.includes(classification)) {
|
|
253
|
+
throw new Error(`Invalid classification: ${classification}. Expected one of: ${WORKFLOW_CLASSIFICATIONS.join(', ')}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const workflowPath = getWorkflowPath(classification);
|
|
257
|
+
const currentStage = firstStage || workflowPath[0];
|
|
258
|
+
|
|
259
|
+
const state = {
|
|
260
|
+
schemaVersion: WORKFLOW_STATE_SCHEMA_VERSION,
|
|
261
|
+
currentStage,
|
|
262
|
+
completedStages: [],
|
|
263
|
+
skippedStages: [],
|
|
264
|
+
workflowDecisions: {
|
|
265
|
+
classification,
|
|
266
|
+
reason: 'initialized',
|
|
267
|
+
userOverride: false,
|
|
268
|
+
overrides: [],
|
|
269
|
+
},
|
|
270
|
+
parallelTracks: [],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
return saveState(projectRoot, state);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function transitionStage(projectRoot, toStage, options = {}) {
|
|
277
|
+
const targetStage = normalizeStageId(toStage);
|
|
278
|
+
if (!targetStage) {
|
|
279
|
+
throw new Error(`Invalid target stage: ${toStage}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const { state: currentState } = loadState(projectRoot, options);
|
|
283
|
+
if (!currentState) {
|
|
284
|
+
throw new Error('No workflow state found. Initialize state first with initializeState().');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const previousState = JSON.parse(JSON.stringify(currentState));
|
|
288
|
+
const allowed = getAllowedTransitionsForWorkflowState(currentState);
|
|
289
|
+
|
|
290
|
+
if (!allowed.includes(targetStage)) {
|
|
291
|
+
if (!options.override) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Transition from ${currentState.currentStage} to ${targetStage} is not allowed. ` +
|
|
294
|
+
`Allowed transitions: ${allowed.join(', ') || 'none'}. Provide an override to force.`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const completedStages = [...currentState.completedStages];
|
|
300
|
+
if (!completedStages.includes(currentState.currentStage)) {
|
|
301
|
+
completedStages.push(currentState.currentStage);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const overrides = [...(currentState.workflowDecisions.overrides || [])];
|
|
305
|
+
if (options.override) {
|
|
306
|
+
overrides.push({
|
|
307
|
+
type: options.override.type || 'manual',
|
|
308
|
+
fromStage: currentState.currentStage,
|
|
309
|
+
toStage: targetStage,
|
|
310
|
+
reason: options.override.reason || '',
|
|
311
|
+
actor: options.override.actor || 'unknown',
|
|
312
|
+
userOverride: true,
|
|
313
|
+
recordedAt: new Date().toISOString(),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const newStateInput = {
|
|
318
|
+
schemaVersion: currentState.schemaVersion || WORKFLOW_STATE_SCHEMA_VERSION,
|
|
319
|
+
currentStage: targetStage,
|
|
320
|
+
completedStages,
|
|
321
|
+
skippedStages: currentState.skippedStages || [],
|
|
322
|
+
workflowDecisions: {
|
|
323
|
+
...currentState.workflowDecisions,
|
|
324
|
+
overrides,
|
|
325
|
+
userOverride: overrides.length > 0,
|
|
326
|
+
},
|
|
327
|
+
parallelTracks: currentState.parallelTracks || [],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const newState = saveState(projectRoot, newStateInput);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
previousState,
|
|
334
|
+
newState,
|
|
335
|
+
transitioned: true,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
WORKFLOW_STATE_FILENAME,
|
|
341
|
+
extractWorkflowStateFromComments,
|
|
342
|
+
extractWorkflowStateFromIssue,
|
|
343
|
+
initializeState,
|
|
344
|
+
loadState,
|
|
345
|
+
readBeadsIssue,
|
|
346
|
+
readWorkflowStateFromBeads,
|
|
347
|
+
saveState,
|
|
348
|
+
transitionStage,
|
|
349
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-workflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "7-stage TDD workflow for ALL AI coding agents (Claude, Cursor, Cline, OpenCode, Copilot, Kilo Code, Roo Code, Codex)",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forge": "bin/forge.js",
|
|
7
|
+
"forge-workflow": "bin/forge.js",
|
|
7
8
|
"forge-preflight": "bin/forge-preflight.js"
|
|
8
9
|
},
|
|
9
10
|
"workspaces": [
|
|
@@ -14,16 +15,23 @@
|
|
|
14
15
|
"test:setup": "bash test-env/automation/setup-fixtures.sh",
|
|
15
16
|
"test:all": "bun run test:setup && bun test",
|
|
16
17
|
"test:coverage": "c8 --check-coverage bun test",
|
|
18
|
+
"test:ci:shard": "node scripts/test-ci-shard.js",
|
|
17
19
|
"typecheck": "echo 'No TypeScript in project - skipping type check'",
|
|
18
20
|
"lint": "eslint . --max-warnings 0",
|
|
19
|
-
"check": "
|
|
21
|
+
"check": "node scripts/validate.js",
|
|
20
22
|
"prepare": "lefthook install || echo 'Note: lefthook not installed. Git hooks disabled. Run: bun add -d lefthook'",
|
|
21
23
|
"setup": "node ./bin/forge.js setup",
|
|
22
24
|
"help": "node ./bin/forge.js --help",
|
|
23
25
|
"validate:yaml": "find . \\( -name '*.yml' -o -name '*.yaml' \\) | grep -v node_modules | while IFS= read -r file; do echo \"Checking $file...\" && bun x js-yaml \"$file\" > /dev/null || exit 1; done",
|
|
24
26
|
"test:mutation": "npx stryker run",
|
|
25
|
-
"test:benchmark": "node scripts/benchmark.js",
|
|
26
|
-
"test:
|
|
27
|
+
"test:benchmark": "node scripts/benchmark.js --output test-results/benchmark-results.json --profile-dir test-results/benchmark-profiles",
|
|
28
|
+
"test:benchmark:full": "node scripts/benchmark.js --output test-results/benchmark-full-results.json --profile-dir test-results/benchmark-full-profiles --group full-suite --group validate",
|
|
29
|
+
"test:full:parallel": "node scripts/test-full-suite.js",
|
|
30
|
+
"test:dashboard": "node scripts/test-dashboard.js",
|
|
31
|
+
"test:profile": "node scripts/test-profile.js --input-dir test-results --output test-results/test-profile.json --label local",
|
|
32
|
+
"test:randomized": "bun test --randomize",
|
|
33
|
+
"test:rerun": "bun test --rerun-each 2",
|
|
34
|
+
"agentic:sync": "node scripts/sync-agentic-workflow.js"
|
|
27
35
|
},
|
|
28
36
|
"peerDependencies": {
|
|
29
37
|
"lefthook": "^1.0.0"
|