forge-workflow 0.0.6 → 0.0.7
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/.cursorrules +149 -0
- package/bin/forge.js +36 -3
- package/lib/agents/README.md +46 -1
- package/lib/agents/cline.plugin.json +11 -4
- package/lib/agents/codex.plugin.json +2 -2
- package/lib/agents/copilot.plugin.json +5 -5
- package/lib/agents/cursor.plugin.json +1 -1
- package/lib/agents/kilocode.plugin.json +1 -1
- package/lib/agents/opencode.plugin.json +7 -4
- package/lib/agents/roo.plugin.json +10 -3
- package/lib/agents-config.js +127 -79
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +40 -1
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +26 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/setup.js +4295 -0
- package/lib/commands/ship.js +20 -0
- package/lib/commands/status.js +210 -44
- package/lib/commands/sync.js +17 -1
- package/lib/commands/validate.js +13 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/frontmatter.js +79 -0
- package/lib/husky-migration.js +113 -12
- package/lib/lefthook-check.js +27 -6
- package/lib/plugin-manager.js +225 -72
- package/lib/project-discovery.js +39 -5
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/ui-utils.js +43 -0
- package/lib/validation-utils.js +163 -0
- package/lib/workflow/enforce-stage.js +179 -0
- package/lib/workflow/stages.js +201 -0
- package/lib/workflow/state.js +332 -0
- package/opencode.json +67 -0
- package/package.json +15 -5
- package/scripts/beads-context.sh +12 -4
- package/scripts/check-agents.js +103 -0
- package/scripts/pr-coordinator.sh +71 -21
- package/scripts/smart-status.sh +21 -11
- package/scripts/sync-commands.js +49 -20
- package/scripts/test.js +16 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { repairWorkflowRuntimeAssets } = require('../commands/setup');
|
|
7
|
+
const { checkRuntimeHealth } = require('../runtime-health');
|
|
8
|
+
const { normalizeStageId } = require('./stages');
|
|
9
|
+
const {
|
|
10
|
+
getAllowedTransitionsForWorkflowState,
|
|
11
|
+
normalizeOverrideRecord,
|
|
12
|
+
readWorkflowState,
|
|
13
|
+
} = require('./state');
|
|
14
|
+
|
|
15
|
+
const WORKFLOW_STATE_FILENAME = '.forge-state.json';
|
|
16
|
+
|
|
17
|
+
function getOverrideInput(flags = {}) {
|
|
18
|
+
if (Object.hasOwn(flags, 'overrideStage')) {
|
|
19
|
+
return flags.overrideStage;
|
|
20
|
+
}
|
|
21
|
+
if (Object.hasOwn(flags, '--override-stage')) {
|
|
22
|
+
return flags['--override-stage'];
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getCliFlagValue(flagName, args = []) {
|
|
28
|
+
if (!Array.isArray(args)) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const prefix = `${flagName}=`;
|
|
33
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
34
|
+
const arg = args[index];
|
|
35
|
+
if (arg === flagName) {
|
|
36
|
+
return index + 1 < args.length ? args[index + 1] : null;
|
|
37
|
+
}
|
|
38
|
+
if (typeof arg === 'string' && arg.startsWith(prefix)) {
|
|
39
|
+
return arg.slice(prefix.length);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveOverrideInput(flags = {}, args = []) {
|
|
47
|
+
return getOverrideInput(flags) || getCliFlagValue('--override-stage', args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseOverride(flags = {}, args = []) {
|
|
51
|
+
const input = resolveOverrideInput(flags, args);
|
|
52
|
+
if (!input) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = typeof input === 'string' ? JSON.parse(input) : input;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(`Invalid JSON in override-stage flag: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
return normalizeOverrideRecord(parsed);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readWorkflowStateFile(projectRoot) {
|
|
66
|
+
if (!projectRoot) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const statePath = path.join(projectRoot, WORKFLOW_STATE_FILENAME);
|
|
71
|
+
if (!fs.existsSync(statePath)) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return fs.readFileSync(statePath, 'utf8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveWorkflowStateInput(workflowState, flags = {}, args = [], projectRoot) {
|
|
79
|
+
return workflowState
|
|
80
|
+
|| flags.workflowState
|
|
81
|
+
|| flags['--workflow-state']
|
|
82
|
+
|| getCliFlagValue('--workflow-state', args)
|
|
83
|
+
|| readWorkflowStateFile(projectRoot);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readWorkflowStateInput(input) {
|
|
87
|
+
if (!input) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return readWorkflowState(input);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatDiagnostics(diagnostics = []) {
|
|
95
|
+
return diagnostics
|
|
96
|
+
.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`)
|
|
97
|
+
.join('; ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function enforceStageEntry({ commandName, args = [], flags = {}, projectRoot, workflowState, health, repairRuntime } = {}) {
|
|
101
|
+
const stageId = normalizeStageId(commandName);
|
|
102
|
+
if (!stageId) {
|
|
103
|
+
return { allowed: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (projectRoot) {
|
|
107
|
+
repairWorkflowRuntimeAssets(projectRoot);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let runtimeHealth = health || checkRuntimeHealth(projectRoot);
|
|
111
|
+
if (runtimeHealth.hardStop && typeof repairRuntime === 'function') {
|
|
112
|
+
const repairedHealth = await repairRuntime({
|
|
113
|
+
commandName,
|
|
114
|
+
flags,
|
|
115
|
+
projectRoot,
|
|
116
|
+
workflowState,
|
|
117
|
+
health: runtimeHealth,
|
|
118
|
+
});
|
|
119
|
+
if (repairedHealth) {
|
|
120
|
+
runtimeHealth = repairedHealth;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (runtimeHealth.hardStop) {
|
|
124
|
+
throw new Error(`Stage ${stageId} blocked by runtime prerequisites: ${formatDiagnostics(runtimeHealth.diagnostics)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stateInput = resolveWorkflowStateInput(workflowState, flags, args, projectRoot);
|
|
128
|
+
const currentState = readWorkflowStateInput(stateInput);
|
|
129
|
+
if (!currentState) {
|
|
130
|
+
if (stageId === 'plan') {
|
|
131
|
+
return { allowed: true, stage: stageId, workflowState: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Stage ${stageId} requires authoritative workflow state. ` +
|
|
136
|
+
`Provide --workflow-state or restore ${WORKFLOW_STATE_FILENAME} before continuing.`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const currentStage = currentState.currentStage;
|
|
141
|
+
const classification = currentState.workflowDecisions?.classification;
|
|
142
|
+
if (!currentStage || !classification || stageId === currentStage) {
|
|
143
|
+
return { allowed: true, stage: stageId, workflowState: currentState };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const allowedTransitions = getAllowedTransitionsForWorkflowState(currentState);
|
|
147
|
+
if (allowedTransitions.includes(stageId)) {
|
|
148
|
+
return { allowed: true, stage: stageId, workflowState: currentState };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const override = parseOverride(flags, args);
|
|
152
|
+
if (!override) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Stage ${stageId} is blocked from ${currentStage}. ` +
|
|
155
|
+
`Provide an explicit override payload via overrideStage or --override-stage.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (override.fromStage !== currentStage || override.toStage !== stageId) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Stage override does not match workflow state. Expected ${currentStage} -> ${stageId}.`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
allowed: true,
|
|
167
|
+
stage: stageId,
|
|
168
|
+
workflowState: currentState,
|
|
169
|
+
override,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
enforceStageEntry,
|
|
175
|
+
getCliFlagValue,
|
|
176
|
+
parseOverride,
|
|
177
|
+
resolveWorkflowStateInput,
|
|
178
|
+
readWorkflowStateFile,
|
|
179
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const WORKFLOW_CLASSIFICATIONS = Object.freeze([
|
|
4
|
+
'critical',
|
|
5
|
+
'standard',
|
|
6
|
+
'refactor',
|
|
7
|
+
'simple',
|
|
8
|
+
'hotfix',
|
|
9
|
+
'docs',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const STAGE_IDS = Object.freeze([
|
|
13
|
+
'plan',
|
|
14
|
+
'dev',
|
|
15
|
+
'validate',
|
|
16
|
+
'ship',
|
|
17
|
+
'review',
|
|
18
|
+
'premerge',
|
|
19
|
+
'verify',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const STAGE_LABELS = Object.freeze({
|
|
23
|
+
plan: 'Plan',
|
|
24
|
+
dev: 'Dev',
|
|
25
|
+
validate: 'Validate',
|
|
26
|
+
ship: 'Ship',
|
|
27
|
+
review: 'Review',
|
|
28
|
+
premerge: 'Premerge',
|
|
29
|
+
verify: 'Verify',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const STAGE_COMMANDS = Object.freeze({
|
|
33
|
+
plan: '/plan',
|
|
34
|
+
dev: '/dev',
|
|
35
|
+
validate: '/validate',
|
|
36
|
+
ship: '/ship',
|
|
37
|
+
review: '/review',
|
|
38
|
+
premerge: '/premerge',
|
|
39
|
+
verify: '/verify',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const WORKFLOW_STAGE_MATRIX = Object.freeze({
|
|
43
|
+
critical: Object.freeze(['plan', 'dev', 'validate', 'ship', 'review', 'premerge', 'verify']),
|
|
44
|
+
standard: Object.freeze(['plan', 'dev', 'validate', 'ship', 'review', 'premerge']),
|
|
45
|
+
refactor: Object.freeze(['plan', 'dev', 'validate', 'ship', 'premerge']),
|
|
46
|
+
simple: Object.freeze(['dev', 'validate', 'ship']),
|
|
47
|
+
hotfix: Object.freeze(['dev', 'validate', 'ship']),
|
|
48
|
+
// Docs-only work intentionally reuses /verify as a pre-ship content check to
|
|
49
|
+
// keep the existing lightweight docs path, even though /verify is post-merge
|
|
50
|
+
// everywhere else in the full workflow.
|
|
51
|
+
docs: Object.freeze(['verify', 'ship']),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const WORKFLOW_TERMINAL_STAGES = Object.freeze(Object.entries(WORKFLOW_STAGE_MATRIX).reduce((accumulator, [classification, path]) => {
|
|
55
|
+
accumulator[classification] = path.at(-1);
|
|
56
|
+
return accumulator;
|
|
57
|
+
}, {}));
|
|
58
|
+
|
|
59
|
+
function normalizeClassification(classification) {
|
|
60
|
+
return typeof classification === 'string' && Object.hasOwn(WORKFLOW_STAGE_MATRIX, classification)
|
|
61
|
+
? classification
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeStageId(stageId) {
|
|
66
|
+
return typeof stageId === 'string' && Object.hasOwn(STAGE_LABELS, stageId)
|
|
67
|
+
? stageId
|
|
68
|
+
: null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isCanonicalStageId(stageId) {
|
|
72
|
+
return normalizeStageId(stageId) !== null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getWorkflowPath(classification) {
|
|
76
|
+
const normalized = normalizeClassification(classification);
|
|
77
|
+
return normalized ? WORKFLOW_STAGE_MATRIX[normalized] : Object.freeze([]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getStageWorkflow(stageId, classification) {
|
|
81
|
+
const normalizedStage = normalizeStageId(stageId);
|
|
82
|
+
const normalizedClassification = normalizeClassification(classification);
|
|
83
|
+
|
|
84
|
+
if (!normalizedStage || !normalizedClassification) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const path = WORKFLOW_STAGE_MATRIX[normalizedClassification];
|
|
89
|
+
const order = path.indexOf(normalizedStage);
|
|
90
|
+
|
|
91
|
+
if (order === -1) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const nextStages = order < path.length - 1 ? Object.freeze([path[order + 1]]) : Object.freeze([]);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
classification: normalizedClassification,
|
|
99
|
+
order: order + 1,
|
|
100
|
+
nextStages,
|
|
101
|
+
terminal: order === path.length - 1,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getAllowedTransitions(stageId, classification) {
|
|
106
|
+
const workflow = getStageWorkflow(stageId, classification);
|
|
107
|
+
return workflow ? workflow.nextStages : Object.freeze([]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function canTransition(fromStageId, toStageId, classification) {
|
|
111
|
+
const normalizedClassification = normalizeClassification(classification);
|
|
112
|
+
const fromStage = normalizeStageId(fromStageId);
|
|
113
|
+
const toStage = normalizeStageId(toStageId);
|
|
114
|
+
|
|
115
|
+
if (!normalizedClassification || !fromStage || !toStage) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const path = WORKFLOW_STAGE_MATRIX[normalizedClassification];
|
|
120
|
+
const fromIndex = path.indexOf(fromStage);
|
|
121
|
+
if (fromIndex === -1 || fromIndex === path.length - 1) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return path[fromIndex + 1] === toStage;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isTerminalStage(stageId, classification) {
|
|
129
|
+
const workflow = getStageWorkflow(stageId, classification);
|
|
130
|
+
return workflow ? workflow.terminal : false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertTransitionAllowed(fromStageId, toStageId, classification) {
|
|
134
|
+
if (canTransition(fromStageId, toStageId, classification)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fromStage = normalizeStageId(fromStageId) || String(fromStageId);
|
|
139
|
+
const toStage = normalizeStageId(toStageId) || String(toStageId);
|
|
140
|
+
const normalizedClassification = normalizeClassification(classification) || 'unknown';
|
|
141
|
+
const allowed = getAllowedTransitions(fromStageId, classification);
|
|
142
|
+
const suffix = allowed.length > 0 ? ` Allowed next stages: ${allowed.join(', ')}.` : '';
|
|
143
|
+
|
|
144
|
+
throw new Error(`Invalid workflow transition: ${fromStage} -> ${toStage} for ${normalizedClassification} workflow.${suffix}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const STAGE_MODEL = Object.freeze(STAGE_IDS.reduce((accumulator, stageId) => {
|
|
148
|
+
const workflows = {};
|
|
149
|
+
|
|
150
|
+
for (const classification of WORKFLOW_CLASSIFICATIONS) {
|
|
151
|
+
const workflow = getStageWorkflow(stageId, classification);
|
|
152
|
+
if (!workflow) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
workflows[classification] = Object.freeze({
|
|
157
|
+
order: workflow.order,
|
|
158
|
+
nextStages: workflow.nextStages,
|
|
159
|
+
terminal: workflow.terminal,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
accumulator[stageId] = Object.freeze({
|
|
164
|
+
id: stageId,
|
|
165
|
+
label: STAGE_LABELS[stageId],
|
|
166
|
+
command: STAGE_COMMANDS[stageId],
|
|
167
|
+
workflows: Object.freeze(workflows),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return accumulator;
|
|
171
|
+
}, {}));
|
|
172
|
+
|
|
173
|
+
const STAGE_TRANSITIONS = Object.freeze(STAGE_IDS.reduce((accumulator, stageId) => {
|
|
174
|
+
accumulator[stageId] = Object.freeze(
|
|
175
|
+
WORKFLOW_CLASSIFICATIONS.reduce((workflowMap, classification) => {
|
|
176
|
+
workflowMap[classification] = getAllowedTransitions(stageId, classification);
|
|
177
|
+
return workflowMap;
|
|
178
|
+
}, {}),
|
|
179
|
+
);
|
|
180
|
+
return accumulator;
|
|
181
|
+
}, {}));
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
WORKFLOW_CLASSIFICATIONS,
|
|
185
|
+
STAGE_IDS,
|
|
186
|
+
STAGE_LABELS,
|
|
187
|
+
STAGE_COMMANDS,
|
|
188
|
+
WORKFLOW_STAGE_MATRIX,
|
|
189
|
+
WORKFLOW_TERMINAL_STAGES,
|
|
190
|
+
STAGE_TRANSITIONS,
|
|
191
|
+
STAGE_MODEL,
|
|
192
|
+
normalizeClassification,
|
|
193
|
+
normalizeStageId,
|
|
194
|
+
isCanonicalStageId,
|
|
195
|
+
getWorkflowPath,
|
|
196
|
+
getStageWorkflow,
|
|
197
|
+
getAllowedTransitions,
|
|
198
|
+
canTransition,
|
|
199
|
+
isTerminalStage,
|
|
200
|
+
assertTransitionAllowed,
|
|
201
|
+
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
WORKFLOW_CLASSIFICATIONS,
|
|
5
|
+
STAGE_IDS,
|
|
6
|
+
STAGE_MODEL,
|
|
7
|
+
getWorkflowPath,
|
|
8
|
+
normalizeStageId,
|
|
9
|
+
} = require('./stages.js');
|
|
10
|
+
|
|
11
|
+
const WORKFLOW_STATE_SCHEMA_VERSION = 2;
|
|
12
|
+
const LEGACY_STANDARD_VERIFY = Symbol('legacy-standard-verify');
|
|
13
|
+
const LEGACY_STANDARD_WORKFLOW_PATH = Object.freeze([
|
|
14
|
+
'plan',
|
|
15
|
+
'dev',
|
|
16
|
+
'validate',
|
|
17
|
+
'ship',
|
|
18
|
+
'review',
|
|
19
|
+
'premerge',
|
|
20
|
+
'verify',
|
|
21
|
+
]);
|
|
22
|
+
const LEGACY_WORKFLOW_STATE_OPTIONS = Object.freeze({
|
|
23
|
+
allowLegacyDefaultClassification: true,
|
|
24
|
+
allowLegacyStandardVerify: true,
|
|
25
|
+
allowLegacyOverrideRecords: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function normalizeString(value) {
|
|
29
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeBoolean(value) {
|
|
33
|
+
return value === true || value === 1 || value === '1' || value === 'true';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeStageList(stages) {
|
|
37
|
+
if (!Array.isArray(stages)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normalized = [];
|
|
42
|
+
for (const stage of stages) {
|
|
43
|
+
const stageId = normalizeStageId(stage);
|
|
44
|
+
if (!stageId || normalized.includes(stageId)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
normalized.push(stageId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeParallelTrack(track) {
|
|
54
|
+
if (!track || typeof track !== 'object' || Array.isArray(track)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalized = {
|
|
59
|
+
name: normalizeString(track.name),
|
|
60
|
+
agent: normalizeString(track.agent),
|
|
61
|
+
status: normalizeString(track.status),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (track.worktree && typeof track.worktree === 'object' && !Array.isArray(track.worktree)) {
|
|
65
|
+
normalized.worktree = {
|
|
66
|
+
path: normalizeString(track.worktree.path),
|
|
67
|
+
branch: normalizeString(track.worktree.branch),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeOverrideRecord(override = {}, options = {}) {
|
|
75
|
+
const type = normalizeString(override.type || override.kind) || 'manual';
|
|
76
|
+
const fromStage = override.fromStage == null ? null : normalizeStageId(override.fromStage);
|
|
77
|
+
const toStage = override.toStage == null ? null : normalizeStageId(override.toStage);
|
|
78
|
+
const reason = normalizeString(override.reason);
|
|
79
|
+
const actor = normalizeString(override.actor) || 'unknown';
|
|
80
|
+
const recordedAt = normalizeString(override.recordedAt || override.at || override.timestamp) || new Date().toISOString();
|
|
81
|
+
|
|
82
|
+
if (!type) {
|
|
83
|
+
throw new Error('Override record must include a type');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!options.allowLegacyOverrideRecords && (!fromStage || !toStage || !reason)) {
|
|
87
|
+
throw new Error('Override record must include fromStage, toStage, and a non-empty reason');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
type,
|
|
92
|
+
fromStage,
|
|
93
|
+
toStage,
|
|
94
|
+
reason,
|
|
95
|
+
actor,
|
|
96
|
+
userOverride: normalizeBoolean(override.userOverride),
|
|
97
|
+
recordedAt,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeWorkflowOverrides(value = {}, options = {}) {
|
|
102
|
+
const overrides = Array.isArray(value.overrides)
|
|
103
|
+
? value.overrides.map(override => normalizeOverrideRecord(override, options))
|
|
104
|
+
: [];
|
|
105
|
+
const userOverride = normalizeBoolean(value.userOverride);
|
|
106
|
+
|
|
107
|
+
if (userOverride && overrides.length === 0) {
|
|
108
|
+
throw new Error('workflowDecisions.userOverride requires at least one override record');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
overrides,
|
|
113
|
+
userOverride: overrides.length > 0 || userOverride,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildWorkflowDecisions(classification, value, overrideState) {
|
|
118
|
+
return {
|
|
119
|
+
classification,
|
|
120
|
+
reason: normalizeString(value.reason),
|
|
121
|
+
userOverride: overrideState.userOverride,
|
|
122
|
+
overrides: overrideState.overrides,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeWorkflowDecisions(value = {}, options = {}) {
|
|
127
|
+
const hasClassification = value && typeof value === 'object'
|
|
128
|
+
? Object.hasOwn(value, 'classification')
|
|
129
|
+
: false;
|
|
130
|
+
const classification = normalizeString(value.classification);
|
|
131
|
+
const overrideState = normalizeWorkflowOverrides(value, options);
|
|
132
|
+
|
|
133
|
+
if (!classification) {
|
|
134
|
+
if (options.allowLegacyDefaultClassification && !hasClassification) {
|
|
135
|
+
return buildWorkflowDecisions('standard', value, overrideState);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
'Workflow state is missing a classification field. Delete .forge-state.json to reset, or add "classification": "standard" manually.'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!WORKFLOW_CLASSIFICATIONS.includes(classification)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Invalid workflow classification: ${value.classification}. Expected one of: ${WORKFLOW_CLASSIFICATIONS.join(', ')}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return buildWorkflowDecisions(classification, value, overrideState);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function shouldUseLegacyStandardVerifyPath(classification, input, options = {}) {
|
|
153
|
+
const legacyBySchema = input.schemaVersion == null
|
|
154
|
+
? options.treatMissingSchemaAsCurrent !== true
|
|
155
|
+
: input.schemaVersion < WORKFLOW_STATE_SCHEMA_VERSION;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
options.allowLegacyStandardVerify === true &&
|
|
159
|
+
classification === 'standard' &&
|
|
160
|
+
(
|
|
161
|
+
input[LEGACY_STANDARD_VERIFY] === true ||
|
|
162
|
+
legacyBySchema
|
|
163
|
+
)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertWorkflowPathTransition(previousStage, currentStage, workflowPath, classification) {
|
|
168
|
+
const previousIndex = workflowPath.indexOf(previousStage);
|
|
169
|
+
const currentIndex = workflowPath.indexOf(currentStage);
|
|
170
|
+
const allowed = previousIndex === -1 || previousIndex === workflowPath.length - 1
|
|
171
|
+
? []
|
|
172
|
+
: [workflowPath[previousIndex + 1]];
|
|
173
|
+
|
|
174
|
+
if (previousIndex !== -1 && currentIndex === previousIndex + 1) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const suffix = allowed.length > 0 ? ` Allowed next stages: ${allowed.join(', ')}.` : '';
|
|
179
|
+
throw new Error(
|
|
180
|
+
`Invalid workflow transition: ${previousStage} -> ${currentStage} for ${classification} workflow.${suffix}`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeWorkflowState(input = {}, options = {}) {
|
|
185
|
+
const currentStage = normalizeStageId(input.currentStage);
|
|
186
|
+
if (!currentStage) {
|
|
187
|
+
throw new Error(`Invalid current stage: ${input.currentStage}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const workflowDecisions = normalizeWorkflowDecisions(input.workflowDecisions || {}, options);
|
|
191
|
+
const useLegacyStandardVerify = shouldUseLegacyStandardVerifyPath(
|
|
192
|
+
workflowDecisions.classification,
|
|
193
|
+
input,
|
|
194
|
+
options
|
|
195
|
+
);
|
|
196
|
+
const workflowPath = useLegacyStandardVerify
|
|
197
|
+
? LEGACY_STANDARD_WORKFLOW_PATH
|
|
198
|
+
: getWorkflowPath(workflowDecisions.classification);
|
|
199
|
+
|
|
200
|
+
const previousStage = input.previousStage == null ? null : normalizeStageId(input.previousStage);
|
|
201
|
+
if (input.previousStage != null && !previousStage) {
|
|
202
|
+
throw new Error(`Invalid previous stage: ${input.previousStage}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!workflowPath.includes(currentStage)) {
|
|
206
|
+
throw new Error(`Stage ${currentStage} is not valid for ${workflowDecisions.classification} workflow`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (previousStage) {
|
|
210
|
+
assertWorkflowPathTransition(previousStage, currentStage, workflowPath, workflowDecisions.classification);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const completedStages = normalizeStageList(input.completedStages);
|
|
214
|
+
const skippedStages = normalizeStageList(input.skippedStages);
|
|
215
|
+
const invalidCompletedStage = completedStages.find(stage => !workflowPath.includes(stage));
|
|
216
|
+
if (invalidCompletedStage) {
|
|
217
|
+
throw new Error(`Completed stage ${invalidCompletedStage} is not valid for ${workflowDecisions.classification} workflow`);
|
|
218
|
+
}
|
|
219
|
+
const invalidSkippedStage = skippedStages.find(stage => !workflowPath.includes(stage));
|
|
220
|
+
if (invalidSkippedStage) {
|
|
221
|
+
throw new Error(`Skipped stage ${invalidSkippedStage} is not valid for ${workflowDecisions.classification} workflow`);
|
|
222
|
+
}
|
|
223
|
+
const parallelTracks = Array.isArray(input.parallelTracks)
|
|
224
|
+
? input.parallelTracks.map(normalizeParallelTrack).filter(Boolean)
|
|
225
|
+
: [];
|
|
226
|
+
|
|
227
|
+
const payload = {
|
|
228
|
+
schemaVersion: useLegacyStandardVerify ? 1 : WORKFLOW_STATE_SCHEMA_VERSION,
|
|
229
|
+
currentStage,
|
|
230
|
+
completedStages,
|
|
231
|
+
skippedStages,
|
|
232
|
+
workflowDecisions,
|
|
233
|
+
parallelTracks,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (useLegacyStandardVerify) {
|
|
237
|
+
Object.defineProperty(payload, LEGACY_STANDARD_VERIFY, {
|
|
238
|
+
value: true,
|
|
239
|
+
enumerable: false,
|
|
240
|
+
configurable: false,
|
|
241
|
+
writable: false,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return payload;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function serializeWorkflowState(input) {
|
|
249
|
+
return normalizeWorkflowState(input, {
|
|
250
|
+
...LEGACY_WORKFLOW_STATE_OPTIONS,
|
|
251
|
+
treatMissingSchemaAsCurrent: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function readWorkflowState(source) {
|
|
256
|
+
if (source == null) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (typeof source === 'string') {
|
|
261
|
+
try {
|
|
262
|
+
return normalizeWorkflowState(JSON.parse(source), LEGACY_WORKFLOW_STATE_OPTIONS);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw new Error(`Failed to parse workflow state: ${error.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (typeof source !== 'object') {
|
|
269
|
+
throw new Error('Workflow state source must be a string or object');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (source.schemaVersion === WORKFLOW_STATE_SCHEMA_VERSION || source.currentStage) {
|
|
273
|
+
return normalizeWorkflowState(source, LEGACY_WORKFLOW_STATE_OPTIONS);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (source.workflowState) {
|
|
277
|
+
return normalizeWorkflowState(source.workflowState, LEGACY_WORKFLOW_STATE_OPTIONS);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (source.metadata && source.metadata.workflowState) {
|
|
281
|
+
return normalizeWorkflowState(source.metadata.workflowState, LEGACY_WORKFLOW_STATE_OPTIONS);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return normalizeWorkflowState(source, LEGACY_WORKFLOW_STATE_OPTIONS);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function writeWorkflowState(input) {
|
|
288
|
+
const payload = serializeWorkflowState(input);
|
|
289
|
+
|
|
290
|
+
return JSON.stringify(payload, null, 2);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getWorkflowPathForState(workflowState) {
|
|
294
|
+
if (!workflowState || typeof workflowState !== 'object') {
|
|
295
|
+
return Object.freeze([]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return workflowState[LEGACY_STANDARD_VERIFY]
|
|
299
|
+
? LEGACY_STANDARD_WORKFLOW_PATH
|
|
300
|
+
: getWorkflowPath(workflowState.workflowDecisions?.classification);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getAllowedTransitionsForWorkflowState(workflowState) {
|
|
304
|
+
const currentStage = normalizeStageId(workflowState?.currentStage);
|
|
305
|
+
if (!currentStage) {
|
|
306
|
+
return Object.freeze([]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const workflowPath = getWorkflowPathForState(workflowState);
|
|
310
|
+
const currentIndex = workflowPath.indexOf(currentStage);
|
|
311
|
+
if (currentIndex === -1 || currentIndex === workflowPath.length - 1) {
|
|
312
|
+
return Object.freeze([]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return Object.freeze([workflowPath[currentIndex + 1]]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
getAllowedTransitionsForWorkflowState,
|
|
320
|
+
getWorkflowPathForState,
|
|
321
|
+
LEGACY_STANDARD_VERIFY,
|
|
322
|
+
WORKFLOW_STATE_SCHEMA_VERSION,
|
|
323
|
+
WORKFLOW_CLASSIFICATIONS,
|
|
324
|
+
normalizeOverrideRecord,
|
|
325
|
+
normalizeWorkflowDecisions,
|
|
326
|
+
normalizeWorkflowState,
|
|
327
|
+
serializeWorkflowState,
|
|
328
|
+
readWorkflowState,
|
|
329
|
+
writeWorkflowState,
|
|
330
|
+
STAGE_IDS,
|
|
331
|
+
STAGE_MODEL,
|
|
332
|
+
};
|