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.
Files changed (164) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.cursorrules +149 -0
  36. package/.github/prompts/dev.prompt.md +6 -1
  37. package/.github/prompts/plan.prompt.md +59 -14
  38. package/.github/prompts/premerge.prompt.md +10 -0
  39. package/.github/prompts/review.prompt.md +7 -1
  40. package/.github/prompts/ship.prompt.md +95 -47
  41. package/.github/prompts/status.prompt.md +42 -0
  42. package/.github/prompts/validate.prompt.md +7 -1
  43. package/.github/prompts/verify.prompt.md +52 -4
  44. package/.kilocode/workflows/dev.md +6 -1
  45. package/.kilocode/workflows/plan.md +59 -14
  46. package/.kilocode/workflows/premerge.md +10 -0
  47. package/.kilocode/workflows/review.md +7 -1
  48. package/.kilocode/workflows/ship.md +95 -47
  49. package/.kilocode/workflows/status.md +42 -0
  50. package/.kilocode/workflows/validate.md +7 -1
  51. package/.kilocode/workflows/verify.md +52 -4
  52. package/.opencode/commands/dev.md +6 -1
  53. package/.opencode/commands/plan.md +59 -14
  54. package/.opencode/commands/premerge.md +10 -0
  55. package/.opencode/commands/review.md +7 -1
  56. package/.opencode/commands/ship.md +95 -47
  57. package/.opencode/commands/status.md +42 -0
  58. package/.opencode/commands/validate.md +7 -1
  59. package/.opencode/commands/verify.md +52 -4
  60. package/.roo/commands/dev.md +6 -1
  61. package/.roo/commands/plan.md +59 -14
  62. package/.roo/commands/premerge.md +10 -0
  63. package/.roo/commands/review.md +7 -1
  64. package/.roo/commands/ship.md +95 -47
  65. package/.roo/commands/status.md +42 -0
  66. package/.roo/commands/validate.md +7 -1
  67. package/.roo/commands/verify.md +52 -4
  68. package/AGENTS.md +97 -0
  69. package/CLAUDE.md +10 -0
  70. package/README.md +2 -2
  71. package/bin/forge-cmd.js +5 -1
  72. package/bin/forge-preflight.js +15 -2
  73. package/bin/forge.js +211 -9
  74. package/docs/ENHANCED_ONBOARDING.md +96 -86
  75. package/docs/ROADMAP.md +2 -2
  76. package/docs/TOOLCHAIN.md +23 -0
  77. package/docs/VALIDATION.md +1 -1
  78. package/lefthook.yml +11 -0
  79. package/lib/agents/README.md +46 -1
  80. package/lib/agents/cline.plugin.json +11 -4
  81. package/lib/agents/codex.plugin.json +2 -2
  82. package/lib/agents/copilot.plugin.json +5 -5
  83. package/lib/agents/cursor.plugin.json +1 -1
  84. package/lib/agents/kilocode.plugin.json +1 -1
  85. package/lib/agents/opencode.plugin.json +7 -4
  86. package/lib/agents/roo.plugin.json +10 -3
  87. package/lib/agents-config.js +129 -81
  88. package/lib/codex-skills.js +50 -0
  89. package/lib/commands/_registry.js +173 -0
  90. package/lib/commands/clean.js +181 -0
  91. package/lib/commands/commands-reset.js +147 -0
  92. package/lib/commands/dev.js +84 -0
  93. package/lib/commands/plan.js +18 -0
  94. package/lib/commands/push.js +196 -0
  95. package/lib/commands/recommend.js +1 -1
  96. package/lib/commands/setup.js +4295 -0
  97. package/lib/commands/ship.js +20 -0
  98. package/lib/commands/status.js +210 -44
  99. package/lib/commands/sync.js +71 -0
  100. package/lib/commands/team.js +37 -0
  101. package/lib/commands/test.js +207 -0
  102. package/lib/commands/validate.js +13 -0
  103. package/lib/commands/worktree.js +310 -0
  104. package/lib/detect-agent.js +38 -8
  105. package/lib/detection-utils.js +405 -0
  106. package/lib/docs-command.js +51 -0
  107. package/lib/docs-copy.js +50 -0
  108. package/lib/file-utils.js +260 -0
  109. package/lib/forge-context.js +42 -0
  110. package/lib/freshness-token.js +148 -0
  111. package/lib/frontmatter.js +79 -0
  112. package/lib/greptile-match.js +80 -0
  113. package/lib/husky-migration.js +113 -12
  114. package/lib/lefthook-check.js +27 -6
  115. package/lib/plugin-manager.js +225 -72
  116. package/lib/project-discovery.js +39 -5
  117. package/lib/reset.js +309 -0
  118. package/lib/runtime-health.js +305 -0
  119. package/lib/shell-utils.js +50 -0
  120. package/lib/task-ownership.js +117 -0
  121. package/lib/ui-utils.js +43 -0
  122. package/lib/validation-utils.js +163 -0
  123. package/lib/workflow/enforce-stage.js +179 -0
  124. package/lib/workflow/stages.js +201 -0
  125. package/lib/workflow/state.js +332 -0
  126. package/opencode.json +67 -0
  127. package/package.json +16 -6
  128. package/scripts/beads-context.sh +165 -22
  129. package/scripts/beads-context.test.js +5 -1
  130. package/scripts/check-agents.js +103 -0
  131. package/scripts/check-forge-token.js +98 -0
  132. package/scripts/conflict-detect.sh +2 -2
  133. package/scripts/dep-guard.sh +6 -28
  134. package/scripts/file-index.sh +117 -23
  135. package/scripts/forge-team/index.sh +86 -0
  136. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  137. package/scripts/forge-team/lib/claim.sh +256 -0
  138. package/scripts/forge-team/lib/dashboard.sh +341 -0
  139. package/scripts/forge-team/lib/epic.sh +332 -0
  140. package/scripts/forge-team/lib/hooks.sh +253 -0
  141. package/scripts/forge-team/lib/identity.sh +235 -0
  142. package/scripts/forge-team/lib/sync-github.sh +317 -0
  143. package/scripts/forge-team/lib/verify.sh +284 -0
  144. package/scripts/forge-team/lib/workload.sh +296 -0
  145. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  146. package/scripts/forge-team/tests/claim.test.sh +179 -0
  147. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  148. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  149. package/scripts/forge-team/tests/epic.test.sh +176 -0
  150. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  151. package/scripts/forge-team/tests/identity.test.sh +176 -0
  152. package/scripts/forge-team/tests/integration.test.sh +371 -0
  153. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  154. package/scripts/forge-team/tests/verify.test.sh +314 -0
  155. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  156. package/scripts/forge-team/tests/workload.test.sh +209 -0
  157. package/scripts/lib/eval-runner.js +39 -0
  158. package/scripts/lib/jsonl-lock.sh +48 -0
  159. package/scripts/lib/sanitize.sh +116 -0
  160. package/scripts/pr-coordinator.sh +756 -0
  161. package/scripts/smart-status.sh +58 -21
  162. package/scripts/sync-commands.js +49 -20
  163. package/scripts/sync-utils.sh +24 -29
  164. 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 };
@@ -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
+ };