forge-workflow 0.0.5 → 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/.claude/commands/dev.md +6 -1
- package/.claude/commands/plan.md +59 -14
- package/.claude/commands/premerge.md +10 -0
- package/.claude/commands/review.md +7 -1
- package/.claude/commands/ship.md +95 -47
- package/.claude/commands/status.md +42 -0
- package/.claude/commands/validate.md +7 -1
- package/.claude/commands/verify.md +52 -4
- package/.claude/rules/workflow.md +16 -0
- package/.claude/scripts/greptile-resolve.sh +32 -0
- package/.cline/workflows/dev.md +6 -1
- package/.cline/workflows/plan.md +59 -14
- package/.cline/workflows/premerge.md +10 -0
- package/.cline/workflows/review.md +7 -1
- package/.cline/workflows/ship.md +95 -47
- package/.cline/workflows/status.md +42 -0
- package/.cline/workflows/validate.md +7 -1
- package/.cline/workflows/verify.md +52 -4
- package/.codex/skills/dev/SKILL.md +6 -1
- package/.codex/skills/plan/SKILL.md +59 -14
- package/.codex/skills/premerge/SKILL.md +10 -0
- package/.codex/skills/review/SKILL.md +7 -1
- package/.codex/skills/ship/SKILL.md +95 -47
- package/.codex/skills/status/SKILL.md +42 -0
- package/.codex/skills/validate/SKILL.md +7 -1
- package/.codex/skills/verify/SKILL.md +52 -4
- package/.cursor/commands/dev.md +6 -1
- package/.cursor/commands/plan.md +59 -14
- package/.cursor/commands/premerge.md +10 -0
- package/.cursor/commands/review.md +7 -1
- package/.cursor/commands/ship.md +95 -47
- package/.cursor/commands/status.md +42 -0
- package/.cursor/commands/validate.md +7 -1
- package/.cursor/commands/verify.md +52 -4
- package/.cursorrules +149 -0
- package/.github/prompts/dev.prompt.md +6 -1
- package/.github/prompts/plan.prompt.md +59 -14
- package/.github/prompts/premerge.prompt.md +10 -0
- package/.github/prompts/review.prompt.md +7 -1
- package/.github/prompts/ship.prompt.md +95 -47
- package/.github/prompts/status.prompt.md +42 -0
- package/.github/prompts/validate.prompt.md +7 -1
- package/.github/prompts/verify.prompt.md +52 -4
- package/.kilocode/workflows/dev.md +6 -1
- package/.kilocode/workflows/plan.md +59 -14
- package/.kilocode/workflows/premerge.md +10 -0
- package/.kilocode/workflows/review.md +7 -1
- package/.kilocode/workflows/ship.md +95 -47
- package/.kilocode/workflows/status.md +42 -0
- package/.kilocode/workflows/validate.md +7 -1
- package/.kilocode/workflows/verify.md +52 -4
- package/.opencode/commands/dev.md +6 -1
- package/.opencode/commands/plan.md +59 -14
- package/.opencode/commands/premerge.md +10 -0
- package/.opencode/commands/review.md +7 -1
- package/.opencode/commands/ship.md +95 -47
- package/.opencode/commands/status.md +42 -0
- package/.opencode/commands/validate.md +7 -1
- package/.opencode/commands/verify.md +52 -4
- package/.roo/commands/dev.md +6 -1
- package/.roo/commands/plan.md +59 -14
- package/.roo/commands/premerge.md +10 -0
- package/.roo/commands/review.md +7 -1
- package/.roo/commands/ship.md +95 -47
- package/.roo/commands/status.md +42 -0
- package/.roo/commands/validate.md +7 -1
- package/.roo/commands/verify.md +52 -4
- package/AGENTS.md +97 -0
- package/CLAUDE.md +10 -0
- package/README.md +2 -2
- package/bin/forge-cmd.js +5 -1
- package/bin/forge-preflight.js +15 -2
- package/bin/forge.js +211 -9
- package/docs/ENHANCED_ONBOARDING.md +96 -86
- package/docs/ROADMAP.md +2 -2
- package/docs/TOOLCHAIN.md +23 -0
- package/docs/VALIDATION.md +1 -1
- package/lefthook.yml +11 -0
- 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 +129 -81
- package/lib/codex-skills.js +50 -0
- package/lib/commands/_registry.js +173 -0
- package/lib/commands/clean.js +181 -0
- package/lib/commands/commands-reset.js +147 -0
- package/lib/commands/dev.js +84 -0
- package/lib/commands/plan.js +18 -0
- package/lib/commands/push.js +196 -0
- package/lib/commands/recommend.js +1 -1
- 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 +71 -0
- package/lib/commands/team.js +37 -0
- package/lib/commands/test.js +207 -0
- package/lib/commands/validate.js +13 -0
- package/lib/commands/worktree.js +310 -0
- package/lib/detect-agent.js +38 -8
- package/lib/detection-utils.js +405 -0
- package/lib/docs-command.js +51 -0
- package/lib/docs-copy.js +50 -0
- package/lib/file-utils.js +260 -0
- package/lib/forge-context.js +42 -0
- package/lib/freshness-token.js +148 -0
- package/lib/frontmatter.js +79 -0
- package/lib/greptile-match.js +80 -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/reset.js +309 -0
- package/lib/runtime-health.js +305 -0
- package/lib/shell-utils.js +50 -0
- package/lib/task-ownership.js +117 -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 +16 -6
- package/scripts/beads-context.sh +165 -22
- package/scripts/beads-context.test.js +5 -1
- package/scripts/check-agents.js +103 -0
- package/scripts/check-forge-token.js +98 -0
- package/scripts/conflict-detect.sh +2 -2
- package/scripts/dep-guard.sh +6 -28
- package/scripts/file-index.sh +117 -23
- package/scripts/forge-team/index.sh +86 -0
- package/scripts/forge-team/lib/agent-prompt.sh +52 -0
- package/scripts/forge-team/lib/claim.sh +256 -0
- package/scripts/forge-team/lib/dashboard.sh +341 -0
- package/scripts/forge-team/lib/epic.sh +332 -0
- package/scripts/forge-team/lib/hooks.sh +253 -0
- package/scripts/forge-team/lib/identity.sh +235 -0
- package/scripts/forge-team/lib/sync-github.sh +317 -0
- package/scripts/forge-team/lib/verify.sh +284 -0
- package/scripts/forge-team/lib/workload.sh +296 -0
- package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
- package/scripts/forge-team/tests/claim.test.sh +179 -0
- package/scripts/forge-team/tests/dashboard.test.sh +170 -0
- package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
- package/scripts/forge-team/tests/epic.test.sh +176 -0
- package/scripts/forge-team/tests/hooks.test.sh +239 -0
- package/scripts/forge-team/tests/identity.test.sh +176 -0
- package/scripts/forge-team/tests/integration.test.sh +371 -0
- package/scripts/forge-team/tests/sync-github.test.sh +209 -0
- package/scripts/forge-team/tests/verify.test.sh +314 -0
- package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
- package/scripts/forge-team/tests/workload.test.sh +209 -0
- package/scripts/lib/eval-runner.js +39 -0
- package/scripts/lib/jsonl-lock.sh +48 -0
- package/scripts/lib/sanitize.sh +116 -0
- package/scripts/pr-coordinator.sh +756 -0
- package/scripts/smart-status.sh +58 -21
- package/scripts/sync-commands.js +49 -20
- package/scripts/sync-utils.sh +24 -29
- package/scripts/test.js +18 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a task list markdown file and validate file ownership.
|
|
5
|
+
* Within each wave, no two tasks may own the same file.
|
|
6
|
+
* Cross-wave ownership is allowed (sequential execution prevents conflicts).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} content - task list markdown content
|
|
9
|
+
* @returns {{ valid: boolean, violations: Array<{wave: number, task1: number, task2: number, file: string}> }}
|
|
10
|
+
*/
|
|
11
|
+
function validateOwnership(content) {
|
|
12
|
+
const lines = content.split('\n');
|
|
13
|
+
const violations = [];
|
|
14
|
+
|
|
15
|
+
const wavePattern = /^## Wave (\d+)/;
|
|
16
|
+
const taskPattern = /^### Task (\d+)/;
|
|
17
|
+
const ownsPattern = /\*\*OWNS\*\*:\s*(.+)/;
|
|
18
|
+
|
|
19
|
+
let currentWave = null;
|
|
20
|
+
let currentTask = null;
|
|
21
|
+
// Map: wave number -> Map of file -> first task number that owns it
|
|
22
|
+
const waveOwnership = new Map();
|
|
23
|
+
|
|
24
|
+
for (const line of lines) {
|
|
25
|
+
const parsed = parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership);
|
|
26
|
+
if (parsed.matched) {
|
|
27
|
+
currentWave = parsed.currentWave;
|
|
28
|
+
currentTask = parsed.currentTask;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (currentWave !== null && currentTask !== null) {
|
|
33
|
+
const ownedFiles = extractOwnedFiles(line, ownsPattern);
|
|
34
|
+
if (ownedFiles) {
|
|
35
|
+
checkOwnership(ownedFiles, waveOwnership.get(currentWave), currentWave, currentTask, violations);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
valid: violations.length === 0,
|
|
42
|
+
violations,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a line for wave or task headers and update state accordingly.
|
|
48
|
+
* @param {string} line - Current line of markdown
|
|
49
|
+
* @param {RegExp} wavePattern - Pattern to match wave headers
|
|
50
|
+
* @param {RegExp} taskPattern - Pattern to match task headers
|
|
51
|
+
* @param {number|null} currentWave - Current wave number
|
|
52
|
+
* @param {number|null} currentTask - Current task number
|
|
53
|
+
* @param {Map} waveOwnership - Wave ownership map to initialize new waves
|
|
54
|
+
* @returns {{ matched: boolean, currentWave: number|null, currentTask: number|null }}
|
|
55
|
+
*/
|
|
56
|
+
function parseWaveAndTask(line, wavePattern, taskPattern, currentWave, currentTask, waveOwnership) {
|
|
57
|
+
const waveMatch = wavePattern.exec(line);
|
|
58
|
+
if (waveMatch) {
|
|
59
|
+
const wave = Number.parseInt(waveMatch[1], 10);
|
|
60
|
+
if (!waveOwnership.has(wave)) {
|
|
61
|
+
waveOwnership.set(wave, new Map());
|
|
62
|
+
}
|
|
63
|
+
return { matched: true, currentWave: wave, currentTask: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const taskMatch = taskPattern.exec(line);
|
|
67
|
+
if (taskMatch) {
|
|
68
|
+
return { matched: true, currentWave, currentTask: Number.parseInt(taskMatch[1], 10) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { matched: false, currentWave, currentTask };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract owned file paths from an OWNS line.
|
|
76
|
+
* @param {string} line - Current line of markdown
|
|
77
|
+
* @param {RegExp} ownsPattern - Pattern to match OWNS declarations
|
|
78
|
+
* @returns {string[]|null} Array of file paths, or null if line is not an OWNS line
|
|
79
|
+
*/
|
|
80
|
+
function extractOwnedFiles(line, ownsPattern) {
|
|
81
|
+
const ownsMatch = ownsPattern.exec(line);
|
|
82
|
+
if (!ownsMatch) return null;
|
|
83
|
+
|
|
84
|
+
const filesRaw = ownsMatch[1];
|
|
85
|
+
const fileMatches = filesRaw.match(/`([^`]+)`/g);
|
|
86
|
+
if (!fileMatches) return null;
|
|
87
|
+
|
|
88
|
+
return fileMatches.map((f) => f.replaceAll('`', ''));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check for ownership violations and record them.
|
|
93
|
+
* @param {string[]} files - Files declared as owned
|
|
94
|
+
* @param {Map<string, number>} ownership - File-to-task ownership map for the current wave
|
|
95
|
+
* @param {number} currentWave - Current wave number
|
|
96
|
+
* @param {number} currentTask - Current task number
|
|
97
|
+
* @param {Array} violations - Array to push violations into
|
|
98
|
+
*/
|
|
99
|
+
function checkOwnership(files, ownership, currentWave, currentTask, violations) {
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
if (ownership.has(file)) {
|
|
102
|
+
const firstTask = ownership.get(file);
|
|
103
|
+
if (firstTask !== currentTask) {
|
|
104
|
+
violations.push({
|
|
105
|
+
wave: currentWave,
|
|
106
|
+
task1: firstTask,
|
|
107
|
+
task2: currentTask,
|
|
108
|
+
file,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
ownership.set(file, currentTask);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { validateOwnership };
|
package/lib/ui-utils.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI prompt and display utilities
|
|
3
|
+
* Extracted from bin/forge.js for reuse and testability
|
|
4
|
+
* @module lib/ui-utils
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Yes/No prompt helper.
|
|
9
|
+
* @param {Function} question - Async function that prompts user and returns their answer string
|
|
10
|
+
* @param {string} prompt - The prompt text to display
|
|
11
|
+
* @param {boolean} [defaultNo=true] - Whether the default answer is "no"
|
|
12
|
+
* @param {boolean} [nonInteractive=false] - If true, returns default without prompting
|
|
13
|
+
* @returns {Promise<boolean>} User's answer
|
|
14
|
+
*/
|
|
15
|
+
async function askYesNo(question, prompt, defaultNo = true, nonInteractive = false) {
|
|
16
|
+
// Non-interactive mode: return default without prompting
|
|
17
|
+
if (nonInteractive) {
|
|
18
|
+
const defaultValue = !defaultNo;
|
|
19
|
+
console.log(` Non-interactive mode: ${prompt} -> ${defaultValue ? 'yes' : 'no'} (default)`);
|
|
20
|
+
return defaultValue;
|
|
21
|
+
}
|
|
22
|
+
const defaultText = defaultNo ? '[n]' : '[y]';
|
|
23
|
+
while (true) {
|
|
24
|
+
const answer = await question(`${prompt} (y/n) ${defaultText}: `);
|
|
25
|
+
const normalized = answer.trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
// Handle empty input (use default)
|
|
28
|
+
if (normalized === '') return !defaultNo;
|
|
29
|
+
|
|
30
|
+
// Accept yes variations
|
|
31
|
+
if (normalized === 'y' || normalized === 'yes') return true;
|
|
32
|
+
|
|
33
|
+
// Accept no variations
|
|
34
|
+
if (normalized === 'n' || normalized === 'no') return false;
|
|
35
|
+
|
|
36
|
+
// Invalid input - re-prompt
|
|
37
|
+
console.log(' Please enter y or n');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
askYesNo
|
|
43
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validation utilities
|
|
3
|
+
* Extracted from bin/forge.js for reuse and testability
|
|
4
|
+
* @module lib/validation-utils
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run common security checks on input.
|
|
12
|
+
* Checks for shell injection, URL encoding attacks, and non-ASCII characters.
|
|
13
|
+
* @param {string} input - Input string to validate
|
|
14
|
+
* @returns {{valid: boolean, error?: string}}
|
|
15
|
+
*/
|
|
16
|
+
function validateCommonSecurity(input) {
|
|
17
|
+
// Shell injection check - common shell metacharacters
|
|
18
|
+
if (/[;|&$`()<>\r\n]/.test(input)) {
|
|
19
|
+
return { valid: false, error: 'Invalid characters detected (shell metacharacters)' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// URL encoding check - prevent encoded path traversal
|
|
23
|
+
if (/%2[eE]|%2[fF]|%5[cC]/.test(input)) {
|
|
24
|
+
return { valid: false, error: 'URL-encoded characters not allowed' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ASCII-only check - prevent unicode attacks
|
|
28
|
+
if (!/^[\x20-\x7E]+$/.test(input)) {
|
|
29
|
+
return { valid: false, error: 'Only ASCII printable characters allowed' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { valid: true }; // No security issues found
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate user input against security patterns.
|
|
37
|
+
* Prevents shell injection, path traversal, and unicode attacks.
|
|
38
|
+
* @param {string} input - User input to validate
|
|
39
|
+
* @param {string} type - Input type: 'path', 'agent', 'hash', 'directory_path'
|
|
40
|
+
* @param {string} [projectRoot] - Project root path (required for 'path' type)
|
|
41
|
+
* @returns {{valid: boolean, error?: string}}
|
|
42
|
+
*/
|
|
43
|
+
function validateUserInput(input, type, projectRoot) {
|
|
44
|
+
// Common security checks first
|
|
45
|
+
const securityResult = validateCommonSecurity(input);
|
|
46
|
+
if (!securityResult.valid) return securityResult;
|
|
47
|
+
|
|
48
|
+
// Type-specific validation - delegated to helpers
|
|
49
|
+
switch (type) {
|
|
50
|
+
case 'path':
|
|
51
|
+
return validatePathInput(input, projectRoot);
|
|
52
|
+
case 'directory_path':
|
|
53
|
+
return validateDirectoryPathInput(input);
|
|
54
|
+
case 'agent':
|
|
55
|
+
return validateAgentInput(input);
|
|
56
|
+
case 'hash':
|
|
57
|
+
return validateHashInput(input);
|
|
58
|
+
default:
|
|
59
|
+
return { valid: true };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate 'path' type input - ensures path stays within project root.
|
|
65
|
+
* @param {string} input - Path to validate
|
|
66
|
+
* @param {string} projectRoot - Project root directory
|
|
67
|
+
* @returns {{valid: boolean, error?: string}}
|
|
68
|
+
*/
|
|
69
|
+
function validatePathInput(input, projectRoot) {
|
|
70
|
+
const resolved = path.resolve(projectRoot, input);
|
|
71
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
72
|
+
if (!resolved.startsWith(resolvedRoot + path.sep) && resolved !== resolvedRoot) {
|
|
73
|
+
return { valid: false, error: 'Path outside project root' };
|
|
74
|
+
}
|
|
75
|
+
return { valid: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate 'directory_path' type input - blocks system directories.
|
|
80
|
+
* @param {string} input - Directory path to validate
|
|
81
|
+
* @returns {{valid: boolean, error?: string}}
|
|
82
|
+
*/
|
|
83
|
+
function validateDirectoryPathInput(input) {
|
|
84
|
+
// Block null bytes
|
|
85
|
+
if (input.includes('\0')) {
|
|
86
|
+
return { valid: false, error: 'Null bytes not allowed in path' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Block absolute paths to sensitive system directories
|
|
90
|
+
const resolved = path.resolve(input);
|
|
91
|
+
const normalizedResolved = path.normalize(resolved).toLowerCase();
|
|
92
|
+
|
|
93
|
+
// Get platform-specific blocked paths
|
|
94
|
+
const blockedPaths = process.platform === 'win32'
|
|
95
|
+
? [String.raw`c:\windows`, String.raw`c:\program files`, String.raw`c:\program files (x86)`]
|
|
96
|
+
: ['/etc', '/bin', '/sbin', '/boot', '/sys', '/proc', '/dev'];
|
|
97
|
+
const errorMsg = process.platform === 'win32'
|
|
98
|
+
? 'Cannot target Windows system directories'
|
|
99
|
+
: 'Cannot target system directories';
|
|
100
|
+
|
|
101
|
+
if (blockedPaths.some(blocked => normalizedResolved.startsWith(blocked))) {
|
|
102
|
+
return { valid: false, error: errorMsg };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { valid: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate 'agent' type input - lowercase alphanumeric with hyphens only.
|
|
110
|
+
* @param {string} input - Agent name to validate
|
|
111
|
+
* @returns {{valid: boolean, error?: string}}
|
|
112
|
+
*/
|
|
113
|
+
function validateAgentInput(input) {
|
|
114
|
+
if (!/^[a-z0-9-]+$/.test(input)) {
|
|
115
|
+
return { valid: false, error: 'Agent name must be lowercase alphanumeric with hyphens' };
|
|
116
|
+
}
|
|
117
|
+
return { valid: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate 'hash' type input - git commit hash (4-40 hex chars).
|
|
122
|
+
* @param {string} input - Hash to validate
|
|
123
|
+
* @returns {{valid: boolean, error?: string}}
|
|
124
|
+
*/
|
|
125
|
+
function validateHashInput(input) {
|
|
126
|
+
if (!/^[0-9a-f]{4,40}$/i.test(input)) {
|
|
127
|
+
return { valid: false, error: 'Invalid commit hash format (must be 4-40 hex chars)' };
|
|
128
|
+
}
|
|
129
|
+
return { valid: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check write permission to a directory or file.
|
|
134
|
+
* @param {string} filePath - Path to check
|
|
135
|
+
* @returns {{writable: boolean, error?: string}}
|
|
136
|
+
*/
|
|
137
|
+
function _checkWritePermission(filePath) {
|
|
138
|
+
try {
|
|
139
|
+
const dir = fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
|
|
140
|
+
const testFile = path.join(dir, `.forge-write-test-${Date.now()}`);
|
|
141
|
+
fs.writeFileSync(testFile, 'test');
|
|
142
|
+
fs.unlinkSync(testFile);
|
|
143
|
+
return { writable: true };
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
146
|
+
const fix = process.platform === 'win32'
|
|
147
|
+
? 'Run Command Prompt as Administrator'
|
|
148
|
+
: 'Try: sudo npx forge setup';
|
|
149
|
+
return { writable: false, error: `No write permission to ${filePath}. ${fix}` };
|
|
150
|
+
}
|
|
151
|
+
return { writable: false, error: err.message };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
validateCommonSecurity,
|
|
157
|
+
validateUserInput,
|
|
158
|
+
validatePathInput,
|
|
159
|
+
validateDirectoryPathInput,
|
|
160
|
+
validateAgentInput,
|
|
161
|
+
validateHashInput,
|
|
162
|
+
_checkWritePermission
|
|
163
|
+
};
|
|
@@ -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
|
+
};
|