speci 0.1.0
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/README.md +523 -0
- package/bin/speci.ts +228 -0
- package/lib/commands/init.ts +299 -0
- package/lib/commands/monitor.ts +579 -0
- package/lib/commands/plan.ts +112 -0
- package/lib/commands/refactor.ts +157 -0
- package/lib/commands/run.ts +531 -0
- package/lib/commands/status.ts +209 -0
- package/lib/commands/task.ts +133 -0
- package/lib/config.ts +644 -0
- package/lib/copilot.ts +229 -0
- package/lib/errors.ts +166 -0
- package/lib/state.ts +148 -0
- package/lib/ui/banner.ts +109 -0
- package/lib/ui/box.ts +161 -0
- package/lib/ui/colors.ts +110 -0
- package/lib/ui/glyphs.ts +91 -0
- package/lib/ui/palette.ts +118 -0
- package/lib/ui/terminal.ts +118 -0
- package/lib/utils/atomic-write.ts +147 -0
- package/lib/utils/gate.ts +197 -0
- package/lib/utils/i18n.ts +92 -0
- package/lib/utils/lock.ts +189 -0
- package/lib/utils/logger.ts +143 -0
- package/lib/utils/preflight.ts +236 -0
- package/lib/utils/process.ts +127 -0
- package/lib/utils/signals.ts +145 -0
- package/lib/utils/suggest.ts +71 -0
- package/package.json +38 -0
- package/templates/agents/speci-fix.md +107 -0
- package/templates/agents/speci-impl.md +152 -0
- package/templates/agents/speci-plan.md +771 -0
- package/templates/agents/speci-refactor.md +652 -0
- package/templates/agents/speci-review.md +169 -0
- package/templates/agents/speci-task.md +369 -0
- package/templates/agents/speci-tidy.md +84 -0
- package/templates/agents/subagents/final_reviewer.prompt.md +143 -0
- package/templates/agents/subagents/mvt_generator.prompt.md +171 -0
- package/templates/agents/subagents/plan_codebase_context.prompt.md +29 -0
- package/templates/agents/subagents/plan_initial_planner.prompt.md +31 -0
- package/templates/agents/subagents/plan_refine_architecture.prompt.md +21 -0
- package/templates/agents/subagents/plan_refine_dataflow.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_edgecases.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_errors.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_final.prompt.md +25 -0
- package/templates/agents/subagents/plan_refine_integration.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_performance.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_requirements.prompt.md +16 -0
- package/templates/agents/subagents/plan_refine_security.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_testing.prompt.md +21 -0
- package/templates/agents/subagents/plan_requirements_deep_dive.prompt.md +30 -0
- package/templates/agents/subagents/progress_generator.prompt.md +178 -0
- package/templates/agents/subagents/refactor_analyze_crosscutting.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_duplication.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_errors.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_functions.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_naming.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_performance.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_state.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_structure.prompt.md +64 -0
- package/templates/agents/subagents/refactor_analyze_testing.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_types.prompt.md +66 -0
- package/templates/agents/subagents/refactor_review_completeness.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_final.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_risks.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_roadmap.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_technical.prompt.md +63 -0
- package/templates/agents/subagents/task_generator.prompt.md +145 -0
- package/templates/agents/subagents/task_reviewer.prompt.md +85 -0
- package/templates/speci.config.json +36 -0
package/lib/copilot.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot CLI Wrapper Module
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean interface for invoking GitHub Copilot CLI with:
|
|
5
|
+
* - Interactive mode (-i flag) for full terminal passthrough
|
|
6
|
+
* - One-shot mode (-p flag) for non-interactive agent execution
|
|
7
|
+
* - Retry logic with exponential backoff for transient failures
|
|
8
|
+
* - Proper stdio handling and process spawning
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import type { SpeciConfig } from './config.js';
|
|
13
|
+
import { resolveAgentPath } from './config.js';
|
|
14
|
+
import { log } from './utils/logger.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for building copilot CLI arguments
|
|
18
|
+
*/
|
|
19
|
+
export interface CopilotArgsOptions {
|
|
20
|
+
interactive: boolean;
|
|
21
|
+
prompt?: string;
|
|
22
|
+
agent?: string;
|
|
23
|
+
allowAll?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Result of running an agent
|
|
28
|
+
*/
|
|
29
|
+
export interface AgentRunResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
exitCode: number;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Retry policy for transient failures
|
|
37
|
+
*/
|
|
38
|
+
interface RetryPolicy {
|
|
39
|
+
maxRetries: number;
|
|
40
|
+
baseDelay: number;
|
|
41
|
+
maxDelay: number;
|
|
42
|
+
retryableExitCodes: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default retry policy
|
|
47
|
+
*/
|
|
48
|
+
const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
|
49
|
+
maxRetries: 3,
|
|
50
|
+
baseDelay: 1000,
|
|
51
|
+
maxDelay: 4000,
|
|
52
|
+
retryableExitCodes: [429], // Rate limit
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build copilot CLI arguments from config and options
|
|
57
|
+
*
|
|
58
|
+
* @param config - Speci configuration
|
|
59
|
+
* @param options - Argument building options
|
|
60
|
+
* @returns Array of CLI arguments
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* const args = buildCopilotArgs(config, { interactive: true });
|
|
65
|
+
* // Returns: ['-i', '--allow-all']
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function buildCopilotArgs(
|
|
69
|
+
config: SpeciConfig,
|
|
70
|
+
options: CopilotArgsOptions
|
|
71
|
+
): string[] {
|
|
72
|
+
const args: string[] = [];
|
|
73
|
+
|
|
74
|
+
// Mode flag
|
|
75
|
+
if (options.interactive) {
|
|
76
|
+
args.push('-i');
|
|
77
|
+
} else if (options.prompt) {
|
|
78
|
+
args.push('-p', options.prompt);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Agent flag
|
|
82
|
+
if (options.agent) {
|
|
83
|
+
args.push(`--agent=${options.agent}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Permission flag
|
|
87
|
+
const { permissions } = config.copilot;
|
|
88
|
+
if (permissions === 'allow-all') {
|
|
89
|
+
args.push('--allow-all');
|
|
90
|
+
} else if (permissions === 'yolo') {
|
|
91
|
+
args.push('--yolo');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Model flag
|
|
95
|
+
if (config.copilot.model) {
|
|
96
|
+
args.push('--model', config.copilot.model);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Extra flags
|
|
100
|
+
args.push(...config.copilot.extraFlags);
|
|
101
|
+
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Spawn copilot CLI process
|
|
107
|
+
*
|
|
108
|
+
* @param args - CLI arguments
|
|
109
|
+
* @param options - Spawn options
|
|
110
|
+
* @returns Promise that resolves with exit code
|
|
111
|
+
* @throws {Error} If copilot process fails to spawn
|
|
112
|
+
*/
|
|
113
|
+
export async function spawnCopilot(
|
|
114
|
+
args: string[],
|
|
115
|
+
options: { inherit?: boolean; cwd?: string } = {}
|
|
116
|
+
): Promise<number> {
|
|
117
|
+
const { inherit = true, cwd = process.cwd() } = options;
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const child = spawn('copilot', args, {
|
|
121
|
+
stdio: inherit ? 'inherit' : 'pipe',
|
|
122
|
+
cwd,
|
|
123
|
+
env: process.env,
|
|
124
|
+
shell: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
child.on('error', (err) => {
|
|
128
|
+
reject(err);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
child.on('close', (code) => {
|
|
132
|
+
resolve(code ?? 1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sleep for specified milliseconds
|
|
139
|
+
*
|
|
140
|
+
* @param ms - Milliseconds to sleep
|
|
141
|
+
* @returns Promise that resolves after delay
|
|
142
|
+
*/
|
|
143
|
+
async function sleep(ms: number): Promise<void> {
|
|
144
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Run an agent with retry logic for transient failures
|
|
149
|
+
*
|
|
150
|
+
* @param config - Speci configuration
|
|
151
|
+
* @param agentName - Name of agent to run
|
|
152
|
+
* @param label - Human-readable label for logging
|
|
153
|
+
* @param policy - Retry policy (uses default if not specified)
|
|
154
|
+
* @returns Promise that resolves with agent run result
|
|
155
|
+
* @throws {Error} If agent file not found or execution fails
|
|
156
|
+
*/
|
|
157
|
+
export async function runAgent(
|
|
158
|
+
config: SpeciConfig,
|
|
159
|
+
agentName: string,
|
|
160
|
+
_label: string,
|
|
161
|
+
policy: RetryPolicy = DEFAULT_RETRY_POLICY
|
|
162
|
+
): Promise<AgentRunResult> {
|
|
163
|
+
let lastError: Error | undefined;
|
|
164
|
+
let lastExitCode = 1;
|
|
165
|
+
|
|
166
|
+
for (let attempt = 0; attempt <= policy.maxRetries; attempt++) {
|
|
167
|
+
if (attempt > 0) {
|
|
168
|
+
const delay = Math.min(
|
|
169
|
+
policy.baseDelay * Math.pow(2, attempt - 1),
|
|
170
|
+
policy.maxDelay
|
|
171
|
+
);
|
|
172
|
+
log.warn(`Retry ${attempt}/${policy.maxRetries} after ${delay}ms...`);
|
|
173
|
+
await sleep(delay);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const agentPath = resolveAgentPath(
|
|
178
|
+
config,
|
|
179
|
+
agentName as
|
|
180
|
+
| 'plan'
|
|
181
|
+
| 'task'
|
|
182
|
+
| 'refactor'
|
|
183
|
+
| 'impl'
|
|
184
|
+
| 'review'
|
|
185
|
+
| 'fix'
|
|
186
|
+
| 'tidy'
|
|
187
|
+
);
|
|
188
|
+
const args = buildCopilotArgs(config, {
|
|
189
|
+
interactive: true,
|
|
190
|
+
agent: agentPath,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
log.debug(`Spawning copilot: copilot ${args.join(' ')}`);
|
|
194
|
+
const exitCode = await spawnCopilot(args);
|
|
195
|
+
|
|
196
|
+
if (exitCode === 0) {
|
|
197
|
+
return { success: true, exitCode: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
lastExitCode = exitCode;
|
|
201
|
+
|
|
202
|
+
// Check if retryable
|
|
203
|
+
if (!policy.retryableExitCodes.includes(exitCode)) {
|
|
204
|
+
return {
|
|
205
|
+
success: false,
|
|
206
|
+
exitCode,
|
|
207
|
+
error: `Agent exited with code ${exitCode}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
lastError = err as Error;
|
|
212
|
+
|
|
213
|
+
// ENOENT is not retryable
|
|
214
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
exitCode: 127,
|
|
218
|
+
error: 'Copilot CLI not found. Is it installed and in PATH?',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
exitCode: lastExitCode,
|
|
227
|
+
error: lastError?.message ?? `Failed after ${policy.maxRetries} retries`,
|
|
228
|
+
};
|
|
229
|
+
}
|
package/lib/errors.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Codes Module
|
|
3
|
+
*
|
|
4
|
+
* Defines structured error codes with messages, causes, and solutions.
|
|
5
|
+
* Used for consistent error reporting across the CLI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Error code definition with diagnostic information
|
|
10
|
+
*/
|
|
11
|
+
export interface ErrorDefinition {
|
|
12
|
+
message: string;
|
|
13
|
+
cause: string;
|
|
14
|
+
solution: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* All error codes used by speci CLI
|
|
19
|
+
*
|
|
20
|
+
* Error codes are grouped by category:
|
|
21
|
+
* - ERR-PRE-* : Prerequisite errors (missing dependencies, environment issues)
|
|
22
|
+
* - ERR-INP-* : Input errors (invalid arguments, missing files)
|
|
23
|
+
* - ERR-STA-* : State errors (lock conflicts, state parsing)
|
|
24
|
+
* - ERR-EXE-* : Execution errors (command failures, timeouts)
|
|
25
|
+
*/
|
|
26
|
+
export const ERROR_CODES: Record<string, ErrorDefinition> = {
|
|
27
|
+
// Prerequisite Errors (ERR-PRE-*)
|
|
28
|
+
'ERR-PRE-01': {
|
|
29
|
+
message: 'Copilot CLI is not installed',
|
|
30
|
+
cause: 'The copilot command was not found in PATH',
|
|
31
|
+
solution: 'Run: npm install -g @github/copilot',
|
|
32
|
+
},
|
|
33
|
+
'ERR-PRE-02': {
|
|
34
|
+
message: 'Copilot CLI is not authenticated',
|
|
35
|
+
cause: 'Copilot CLI requires authentication to function',
|
|
36
|
+
solution: 'Run /login in Copilot CLI or set GH_TOKEN environment variable',
|
|
37
|
+
},
|
|
38
|
+
'ERR-PRE-03': {
|
|
39
|
+
message: 'Not a git repository',
|
|
40
|
+
cause: 'Current directory is not inside a git repository',
|
|
41
|
+
solution: 'Run git init in your project root',
|
|
42
|
+
},
|
|
43
|
+
'ERR-PRE-04': {
|
|
44
|
+
message: 'Configuration file not found',
|
|
45
|
+
cause: 'speci.config.json does not exist in project',
|
|
46
|
+
solution: 'Run speci init to create configuration',
|
|
47
|
+
},
|
|
48
|
+
'ERR-PRE-05': {
|
|
49
|
+
message: 'PROGRESS.md file not found',
|
|
50
|
+
cause: 'Progress tracking file does not exist',
|
|
51
|
+
solution: 'Run speci init or create docs/PROGRESS.md manually',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Input Errors (ERR-INP-*)
|
|
55
|
+
'ERR-INP-01': {
|
|
56
|
+
message: 'Required argument missing',
|
|
57
|
+
cause: 'A required command argument was not provided',
|
|
58
|
+
solution: 'Check command usage with --help',
|
|
59
|
+
},
|
|
60
|
+
'ERR-INP-02': {
|
|
61
|
+
message: 'Agent file not found',
|
|
62
|
+
cause: 'Specified agent file does not exist',
|
|
63
|
+
solution: 'Verify agent path or use bundled agents (set to null in config)',
|
|
64
|
+
},
|
|
65
|
+
'ERR-INP-03': {
|
|
66
|
+
message: 'Config file is malformed',
|
|
67
|
+
cause: 'speci.config.json contains invalid JSON syntax',
|
|
68
|
+
solution: 'Fix JSON syntax errors in speci.config.json',
|
|
69
|
+
},
|
|
70
|
+
'ERR-INP-04': {
|
|
71
|
+
message: 'Config validation failed',
|
|
72
|
+
cause: 'Configuration does not match expected schema',
|
|
73
|
+
solution: 'Check config values against schema requirements',
|
|
74
|
+
},
|
|
75
|
+
'ERR-INP-05': {
|
|
76
|
+
message: 'Plan file not found',
|
|
77
|
+
cause: 'Specified plan file does not exist',
|
|
78
|
+
solution: 'Provide valid path with --plan option',
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// State Errors (ERR-STA-*)
|
|
82
|
+
'ERR-STA-01': {
|
|
83
|
+
message: 'Lock file already exists',
|
|
84
|
+
cause: 'Another speci instance may be running',
|
|
85
|
+
solution: 'Wait for other instance to finish or use --force to override',
|
|
86
|
+
},
|
|
87
|
+
'ERR-STA-02': {
|
|
88
|
+
message: 'Cannot parse PROGRESS.md',
|
|
89
|
+
cause: 'Progress file format is invalid or corrupted',
|
|
90
|
+
solution: 'Verify PROGRESS.md follows expected markdown table format',
|
|
91
|
+
},
|
|
92
|
+
'ERR-STA-03': {
|
|
93
|
+
message: 'Invalid state transition',
|
|
94
|
+
cause: 'PROGRESS.md contains invalid or conflicting state markers',
|
|
95
|
+
solution: 'Check state markers (IN PROGRESS, IN REVIEW, COMPLETE, etc.)',
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Execution Errors (ERR-EXE-*)
|
|
99
|
+
'ERR-EXE-01': {
|
|
100
|
+
message: 'Gate command failed',
|
|
101
|
+
cause: 'One or more gate validation commands failed',
|
|
102
|
+
solution: 'Fix lint/typecheck/test errors reported in output',
|
|
103
|
+
},
|
|
104
|
+
'ERR-EXE-02': {
|
|
105
|
+
message: 'Copilot execution failed',
|
|
106
|
+
cause: 'Copilot CLI command exited with error',
|
|
107
|
+
solution: 'Check Copilot authentication and permissions',
|
|
108
|
+
},
|
|
109
|
+
'ERR-EXE-03': {
|
|
110
|
+
message: 'Max iterations reached',
|
|
111
|
+
cause: 'Loop reached maximum iteration limit',
|
|
112
|
+
solution: 'Review progress and increase --max-iterations if needed',
|
|
113
|
+
},
|
|
114
|
+
'ERR-EXE-04': {
|
|
115
|
+
message: 'Max fix attempts exceeded',
|
|
116
|
+
cause: 'Gate validation failed after maximum fix attempts',
|
|
117
|
+
solution: 'Review gate failures and fix issues manually',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get error definition by code
|
|
123
|
+
*
|
|
124
|
+
* @param code - Error code (e.g., 'ERR-PRE-01')
|
|
125
|
+
* @returns Error definition or undefined if code not found
|
|
126
|
+
*/
|
|
127
|
+
export function getErrorDefinition(code: string): ErrorDefinition | undefined {
|
|
128
|
+
return ERROR_CODES[code];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Format error message with code, cause, and solution
|
|
133
|
+
*
|
|
134
|
+
* @param code - Error code
|
|
135
|
+
* @param context - Optional additional context
|
|
136
|
+
* @returns Formatted error message
|
|
137
|
+
*/
|
|
138
|
+
export function formatError(code: string, context?: string): string {
|
|
139
|
+
const def = getErrorDefinition(code);
|
|
140
|
+
if (!def) {
|
|
141
|
+
return `Unknown error code: ${code}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let message = `[${code}] ${def.message}`;
|
|
145
|
+
if (context) {
|
|
146
|
+
message += `\n Context: ${context}`;
|
|
147
|
+
}
|
|
148
|
+
message += `\n Cause: ${def.cause}`;
|
|
149
|
+
message += `\n Solution: ${def.solution}`;
|
|
150
|
+
|
|
151
|
+
return message;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Create error with structured code and message
|
|
156
|
+
*
|
|
157
|
+
* @param code - Error code
|
|
158
|
+
* @param context - Optional additional context
|
|
159
|
+
* @returns Error object with formatted message
|
|
160
|
+
*/
|
|
161
|
+
export function createError(code: string, context?: string): Error {
|
|
162
|
+
const message = formatError(code, context);
|
|
163
|
+
const error = new Error(message);
|
|
164
|
+
error.name = code;
|
|
165
|
+
return error;
|
|
166
|
+
}
|
package/lib/state.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Parser Module
|
|
3
|
+
*
|
|
4
|
+
* Reads and parses PROGRESS.md to determine the current loop state.
|
|
5
|
+
* Provides state detection and task statistics for orchestrator decision-making.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import type { SpeciConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* State enum representing the current orchestration state
|
|
14
|
+
*/
|
|
15
|
+
export enum STATE {
|
|
16
|
+
WORK_LEFT = 'WORK_LEFT',
|
|
17
|
+
IN_REVIEW = 'IN_REVIEW',
|
|
18
|
+
BLOCKED = 'BLOCKED',
|
|
19
|
+
DONE = 'DONE',
|
|
20
|
+
NO_PROGRESS = 'NO_PROGRESS',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Task statistics interface
|
|
25
|
+
*/
|
|
26
|
+
export interface TaskStats {
|
|
27
|
+
total: number;
|
|
28
|
+
completed: number;
|
|
29
|
+
remaining: number;
|
|
30
|
+
inReview: number;
|
|
31
|
+
blocked: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pre-compile regex patterns at module load for performance
|
|
35
|
+
const PATTERNS = {
|
|
36
|
+
BLOCKED: /TASK_\d+\s*\|.*BLOCKED/i,
|
|
37
|
+
IN_REVIEW: /TASK_\d+\s*\|.*IN.REVIEW/i,
|
|
38
|
+
WORK_LEFT: /TASK_\d+\s*\|.*(NOT STARTED|IN PROGRESS)/i,
|
|
39
|
+
TASK_ROW: /TASK_(\d+)\s*\|([^|]+)\|([^|]+)(?:\|([^|]+))?/i,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
// Priority order for state detection (highest to lowest)
|
|
43
|
+
const STATE_PRIORITY: Array<{ state: STATE; pattern: RegExp }> = [
|
|
44
|
+
{ state: STATE.BLOCKED, pattern: PATTERNS.BLOCKED },
|
|
45
|
+
{ state: STATE.IN_REVIEW, pattern: PATTERNS.IN_REVIEW },
|
|
46
|
+
{ state: STATE.WORK_LEFT, pattern: PATTERNS.WORK_LEFT },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if content matches a state pattern
|
|
51
|
+
* @param content - Content to check
|
|
52
|
+
* @param pattern - Regex pattern to test
|
|
53
|
+
* @returns true if pattern matches
|
|
54
|
+
*/
|
|
55
|
+
export function hasStatePattern(content: string, pattern: RegExp): boolean {
|
|
56
|
+
return pattern.test(content);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get current orchestration state by parsing PROGRESS.md
|
|
61
|
+
*
|
|
62
|
+
* @param config - Speci configuration
|
|
63
|
+
* @returns Current STATE enum value
|
|
64
|
+
* @throws {Error} ERR-STA-02 if PROGRESS.md cannot be parsed
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const state = await getState(config);
|
|
69
|
+
* if (state === STATE.WORK_LEFT) {
|
|
70
|
+
* // Start implementation
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export async function getState(config: SpeciConfig): Promise<STATE> {
|
|
75
|
+
const progressPath = config.paths.progress;
|
|
76
|
+
|
|
77
|
+
// Check file existence
|
|
78
|
+
if (!existsSync(progressPath)) {
|
|
79
|
+
return STATE.NO_PROGRESS;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Read file content
|
|
83
|
+
const content = await readFile(progressPath, 'utf8');
|
|
84
|
+
|
|
85
|
+
// Check patterns in priority order (early exit)
|
|
86
|
+
for (const { state, pattern } of STATE_PRIORITY) {
|
|
87
|
+
if (pattern.test(content)) {
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// No incomplete tasks found
|
|
93
|
+
return STATE.DONE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get task statistics from PROGRESS.md
|
|
98
|
+
*
|
|
99
|
+
* @param config - Speci configuration
|
|
100
|
+
* @returns TaskStats object with counts
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const stats = await getTaskStats(config);
|
|
105
|
+
* console.log(`${stats.completed}/${stats.total} tasks complete`);
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export async function getTaskStats(config: SpeciConfig): Promise<TaskStats> {
|
|
109
|
+
const progressPath = config.paths.progress;
|
|
110
|
+
|
|
111
|
+
if (!existsSync(progressPath)) {
|
|
112
|
+
return { total: 0, completed: 0, remaining: 0, inReview: 0, blocked: 0 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const content = await readFile(progressPath, 'utf8');
|
|
116
|
+
const lines = content.split('\n');
|
|
117
|
+
|
|
118
|
+
const stats: TaskStats = {
|
|
119
|
+
total: 0,
|
|
120
|
+
completed: 0,
|
|
121
|
+
remaining: 0,
|
|
122
|
+
inReview: 0,
|
|
123
|
+
blocked: 0,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
// Match task rows: | TASK_001 | Description | Status | or | TASK_001 | Title | Priority | Status |
|
|
128
|
+
const match = line.match(PATTERNS.TASK_ROW);
|
|
129
|
+
if (!match) continue;
|
|
130
|
+
|
|
131
|
+
stats.total++;
|
|
132
|
+
|
|
133
|
+
// Status is in the last captured group (either match[3] for 3 columns or match[4] for 4 columns)
|
|
134
|
+
const status = (match[4] || match[3]).trim().toUpperCase();
|
|
135
|
+
|
|
136
|
+
if (status === 'COMPLETE' || status === 'COMPLETED' || status === 'DONE') {
|
|
137
|
+
stats.completed++;
|
|
138
|
+
} else if (status === 'BLOCKED') {
|
|
139
|
+
stats.blocked++;
|
|
140
|
+
} else if (status === 'IN_REVIEW' || status === 'IN REVIEW') {
|
|
141
|
+
stats.inReview++;
|
|
142
|
+
} else if (status === 'NOT STARTED' || status === 'IN PROGRESS') {
|
|
143
|
+
stats.remaining++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return stats;
|
|
148
|
+
}
|
package/lib/ui/banner.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII Banner Module
|
|
3
|
+
*
|
|
4
|
+
* Renders the SPECI CLI brand banner with horizontal gradient effect.
|
|
5
|
+
* Uses Ice Blue color palette (sky-200 → sky-400 → sky-500) for visual appeal.
|
|
6
|
+
*
|
|
7
|
+
* The banner provides immediate brand recognition on CLI startup.
|
|
8
|
+
* Respects NO_COLOR and FORCE_COLOR environment variables.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { ANSI } from './palette.js';
|
|
13
|
+
import { colorize, supportsColor } from './colors.js';
|
|
14
|
+
|
|
15
|
+
// Use createRequire for reliable JSON imports in ESM (works in both runtime and tests)
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const pkg = require('../../package.json') as { version: string };
|
|
18
|
+
|
|
19
|
+
/** Package version from package.json */
|
|
20
|
+
export const VERSION = pkg.version;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ASCII art banner for SPECI CLI
|
|
24
|
+
* 6 lines tall, 40 characters wide
|
|
25
|
+
*/
|
|
26
|
+
export const BANNER_ART = [
|
|
27
|
+
' ███████╗██████╗ ███████╗ ██████╗██╗',
|
|
28
|
+
' ██╔════╝██╔══██╗██╔════╝██╔════╝██║',
|
|
29
|
+
' ███████╗██████╔╝█████╗ ██║ ██║',
|
|
30
|
+
' ╚════██║██╔═══╝ ██╔══╝ ██║ ██║',
|
|
31
|
+
' ███████║██║ ███████╗╚██████╗██║',
|
|
32
|
+
' ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options for banner rendering
|
|
37
|
+
*/
|
|
38
|
+
export interface BannerOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Include version number below banner
|
|
41
|
+
* @default true
|
|
42
|
+
*/
|
|
43
|
+
showVersion?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Apply horizontal gradient to a line of text
|
|
48
|
+
* Divides line into thirds: sky200 | sky400 | sky500
|
|
49
|
+
*
|
|
50
|
+
* @param line - Text line to apply gradient to
|
|
51
|
+
* @returns Gradient-colored line (or plain text if colors disabled)
|
|
52
|
+
*/
|
|
53
|
+
function applyGradient(line: string): string {
|
|
54
|
+
if (!supportsColor()) {
|
|
55
|
+
return line;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const len = line.length;
|
|
59
|
+
const third = Math.floor(len / 3);
|
|
60
|
+
|
|
61
|
+
const left = line.slice(0, third);
|
|
62
|
+
const middle = line.slice(third, third * 2);
|
|
63
|
+
const right = line.slice(third * 2);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
`${ANSI.sky200}${left}` +
|
|
67
|
+
`${ANSI.sky400}${middle}` +
|
|
68
|
+
`${ANSI.sky500}${right}${ANSI.reset}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render ASCII art banner with Ice Blue gradient
|
|
74
|
+
*
|
|
75
|
+
* Displays the SPECI logo with a horizontal gradient effect.
|
|
76
|
+
* Optionally includes version number centered below the banner.
|
|
77
|
+
*
|
|
78
|
+
* @param options - Render options
|
|
79
|
+
* @returns Formatted banner string ready for console output
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* // Render banner with version
|
|
84
|
+
* console.log(renderBanner());
|
|
85
|
+
*
|
|
86
|
+
* // Render banner without version
|
|
87
|
+
* console.log(renderBanner({ showVersion: false }));
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function renderBanner(options: BannerOptions = {}): string {
|
|
91
|
+
const { showVersion = true } = options;
|
|
92
|
+
|
|
93
|
+
// Apply gradient to each line
|
|
94
|
+
const bannerLines = BANNER_ART.map((line) => applyGradient(line));
|
|
95
|
+
|
|
96
|
+
// Join banner lines
|
|
97
|
+
let output = bannerLines.join('\n');
|
|
98
|
+
|
|
99
|
+
// Add version if requested
|
|
100
|
+
if (showVersion) {
|
|
101
|
+
const versionText = `v${pkg.version}`;
|
|
102
|
+
const bannerWidth = BANNER_ART[0].length;
|
|
103
|
+
const padding = Math.floor((bannerWidth - versionText.length) / 2);
|
|
104
|
+
const centeredVersion = ' '.repeat(padding) + colorize(versionText, 'dim');
|
|
105
|
+
output += '\n' + centeredVersion;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return output;
|
|
109
|
+
}
|