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.
Files changed (70) hide show
  1. package/README.md +523 -0
  2. package/bin/speci.ts +228 -0
  3. package/lib/commands/init.ts +299 -0
  4. package/lib/commands/monitor.ts +579 -0
  5. package/lib/commands/plan.ts +112 -0
  6. package/lib/commands/refactor.ts +157 -0
  7. package/lib/commands/run.ts +531 -0
  8. package/lib/commands/status.ts +209 -0
  9. package/lib/commands/task.ts +133 -0
  10. package/lib/config.ts +644 -0
  11. package/lib/copilot.ts +229 -0
  12. package/lib/errors.ts +166 -0
  13. package/lib/state.ts +148 -0
  14. package/lib/ui/banner.ts +109 -0
  15. package/lib/ui/box.ts +161 -0
  16. package/lib/ui/colors.ts +110 -0
  17. package/lib/ui/glyphs.ts +91 -0
  18. package/lib/ui/palette.ts +118 -0
  19. package/lib/ui/terminal.ts +118 -0
  20. package/lib/utils/atomic-write.ts +147 -0
  21. package/lib/utils/gate.ts +197 -0
  22. package/lib/utils/i18n.ts +92 -0
  23. package/lib/utils/lock.ts +189 -0
  24. package/lib/utils/logger.ts +143 -0
  25. package/lib/utils/preflight.ts +236 -0
  26. package/lib/utils/process.ts +127 -0
  27. package/lib/utils/signals.ts +145 -0
  28. package/lib/utils/suggest.ts +71 -0
  29. package/package.json +38 -0
  30. package/templates/agents/speci-fix.md +107 -0
  31. package/templates/agents/speci-impl.md +152 -0
  32. package/templates/agents/speci-plan.md +771 -0
  33. package/templates/agents/speci-refactor.md +652 -0
  34. package/templates/agents/speci-review.md +169 -0
  35. package/templates/agents/speci-task.md +369 -0
  36. package/templates/agents/speci-tidy.md +84 -0
  37. package/templates/agents/subagents/final_reviewer.prompt.md +143 -0
  38. package/templates/agents/subagents/mvt_generator.prompt.md +171 -0
  39. package/templates/agents/subagents/plan_codebase_context.prompt.md +29 -0
  40. package/templates/agents/subagents/plan_initial_planner.prompt.md +31 -0
  41. package/templates/agents/subagents/plan_refine_architecture.prompt.md +21 -0
  42. package/templates/agents/subagents/plan_refine_dataflow.prompt.md +23 -0
  43. package/templates/agents/subagents/plan_refine_edgecases.prompt.md +23 -0
  44. package/templates/agents/subagents/plan_refine_errors.prompt.md +22 -0
  45. package/templates/agents/subagents/plan_refine_final.prompt.md +25 -0
  46. package/templates/agents/subagents/plan_refine_integration.prompt.md +22 -0
  47. package/templates/agents/subagents/plan_refine_performance.prompt.md +23 -0
  48. package/templates/agents/subagents/plan_refine_requirements.prompt.md +16 -0
  49. package/templates/agents/subagents/plan_refine_security.prompt.md +22 -0
  50. package/templates/agents/subagents/plan_refine_testing.prompt.md +21 -0
  51. package/templates/agents/subagents/plan_requirements_deep_dive.prompt.md +30 -0
  52. package/templates/agents/subagents/progress_generator.prompt.md +178 -0
  53. package/templates/agents/subagents/refactor_analyze_crosscutting.prompt.md +66 -0
  54. package/templates/agents/subagents/refactor_analyze_duplication.prompt.md +65 -0
  55. package/templates/agents/subagents/refactor_analyze_errors.prompt.md +65 -0
  56. package/templates/agents/subagents/refactor_analyze_functions.prompt.md +66 -0
  57. package/templates/agents/subagents/refactor_analyze_naming.prompt.md +65 -0
  58. package/templates/agents/subagents/refactor_analyze_performance.prompt.md +66 -0
  59. package/templates/agents/subagents/refactor_analyze_state.prompt.md +66 -0
  60. package/templates/agents/subagents/refactor_analyze_structure.prompt.md +64 -0
  61. package/templates/agents/subagents/refactor_analyze_testing.prompt.md +66 -0
  62. package/templates/agents/subagents/refactor_analyze_types.prompt.md +66 -0
  63. package/templates/agents/subagents/refactor_review_completeness.prompt.md +63 -0
  64. package/templates/agents/subagents/refactor_review_final.prompt.md +63 -0
  65. package/templates/agents/subagents/refactor_review_risks.prompt.md +63 -0
  66. package/templates/agents/subagents/refactor_review_roadmap.prompt.md +63 -0
  67. package/templates/agents/subagents/refactor_review_technical.prompt.md +63 -0
  68. package/templates/agents/subagents/task_generator.prompt.md +145 -0
  69. package/templates/agents/subagents/task_reviewer.prompt.md +85 -0
  70. 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
+ }