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/config.ts
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Loader Module
|
|
3
|
+
*
|
|
4
|
+
* Handles loading, validating, and merging configuration from speci.config.json.
|
|
5
|
+
* Supports priority-based merge: defaults → config file → env vars.
|
|
6
|
+
* Walks up directories to find config file (similar to ESLint/Prettier).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, dirname, isAbsolute } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { log } from './utils/logger.js';
|
|
13
|
+
|
|
14
|
+
// Get the directory of the compiled output
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
|
|
18
|
+
// Path to bundled templates (relative to compiled lib/ directory)
|
|
19
|
+
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Speci configuration interface
|
|
23
|
+
*/
|
|
24
|
+
export interface SpeciConfig {
|
|
25
|
+
version: string;
|
|
26
|
+
paths: {
|
|
27
|
+
progress: string;
|
|
28
|
+
tasks: string;
|
|
29
|
+
logs: string;
|
|
30
|
+
lock: string;
|
|
31
|
+
};
|
|
32
|
+
agents: {
|
|
33
|
+
plan: string | null;
|
|
34
|
+
task: string | null;
|
|
35
|
+
refactor: string | null;
|
|
36
|
+
impl: string | null;
|
|
37
|
+
review: string | null;
|
|
38
|
+
fix: string | null;
|
|
39
|
+
tidy: string | null;
|
|
40
|
+
};
|
|
41
|
+
copilot: {
|
|
42
|
+
permissions: 'allow-all' | 'yolo' | 'strict' | 'none';
|
|
43
|
+
model: string | null;
|
|
44
|
+
extraFlags: string[];
|
|
45
|
+
};
|
|
46
|
+
gate: {
|
|
47
|
+
commands: string[];
|
|
48
|
+
maxFixAttempts: number;
|
|
49
|
+
};
|
|
50
|
+
loop: {
|
|
51
|
+
maxIterations: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type AgentName =
|
|
56
|
+
| 'plan'
|
|
57
|
+
| 'task'
|
|
58
|
+
| 'refactor'
|
|
59
|
+
| 'impl'
|
|
60
|
+
| 'review'
|
|
61
|
+
| 'fix'
|
|
62
|
+
| 'tidy';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get hardcoded default configuration
|
|
66
|
+
*
|
|
67
|
+
* @returns Default SpeciConfig object with all standard paths and settings
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const defaults = getDefaults();
|
|
72
|
+
* console.log(defaults.paths.progress); // 'docs/PROGRESS.md'
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export function getDefaults(): SpeciConfig {
|
|
76
|
+
return {
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
paths: {
|
|
79
|
+
progress: 'docs/PROGRESS.md',
|
|
80
|
+
tasks: 'docs/tasks',
|
|
81
|
+
logs: '.speci-logs',
|
|
82
|
+
lock: '.speci-lock',
|
|
83
|
+
},
|
|
84
|
+
agents: {
|
|
85
|
+
plan: null,
|
|
86
|
+
task: null,
|
|
87
|
+
refactor: null,
|
|
88
|
+
impl: null,
|
|
89
|
+
review: null,
|
|
90
|
+
fix: null,
|
|
91
|
+
tidy: null,
|
|
92
|
+
},
|
|
93
|
+
copilot: {
|
|
94
|
+
permissions: 'allow-all',
|
|
95
|
+
model: null,
|
|
96
|
+
extraFlags: [],
|
|
97
|
+
},
|
|
98
|
+
gate: {
|
|
99
|
+
commands: ['npm run lint', 'npm run typecheck', 'npm test'],
|
|
100
|
+
maxFixAttempts: 5,
|
|
101
|
+
},
|
|
102
|
+
loop: {
|
|
103
|
+
maxIterations: 100,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Find config file by walking up directory tree
|
|
110
|
+
* @param startDir - Starting directory
|
|
111
|
+
* @returns Path to config file or null if not found
|
|
112
|
+
*/
|
|
113
|
+
function findConfigFile(startDir: string): string | null {
|
|
114
|
+
let currentDir = startDir;
|
|
115
|
+
|
|
116
|
+
// eslint-disable-next-line no-constant-condition
|
|
117
|
+
while (true) {
|
|
118
|
+
const configPath = join(currentDir, 'speci.config.json');
|
|
119
|
+
if (existsSync(configPath)) {
|
|
120
|
+
log.debug(`Found config file at ${configPath}`);
|
|
121
|
+
return configPath;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parentDir = dirname(currentDir);
|
|
125
|
+
if (parentDir === currentDir) {
|
|
126
|
+
log.debug('No config file found, using defaults');
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
currentDir = parentDir;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Deep merge two objects
|
|
135
|
+
* @param target - Target object
|
|
136
|
+
* @param source - Source object
|
|
137
|
+
* @returns Merged object
|
|
138
|
+
*/
|
|
139
|
+
function deepMerge(
|
|
140
|
+
target: SpeciConfig,
|
|
141
|
+
source: Partial<SpeciConfig>
|
|
142
|
+
): SpeciConfig {
|
|
143
|
+
const result = { ...target };
|
|
144
|
+
|
|
145
|
+
for (const key in source) {
|
|
146
|
+
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
|
|
147
|
+
|
|
148
|
+
const sourceValue = source[key as keyof SpeciConfig];
|
|
149
|
+
const targetValue = result[key as keyof SpeciConfig];
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
sourceValue !== null &&
|
|
153
|
+
sourceValue !== undefined &&
|
|
154
|
+
typeof sourceValue === 'object' &&
|
|
155
|
+
!Array.isArray(sourceValue) &&
|
|
156
|
+
typeof targetValue === 'object' &&
|
|
157
|
+
!Array.isArray(targetValue)
|
|
158
|
+
) {
|
|
159
|
+
(result as Record<string, unknown>)[key] = {
|
|
160
|
+
...targetValue,
|
|
161
|
+
...sourceValue,
|
|
162
|
+
};
|
|
163
|
+
} else if (sourceValue !== undefined) {
|
|
164
|
+
(result as Record<string, unknown>)[key] = sourceValue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check if path contains directory traversal attempts
|
|
173
|
+
* @param path - Path to check
|
|
174
|
+
* @returns true if path is safe
|
|
175
|
+
*/
|
|
176
|
+
function isSafePath(path: string): boolean {
|
|
177
|
+
// Check for explicit .. in path components
|
|
178
|
+
const parts = path.split(/[/\\]/);
|
|
179
|
+
if (parts.includes('..')) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Validate config against schema
|
|
188
|
+
*
|
|
189
|
+
* Merges raw config with defaults and validates all required fields
|
|
190
|
+
* and value constraints.
|
|
191
|
+
*
|
|
192
|
+
* @param rawConfig - Raw config object to validate
|
|
193
|
+
* @returns Validated config with defaults merged
|
|
194
|
+
* @throws {Error} ERR-INP-04 if config is invalid
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* const validated = validateConfig({ paths: { progress: 'custom.md' } });
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function validateConfig(rawConfig: Partial<SpeciConfig>): SpeciConfig {
|
|
202
|
+
const defaults = getDefaults();
|
|
203
|
+
const config = deepMerge(defaults, rawConfig);
|
|
204
|
+
|
|
205
|
+
// Validate version
|
|
206
|
+
if (config.version && !config.version.startsWith('1.')) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Config version '${config.version}' is not compatible. Expected: 1.x`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Validate paths for directory traversal
|
|
213
|
+
if (config.paths) {
|
|
214
|
+
for (const value of Object.values(config.paths)) {
|
|
215
|
+
if (value && !isSafePath(value)) {
|
|
216
|
+
throw new Error(`Path '${value}' attempts to escape project directory`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Validate copilot permissions
|
|
222
|
+
const validPermissions = ['allow-all', 'yolo', 'strict', 'none'];
|
|
223
|
+
if (
|
|
224
|
+
config.copilot.permissions &&
|
|
225
|
+
!validPermissions.includes(config.copilot.permissions)
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Invalid config value for 'copilot.permissions': must be one of ${validPermissions.join(', ')}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Validate maxFixAttempts
|
|
233
|
+
if (config.gate.maxFixAttempts < 1) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Invalid config value for 'gate.maxFixAttempts': must be at least 1`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Validate maxIterations
|
|
240
|
+
if (config.loop.maxIterations < 1) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Invalid config value for 'loop.maxIterations': must be at least 1`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return config;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Environment variable mapping configuration
|
|
251
|
+
*/
|
|
252
|
+
interface EnvMapping {
|
|
253
|
+
envVar: string;
|
|
254
|
+
configPath: string[];
|
|
255
|
+
type: 'string' | 'number' | 'boolean' | 'enum';
|
|
256
|
+
enumValues?: string[];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Environment variable to config path mappings
|
|
261
|
+
*/
|
|
262
|
+
const ENV_MAPPINGS: EnvMapping[] = [
|
|
263
|
+
// Path overrides
|
|
264
|
+
{ envVar: 'SPECI_LOG_PATH', configPath: ['paths', 'logs'], type: 'string' },
|
|
265
|
+
{ envVar: 'SPECI_LOGS_PATH', configPath: ['paths', 'logs'], type: 'string' },
|
|
266
|
+
{
|
|
267
|
+
envVar: 'SPECI_PROGRESS_PATH',
|
|
268
|
+
configPath: ['paths', 'progress'],
|
|
269
|
+
type: 'string',
|
|
270
|
+
},
|
|
271
|
+
{ envVar: 'SPECI_LOCK_PATH', configPath: ['paths', 'lock'], type: 'string' },
|
|
272
|
+
{
|
|
273
|
+
envVar: 'SPECI_TASKS_PATH',
|
|
274
|
+
configPath: ['paths', 'tasks'],
|
|
275
|
+
type: 'string',
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// Numeric overrides
|
|
279
|
+
{
|
|
280
|
+
envVar: 'SPECI_MAX_ITERATIONS',
|
|
281
|
+
configPath: ['loop', 'maxIterations'],
|
|
282
|
+
type: 'number',
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
envVar: 'SPECI_MAX_FIX_ATTEMPTS',
|
|
286
|
+
configPath: ['gate', 'maxFixAttempts'],
|
|
287
|
+
type: 'number',
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// String overrides
|
|
291
|
+
{
|
|
292
|
+
envVar: 'SPECI_COPILOT_MODEL',
|
|
293
|
+
configPath: ['copilot', 'model'],
|
|
294
|
+
type: 'string',
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
envVar: 'SPECI_MODEL',
|
|
298
|
+
configPath: ['copilot', 'model'],
|
|
299
|
+
type: 'string',
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
// Enum overrides
|
|
303
|
+
{
|
|
304
|
+
envVar: 'SPECI_COPILOT_PERMISSIONS',
|
|
305
|
+
configPath: ['copilot', 'permissions'],
|
|
306
|
+
type: 'enum',
|
|
307
|
+
enumValues: ['allow-all', 'yolo', 'strict', 'none'],
|
|
308
|
+
},
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Valid SPECI_* environment variable names
|
|
313
|
+
*/
|
|
314
|
+
const VALID_ENV_VARS = ENV_MAPPINGS.map((m) => m.envVar);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Calculate Levenshtein distance between two strings
|
|
318
|
+
* @param a - First string
|
|
319
|
+
* @param b - Second string
|
|
320
|
+
* @returns Edit distance
|
|
321
|
+
*/
|
|
322
|
+
function levenshtein(a: string, b: string): number {
|
|
323
|
+
const matrix: number[][] = [];
|
|
324
|
+
|
|
325
|
+
for (let i = 0; i <= b.length; i++) {
|
|
326
|
+
matrix[i] = [i];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (let j = 0; j <= a.length; j++) {
|
|
330
|
+
matrix[0][j] = j;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (let i = 1; i <= b.length; i++) {
|
|
334
|
+
for (let j = 1; j <= a.length; j++) {
|
|
335
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
336
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
337
|
+
} else {
|
|
338
|
+
matrix[i][j] = Math.min(
|
|
339
|
+
matrix[i - 1][j - 1] + 1,
|
|
340
|
+
matrix[i][j - 1] + 1,
|
|
341
|
+
matrix[i - 1][j] + 1
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return matrix[b.length][a.length];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Find similar env var names using Levenshtein distance
|
|
352
|
+
* @param input - Input env var name
|
|
353
|
+
* @param valid - List of valid env var names
|
|
354
|
+
* @returns Sorted list of similar names (distance <= 3)
|
|
355
|
+
*/
|
|
356
|
+
function findSimilarEnvVars(input: string, valid: string[]): string[] {
|
|
357
|
+
return valid
|
|
358
|
+
.map((v) => ({ var: v, distance: levenshtein(input, v) }))
|
|
359
|
+
.filter(({ distance }) => distance <= 3)
|
|
360
|
+
.sort((a, b) => a.distance - b.distance)
|
|
361
|
+
.map(({ var: v }) => v);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Detect potential typos in SPECI_* environment variables
|
|
366
|
+
*/
|
|
367
|
+
function detectEnvTypos(): void {
|
|
368
|
+
const speciEnvVars = Object.keys(process.env).filter((key) =>
|
|
369
|
+
key.startsWith('SPECI_')
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
for (const envVar of speciEnvVars) {
|
|
373
|
+
if (!VALID_ENV_VARS.includes(envVar)) {
|
|
374
|
+
const suggestions = findSimilarEnvVars(envVar, VALID_ENV_VARS);
|
|
375
|
+
|
|
376
|
+
if (suggestions.length > 0) {
|
|
377
|
+
log.warn(
|
|
378
|
+
`Warning: Unknown environment variable "${envVar}". ` +
|
|
379
|
+
`Did you mean "${suggestions[0]}"?`
|
|
380
|
+
);
|
|
381
|
+
} else {
|
|
382
|
+
log.warn(
|
|
383
|
+
`Warning: Unknown environment variable "${envVar}". ` +
|
|
384
|
+
`Valid SPECI_* variables: ${VALID_ENV_VARS.join(', ')}`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Parse and validate environment variable value
|
|
393
|
+
* @param value - Raw string value from env var
|
|
394
|
+
* @param mapping - Env mapping configuration
|
|
395
|
+
* @returns Validation result with parsed value
|
|
396
|
+
*/
|
|
397
|
+
function parseEnvValue(
|
|
398
|
+
value: string,
|
|
399
|
+
mapping: EnvMapping
|
|
400
|
+
): { valid: boolean; value?: unknown } {
|
|
401
|
+
switch (mapping.type) {
|
|
402
|
+
case 'string':
|
|
403
|
+
return { valid: true, value };
|
|
404
|
+
|
|
405
|
+
case 'number': {
|
|
406
|
+
const num = parseInt(value, 10);
|
|
407
|
+
if (isNaN(num) || num <= 0) {
|
|
408
|
+
return { valid: false };
|
|
409
|
+
}
|
|
410
|
+
return { valid: true, value: num };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case 'boolean': {
|
|
414
|
+
const lower = value.toLowerCase();
|
|
415
|
+
if (['1', 'true', 'yes'].includes(lower)) {
|
|
416
|
+
return { valid: true, value: true };
|
|
417
|
+
}
|
|
418
|
+
if (['0', 'false', 'no', ''].includes(lower)) {
|
|
419
|
+
return { valid: true, value: false };
|
|
420
|
+
}
|
|
421
|
+
return { valid: false };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
case 'enum': {
|
|
425
|
+
if (mapping.enumValues?.includes(value)) {
|
|
426
|
+
return { valid: true, value };
|
|
427
|
+
}
|
|
428
|
+
// Try case-insensitive match
|
|
429
|
+
const lower = value.toLowerCase();
|
|
430
|
+
const match = mapping.enumValues?.find((v) => v.toLowerCase() === lower);
|
|
431
|
+
if (match) {
|
|
432
|
+
return { valid: true, value: match };
|
|
433
|
+
}
|
|
434
|
+
return { valid: false };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
default:
|
|
438
|
+
return { valid: false };
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Set a nested value in an object using path array
|
|
444
|
+
* @param obj - Target object
|
|
445
|
+
* @param path - Path array (e.g., ['paths', 'logs'])
|
|
446
|
+
* @param value - Value to set
|
|
447
|
+
*/
|
|
448
|
+
function setNestedValue(
|
|
449
|
+
obj: Record<string, unknown>,
|
|
450
|
+
path: string[],
|
|
451
|
+
value: unknown
|
|
452
|
+
): void {
|
|
453
|
+
let current: Record<string, unknown> = obj;
|
|
454
|
+
|
|
455
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
456
|
+
if (!(path[i] in current)) {
|
|
457
|
+
current[path[i]] = {};
|
|
458
|
+
}
|
|
459
|
+
current = current[path[i]] as Record<string, unknown>;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
current[path[path.length - 1]] = value;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Apply environment variable overrides to config
|
|
467
|
+
* @param config - Config object to modify
|
|
468
|
+
*/
|
|
469
|
+
function applyEnvOverrides(config: SpeciConfig): void {
|
|
470
|
+
// Check for potential typos
|
|
471
|
+
detectEnvTypos();
|
|
472
|
+
|
|
473
|
+
for (const mapping of ENV_MAPPINGS) {
|
|
474
|
+
const value = process.env[mapping.envVar];
|
|
475
|
+
|
|
476
|
+
if (value === undefined || value === '') {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const parsed = parseEnvValue(value, mapping);
|
|
481
|
+
|
|
482
|
+
if (parsed.valid) {
|
|
483
|
+
setNestedValue(
|
|
484
|
+
config as unknown as Record<string, unknown>,
|
|
485
|
+
mapping.configPath,
|
|
486
|
+
parsed.value
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
log.debug(`Applying env override: ${mapping.envVar}=${parsed.value}`);
|
|
490
|
+
} else {
|
|
491
|
+
log.warn(
|
|
492
|
+
`Warning: Invalid value for ${mapping.envVar}: "${value}". ` +
|
|
493
|
+
`Expected ${mapping.type}${mapping.enumValues ? ` (${mapping.enumValues.join('|')})` : ''}. ` +
|
|
494
|
+
`Using config/default value instead.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Load and validate configuration
|
|
502
|
+
* Searches for speci.config.json starting from cwd, walking up parent directories.
|
|
503
|
+
* Applies defaults and environment variable overrides.
|
|
504
|
+
*
|
|
505
|
+
* @returns Validated SpeciConfig object
|
|
506
|
+
* @throws {Error} ERR-INP-03 if config file is malformed JSON
|
|
507
|
+
* @throws {Error} ERR-INP-04 if config fails schema validation
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* ```typescript
|
|
511
|
+
* const config = loadConfig();
|
|
512
|
+
* console.log(config.paths.progress); // 'docs/PROGRESS.md'
|
|
513
|
+
* ```
|
|
514
|
+
*/
|
|
515
|
+
export function loadConfig(): SpeciConfig {
|
|
516
|
+
const startTime = performance.now();
|
|
517
|
+
|
|
518
|
+
// Find config file
|
|
519
|
+
const configPath = findConfigFile(process.cwd());
|
|
520
|
+
|
|
521
|
+
let rawConfig: Partial<SpeciConfig> = {};
|
|
522
|
+
|
|
523
|
+
if (configPath) {
|
|
524
|
+
try {
|
|
525
|
+
const fileContent = readFileSync(configPath, 'utf-8');
|
|
526
|
+
rawConfig = JSON.parse(fileContent);
|
|
527
|
+
log.debug(`Loaded config from ${configPath}`);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error instanceof SyntaxError) {
|
|
530
|
+
throw new Error(`Config file has invalid JSON: ${error.message}`);
|
|
531
|
+
}
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Validate and merge with defaults
|
|
537
|
+
const config = validateConfig(rawConfig);
|
|
538
|
+
|
|
539
|
+
// Apply environment variable overrides
|
|
540
|
+
applyEnvOverrides(config);
|
|
541
|
+
|
|
542
|
+
const endTime = performance.now();
|
|
543
|
+
log.debug(`Config loaded in ${(endTime - startTime).toFixed(2)}ms`);
|
|
544
|
+
|
|
545
|
+
return config;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Resolve agent path to either custom or bundled template
|
|
550
|
+
*
|
|
551
|
+
* Checks config for custom agent path, falls back to bundled template
|
|
552
|
+
* if custom path not found or doesn't exist.
|
|
553
|
+
*
|
|
554
|
+
* @param config - Config object
|
|
555
|
+
* @param agentName - Name of agent to resolve (e.g., 'impl', 'review')
|
|
556
|
+
* @returns Absolute path to agent template file
|
|
557
|
+
* @throws {Error} ERR-INP-02 if neither custom nor bundled agent exists
|
|
558
|
+
*
|
|
559
|
+
* @example
|
|
560
|
+
* ```typescript
|
|
561
|
+
* const implPath = resolveAgentPath(config, 'impl');
|
|
562
|
+
* // Returns: '/path/to/templates/agents/speci-impl.md'
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
export function resolveAgentPath(
|
|
566
|
+
config: SpeciConfig,
|
|
567
|
+
agentName: AgentName
|
|
568
|
+
): string {
|
|
569
|
+
const customPath = config.agents[agentName];
|
|
570
|
+
|
|
571
|
+
// Check for custom agent path in config
|
|
572
|
+
if (customPath && typeof customPath === 'string') {
|
|
573
|
+
const absolutePath = isAbsolute(customPath)
|
|
574
|
+
? customPath
|
|
575
|
+
: join(process.cwd(), customPath);
|
|
576
|
+
|
|
577
|
+
if (existsSync(absolutePath)) {
|
|
578
|
+
log.debug(`Using custom agent: ${absolutePath}`);
|
|
579
|
+
return absolutePath;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
log.warn(
|
|
583
|
+
`Custom agent not found: ${customPath}, falling back to bundled agent`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Fall back to bundled template
|
|
588
|
+
const bundledPath = join(TEMPLATES_DIR, 'agents', `speci-${agentName}.md`);
|
|
589
|
+
|
|
590
|
+
if (!existsSync(bundledPath)) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`Agent not found: speci-${agentName}.md. ` +
|
|
593
|
+
`Neither custom nor bundled agent exists.`
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
log.debug(`Using bundled agent: ${bundledPath}`);
|
|
598
|
+
return bundledPath;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Resolve subagent prompt path - always bundled, no custom override
|
|
603
|
+
* @param subagentName - Subagent name (e.g., 'task_generator', 'plan_requirements_deep_dive')
|
|
604
|
+
* @returns Absolute path to subagent prompt file
|
|
605
|
+
* @throws Error if subagent prompt not found
|
|
606
|
+
*/
|
|
607
|
+
export function resolveSubagentPath(subagentName: string): string {
|
|
608
|
+
const bundledPath = join(
|
|
609
|
+
TEMPLATES_DIR,
|
|
610
|
+
'agents',
|
|
611
|
+
'subagents',
|
|
612
|
+
`${subagentName}.prompt.md`
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
if (!existsSync(bundledPath)) {
|
|
616
|
+
throw new Error(`Subagent prompt not found: ${subagentName}.prompt.md`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return bundledPath;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Get path to config template for init command
|
|
624
|
+
* @returns Absolute path to config template
|
|
625
|
+
*/
|
|
626
|
+
export function getConfigTemplatePath(): string {
|
|
627
|
+
return join(TEMPLATES_DIR, 'speci.config.json');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Get path to agents template directory for init command
|
|
632
|
+
* @returns Absolute path to agents template directory
|
|
633
|
+
*/
|
|
634
|
+
export function getAgentsTemplatePath(): string {
|
|
635
|
+
return join(TEMPLATES_DIR, 'agents');
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Get path to subagents template directory
|
|
640
|
+
* @returns Absolute path to subagents template directory
|
|
641
|
+
*/
|
|
642
|
+
export function getSubagentsTemplatePath(): string {
|
|
643
|
+
return join(TEMPLATES_DIR, 'agents', 'subagents');
|
|
644
|
+
}
|