ralphctl 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/CHANGELOG.md +94 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/ralphctl +13 -0
- package/package.json +92 -0
- package/schemas/config.schema.json +20 -0
- package/schemas/ideate-output.schema.json +22 -0
- package/schemas/projects.schema.json +53 -0
- package/schemas/requirements-output.schema.json +24 -0
- package/schemas/sprint.schema.json +109 -0
- package/schemas/task-import.schema.json +49 -0
- package/schemas/tasks.schema.json +72 -0
- package/src/ai/executor.ts +973 -0
- package/src/ai/lifecycle.ts +45 -0
- package/src/ai/parser.ts +40 -0
- package/src/ai/permissions.ts +207 -0
- package/src/ai/process-manager.ts +248 -0
- package/src/ai/prompts/ideate-auto.md +144 -0
- package/src/ai/prompts/ideate.md +165 -0
- package/src/ai/prompts/index.ts +89 -0
- package/src/ai/prompts/plan-auto.md +131 -0
- package/src/ai/prompts/plan-common.md +157 -0
- package/src/ai/prompts/plan-interactive.md +190 -0
- package/src/ai/prompts/task-execution.md +159 -0
- package/src/ai/prompts/ticket-refine.md +230 -0
- package/src/ai/rate-limiter.ts +89 -0
- package/src/ai/runner.ts +478 -0
- package/src/ai/session.ts +319 -0
- package/src/ai/task-context.ts +270 -0
- package/src/cli-metadata.ts +7 -0
- package/src/cli.ts +65 -0
- package/src/commands/completion/index.ts +33 -0
- package/src/commands/config/config.ts +58 -0
- package/src/commands/config/index.ts +33 -0
- package/src/commands/dashboard/dashboard.ts +5 -0
- package/src/commands/dashboard/index.ts +6 -0
- package/src/commands/doctor/doctor.ts +271 -0
- package/src/commands/doctor/index.ts +25 -0
- package/src/commands/progress/index.ts +25 -0
- package/src/commands/progress/log.ts +64 -0
- package/src/commands/progress/show.ts +14 -0
- package/src/commands/project/add.ts +336 -0
- package/src/commands/project/index.ts +104 -0
- package/src/commands/project/list.ts +31 -0
- package/src/commands/project/remove.ts +43 -0
- package/src/commands/project/repo.ts +118 -0
- package/src/commands/project/show.ts +49 -0
- package/src/commands/sprint/close.ts +180 -0
- package/src/commands/sprint/context.ts +109 -0
- package/src/commands/sprint/create.ts +60 -0
- package/src/commands/sprint/current.ts +75 -0
- package/src/commands/sprint/delete.ts +72 -0
- package/src/commands/sprint/health.ts +229 -0
- package/src/commands/sprint/ideate.ts +496 -0
- package/src/commands/sprint/index.ts +226 -0
- package/src/commands/sprint/list.ts +86 -0
- package/src/commands/sprint/plan-utils.ts +207 -0
- package/src/commands/sprint/plan.ts +549 -0
- package/src/commands/sprint/refine.ts +359 -0
- package/src/commands/sprint/requirements.ts +58 -0
- package/src/commands/sprint/show.ts +140 -0
- package/src/commands/sprint/start.ts +119 -0
- package/src/commands/sprint/switch.ts +20 -0
- package/src/commands/task/add.ts +316 -0
- package/src/commands/task/import.ts +150 -0
- package/src/commands/task/index.ts +123 -0
- package/src/commands/task/list.ts +145 -0
- package/src/commands/task/next.ts +45 -0
- package/src/commands/task/remove.ts +47 -0
- package/src/commands/task/reorder.ts +45 -0
- package/src/commands/task/show.ts +111 -0
- package/src/commands/task/status.ts +99 -0
- package/src/commands/ticket/add.ts +265 -0
- package/src/commands/ticket/edit.ts +166 -0
- package/src/commands/ticket/index.ts +114 -0
- package/src/commands/ticket/list.ts +128 -0
- package/src/commands/ticket/refine-utils.ts +89 -0
- package/src/commands/ticket/refine.ts +268 -0
- package/src/commands/ticket/remove.ts +48 -0
- package/src/commands/ticket/show.ts +74 -0
- package/src/completion/handle.ts +30 -0
- package/src/completion/resolver.ts +241 -0
- package/src/interactive/dashboard.ts +268 -0
- package/src/interactive/escapable.ts +81 -0
- package/src/interactive/file-browser.ts +153 -0
- package/src/interactive/index.ts +429 -0
- package/src/interactive/menu.ts +403 -0
- package/src/interactive/selectors.ts +273 -0
- package/src/interactive/wizard.ts +221 -0
- package/src/providers/claude.ts +53 -0
- package/src/providers/copilot.ts +86 -0
- package/src/providers/index.ts +43 -0
- package/src/providers/types.ts +85 -0
- package/src/schemas/index.ts +130 -0
- package/src/store/config.ts +74 -0
- package/src/store/progress.ts +230 -0
- package/src/store/project.ts +276 -0
- package/src/store/sprint.ts +229 -0
- package/src/store/task.ts +443 -0
- package/src/store/ticket.ts +178 -0
- package/src/theme/index.ts +215 -0
- package/src/theme/ui.ts +872 -0
- package/src/utils/detect-scripts.ts +247 -0
- package/src/utils/editor-input.ts +41 -0
- package/src/utils/editor.ts +37 -0
- package/src/utils/exit-codes.ts +27 -0
- package/src/utils/file-lock.ts +135 -0
- package/src/utils/git.ts +185 -0
- package/src/utils/ids.ts +37 -0
- package/src/utils/issue-fetch.ts +244 -0
- package/src/utils/json-extract.ts +62 -0
- package/src/utils/multiline.ts +61 -0
- package/src/utils/path-selector.ts +236 -0
- package/src/utils/paths.ts +108 -0
- package/src/utils/provider.ts +34 -0
- package/src/utils/requirements-export.ts +63 -0
- package/src/utils/storage.ts +107 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { colors } from '@src/theme/index.ts';
|
|
3
|
+
import {
|
|
4
|
+
emoji,
|
|
5
|
+
icons,
|
|
6
|
+
log,
|
|
7
|
+
printHeader,
|
|
8
|
+
printSeparator,
|
|
9
|
+
progressBar,
|
|
10
|
+
showSuccess,
|
|
11
|
+
showWarning,
|
|
12
|
+
} from '@src/theme/ui.ts';
|
|
13
|
+
import { sprintCreateCommand } from '@src/commands/sprint/create.ts';
|
|
14
|
+
import { addSingleTicketInteractive } from '@src/commands/ticket/add.ts';
|
|
15
|
+
import { sprintRefineCommand } from '@src/commands/sprint/refine.ts';
|
|
16
|
+
import { sprintPlanCommand } from '@src/commands/sprint/plan.ts';
|
|
17
|
+
import { sprintStartCommand } from '@src/commands/sprint/start.ts';
|
|
18
|
+
import { getCurrentSprint } from '@src/store/config.ts';
|
|
19
|
+
import { getSprint } from '@src/store/sprint.ts';
|
|
20
|
+
|
|
21
|
+
const TOTAL_STEPS = 5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Display a step progress indicator with a progress bar.
|
|
25
|
+
*/
|
|
26
|
+
function showStepProgress(step: number, title: string): void {
|
|
27
|
+
const bar = progressBar(step - 1, TOTAL_STEPS, { width: 10, showPercent: false });
|
|
28
|
+
log.newline();
|
|
29
|
+
printSeparator();
|
|
30
|
+
console.log(` ${colors.highlight(`Step ${String(step)} of ${String(TOTAL_STEPS)}`)} ${bar} ${title}`);
|
|
31
|
+
log.newline();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run the sprint setup wizard -- a multi-step guided flow that walks the user
|
|
36
|
+
* through creating a sprint, adding tickets, refining, planning, and starting.
|
|
37
|
+
*/
|
|
38
|
+
export async function runWizard(): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
printHeader('Sprint Setup Wizard', emoji.donut);
|
|
41
|
+
log.dim('This wizard will guide you through setting up a new sprint.');
|
|
42
|
+
log.dim('You can skip optional steps along the way.');
|
|
43
|
+
log.newline();
|
|
44
|
+
|
|
45
|
+
// ── Step 1: Create Sprint ──────────────────────────────────────────────
|
|
46
|
+
showStepProgress(1, 'Create Sprint');
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await sprintCreateCommand({ interactive: true });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err instanceof Error) {
|
|
52
|
+
log.error(`Sprint creation failed: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
log.newline();
|
|
55
|
+
showWarning('Cannot continue without a sprint. Wizard aborted.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sprintId = await getCurrentSprint();
|
|
60
|
+
if (!sprintId) {
|
|
61
|
+
showWarning('No current sprint set. Wizard aborted.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Step 2: Add Tickets ────────────────────────────────────────────────
|
|
66
|
+
showStepProgress(2, 'Add Tickets');
|
|
67
|
+
|
|
68
|
+
let ticketCount = 0;
|
|
69
|
+
let addMore = true;
|
|
70
|
+
|
|
71
|
+
while (addMore) {
|
|
72
|
+
try {
|
|
73
|
+
const ticket = await addSingleTicketInteractive({});
|
|
74
|
+
if (ticket) {
|
|
75
|
+
ticketCount++;
|
|
76
|
+
} else {
|
|
77
|
+
// No ticket created (no projects, or unrecoverable error) — exit loop
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof Error) {
|
|
82
|
+
log.error(`Failed to add ticket: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
log.newline();
|
|
87
|
+
addMore = await confirm({
|
|
88
|
+
message: `${emoji.donut} Add another ticket?`,
|
|
89
|
+
default: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ticketCount === 0) {
|
|
94
|
+
log.newline();
|
|
95
|
+
showWarning('No tickets added. You can add them later with: ralphctl ticket add');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Step 3: Refine Requirements ────────────────────────────────────────
|
|
99
|
+
showStepProgress(3, 'Refine Requirements');
|
|
100
|
+
|
|
101
|
+
if (ticketCount === 0) {
|
|
102
|
+
log.dim('Skipped -- no tickets to refine.');
|
|
103
|
+
} else {
|
|
104
|
+
const shouldRefine = await confirm({
|
|
105
|
+
message: `${emoji.donut} Refine requirements now?`,
|
|
106
|
+
default: true,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (shouldRefine) {
|
|
110
|
+
try {
|
|
111
|
+
await sprintRefineCommand([]);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof Error) {
|
|
114
|
+
log.error(`Refinement failed: ${err.message}`);
|
|
115
|
+
}
|
|
116
|
+
log.dim('You can refine later with: ralphctl sprint refine');
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
log.dim('Skipped. You can refine later with: ralphctl sprint refine');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Step 4: Plan Tasks ─────────────────────────────────────────────────
|
|
124
|
+
showStepProgress(4, 'Plan Tasks');
|
|
125
|
+
|
|
126
|
+
// Check if refinement was completed (all requirements approved)
|
|
127
|
+
let canPlan = false;
|
|
128
|
+
try {
|
|
129
|
+
const sprint = await getSprint(sprintId);
|
|
130
|
+
const hasTickets = sprint.tickets.length > 0;
|
|
131
|
+
const allApproved = hasTickets && sprint.tickets.every((t) => t.requirementStatus === 'approved');
|
|
132
|
+
canPlan = allApproved;
|
|
133
|
+
|
|
134
|
+
if (!hasTickets) {
|
|
135
|
+
log.dim('Skipped -- no tickets to plan.');
|
|
136
|
+
} else if (!allApproved) {
|
|
137
|
+
log.dim('Skipped -- not all requirements are approved yet.');
|
|
138
|
+
log.dim('Refine first with: ralphctl sprint refine');
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
log.dim('Skipped -- could not read sprint state.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (canPlan) {
|
|
145
|
+
const shouldPlan = await confirm({
|
|
146
|
+
message: `${emoji.donut} Generate tasks now?`,
|
|
147
|
+
default: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (shouldPlan) {
|
|
151
|
+
try {
|
|
152
|
+
await sprintPlanCommand([]);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err instanceof Error) {
|
|
155
|
+
log.error(`Planning failed: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
log.dim('You can plan later with: ralphctl sprint plan');
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
log.dim('Skipped. You can plan later with: ralphctl sprint plan');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Step 5: Start Execution ────────────────────────────────────────────
|
|
165
|
+
showStepProgress(5, 'Start Execution');
|
|
166
|
+
|
|
167
|
+
const shouldStart = await confirm({
|
|
168
|
+
message: `${emoji.donut} Start execution now?`,
|
|
169
|
+
default: false,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (shouldStart) {
|
|
173
|
+
try {
|
|
174
|
+
// Note: sprintStartCommand may call process.exit() on completion
|
|
175
|
+
await sprintStartCommand([]);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err instanceof Error) {
|
|
178
|
+
log.error(`Execution failed: ${err.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Completion Summary ─────────────────────────────────────────────────
|
|
185
|
+
log.newline();
|
|
186
|
+
printSeparator();
|
|
187
|
+
showSuccess('Wizard complete!');
|
|
188
|
+
log.newline();
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const sprint = await getSprint(sprintId);
|
|
192
|
+
log.info(`Sprint "${sprint.name}" is ready.`);
|
|
193
|
+
log.item(`${icons.ticket} ${String(sprint.tickets.length)} ticket(s)`);
|
|
194
|
+
|
|
195
|
+
const approvedCount = sprint.tickets.filter((t) => t.requirementStatus === 'approved').length;
|
|
196
|
+
if (sprint.tickets.length > 0) {
|
|
197
|
+
log.item(`${icons.success} ${String(approvedCount)}/${String(sprint.tickets.length)} requirements approved`);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Sprint read failed, skip summary details
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
log.newline();
|
|
204
|
+
log.dim('Next steps:');
|
|
205
|
+
if (ticketCount === 0) {
|
|
206
|
+
log.item('ralphctl ticket add --project <name>');
|
|
207
|
+
}
|
|
208
|
+
log.item('ralphctl sprint refine');
|
|
209
|
+
log.item('ralphctl sprint plan');
|
|
210
|
+
log.item('ralphctl sprint start');
|
|
211
|
+
log.newline();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
if ((err as Error).name === 'ExitPromptError') {
|
|
214
|
+
log.newline();
|
|
215
|
+
showWarning('Wizard cancelled');
|
|
216
|
+
log.newline();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ProviderAdapter, RateLimitInfo } from '@src/providers/types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Claude Code CLI adapter.
|
|
5
|
+
*
|
|
6
|
+
* Maps to the `claude` binary with `--permission-mode acceptEdits`.
|
|
7
|
+
*/
|
|
8
|
+
export const claudeAdapter: ProviderAdapter = {
|
|
9
|
+
name: 'claude',
|
|
10
|
+
displayName: 'Claude',
|
|
11
|
+
binary: 'claude',
|
|
12
|
+
baseArgs: ['--permission-mode', 'acceptEdits'],
|
|
13
|
+
|
|
14
|
+
experimental: false,
|
|
15
|
+
|
|
16
|
+
buildInteractiveArgs(prompt: string, extraArgs: string[] = []): string[] {
|
|
17
|
+
return [...this.baseArgs, ...extraArgs, '--', prompt];
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
buildHeadlessArgs(extraArgs: string[] = []): string[] {
|
|
21
|
+
return ['-p', '--output-format', 'json', ...this.baseArgs, ...extraArgs];
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
parseJsonOutput(stdout: string): { result: string; sessionId: string | null } {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(stdout) as {
|
|
27
|
+
result?: string;
|
|
28
|
+
session_id?: string;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
result: parsed.result ?? stdout,
|
|
32
|
+
sessionId: parsed.session_id ?? null,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { result: stdout, sessionId: null };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
detectRateLimit(stderr: string): RateLimitInfo {
|
|
40
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
41
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
42
|
+
if (!isRateLimited) {
|
|
43
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
44
|
+
}
|
|
45
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
46
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1000 : null;
|
|
47
|
+
return { rateLimited: true, retryAfterMs };
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
getSpawnEnv(): Record<string, string> {
|
|
51
|
+
return { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1' };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { lstat, readdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { ProviderAdapter, RateLimitInfo } from '@src/providers/types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GitHub Copilot CLI adapter.
|
|
7
|
+
*
|
|
8
|
+
* Maps to the `copilot` binary with `--allow-all-tools`.
|
|
9
|
+
*
|
|
10
|
+
* Key differences from Claude Code CLI:
|
|
11
|
+
* - Interactive mode uses `-i PROMPT` (not `-- PROMPT`)
|
|
12
|
+
* - No `--output-format json`; uses `-s` (silent) for clean stdout
|
|
13
|
+
* - Headless output is plain text — session_id is not in stdout, but can be
|
|
14
|
+
* captured via `--share` which writes `./copilot-session-<ID>.md` on exit
|
|
15
|
+
* - Requires `--autopilot` for autonomous continuation in headless mode
|
|
16
|
+
* - Requires `--no-ask-user` to suppress interactive prompts in headless mode
|
|
17
|
+
* - Status: public preview (experimental: true)
|
|
18
|
+
*/
|
|
19
|
+
export const copilotAdapter: ProviderAdapter = {
|
|
20
|
+
name: 'copilot',
|
|
21
|
+
displayName: 'Copilot',
|
|
22
|
+
binary: 'copilot',
|
|
23
|
+
experimental: true,
|
|
24
|
+
baseArgs: ['--allow-all-tools'],
|
|
25
|
+
|
|
26
|
+
buildInteractiveArgs(prompt: string, extraArgs: string[] = []): string[] {
|
|
27
|
+
return [...this.baseArgs, ...extraArgs, '-i', prompt];
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
buildHeadlessArgs(extraArgs: string[] = []): string[] {
|
|
31
|
+
// -p: execute prompt programmatically (exits after completion)
|
|
32
|
+
// -s: silent — output only the agent response (no usage stats)
|
|
33
|
+
// --autopilot: enable autonomous continuation without user intervention
|
|
34
|
+
// --no-ask-user: disable ask_user tool so agent doesn't block waiting for input
|
|
35
|
+
// --share: write session to ./copilot-session-<ID>.md so we can capture the session ID
|
|
36
|
+
return ['-p', '-s', '--autopilot', '--no-ask-user', '--share', ...this.baseArgs, ...extraArgs];
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
parseJsonOutput(stdout: string): { result: string; sessionId: string | null } {
|
|
40
|
+
// Copilot CLI outputs plain text (no JSON mode), so return as-is.
|
|
41
|
+
// Session ID is captured separately via extractSessionId (--share output file).
|
|
42
|
+
return { result: stdout.trim(), sessionId: null };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async extractSessionId(cwd: string): Promise<string | null> {
|
|
46
|
+
// --share writes ./copilot-session-<ID>.md in the CWD when the process exits.
|
|
47
|
+
// Glob for the file, extract the ID from the filename, then clean it up.
|
|
48
|
+
try {
|
|
49
|
+
const files = await readdir(cwd);
|
|
50
|
+
// Session ID must start with alphanumeric/underscore (not hyphen) to prevent argument injection
|
|
51
|
+
const shareFile = files.find((f) => /^copilot-session-[a-zA-Z0-9_][a-zA-Z0-9_-]*\.md$/.test(f));
|
|
52
|
+
if (!shareFile) return null;
|
|
53
|
+
const match = /^copilot-session-([a-zA-Z0-9_][a-zA-Z0-9_-]{0,127})\.md$/.exec(shareFile);
|
|
54
|
+
if (!match?.[1]) return null;
|
|
55
|
+
// Only delete regular files — refuse symlinks to prevent TOCTOU attacks
|
|
56
|
+
const filePath = join(cwd, shareFile);
|
|
57
|
+
const stat = await lstat(filePath).catch(() => null);
|
|
58
|
+
if (stat?.isFile()) {
|
|
59
|
+
await unlink(filePath).catch(() => {
|
|
60
|
+
// Best-effort cleanup — don't fail session ID capture if unlink fails
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return match[1];
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
detectRateLimit(stderr: string): RateLimitInfo {
|
|
70
|
+
// TODO: These patterns are borrowed from the Claude adapter and have not been validated
|
|
71
|
+
// against real Copilot CLI rate-limit error messages. If Copilot CLI produces different
|
|
72
|
+
// error output (e.g. GitHub API 429 responses), add patterns here based on real observations.
|
|
73
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
74
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
75
|
+
if (!isRateLimited) {
|
|
76
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
77
|
+
}
|
|
78
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
79
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1000 : null;
|
|
80
|
+
return { rateLimited: true, retryAfterMs };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
getSpawnEnv(): Record<string, string> {
|
|
84
|
+
return {};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AiProvider } from '@src/schemas/index.ts';
|
|
2
|
+
import type { ProviderAdapter } from '@src/providers/types.ts';
|
|
3
|
+
import { claudeAdapter } from '@src/providers/claude.ts';
|
|
4
|
+
import { copilotAdapter } from '@src/providers/copilot.ts';
|
|
5
|
+
import { resolveProvider } from '@src/utils/provider.ts';
|
|
6
|
+
import { showWarning } from '@src/theme/ui.ts';
|
|
7
|
+
|
|
8
|
+
export type { ProviderAdapter } from '@src/providers/types.ts';
|
|
9
|
+
export type {
|
|
10
|
+
HeadlessSpawnOptions,
|
|
11
|
+
RateLimitInfo,
|
|
12
|
+
SpawnAsyncOptions,
|
|
13
|
+
SpawnInteractiveResult,
|
|
14
|
+
SpawnResult,
|
|
15
|
+
SpawnSyncOptions,
|
|
16
|
+
} from '@src/providers/types.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the adapter for a specific provider.
|
|
20
|
+
*/
|
|
21
|
+
export function getProvider(provider: AiProvider): ProviderAdapter {
|
|
22
|
+
switch (provider) {
|
|
23
|
+
case 'claude':
|
|
24
|
+
return claudeAdapter;
|
|
25
|
+
case 'copilot':
|
|
26
|
+
return copilotAdapter;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the active provider from config (prompting on first use)
|
|
32
|
+
* and return its adapter.
|
|
33
|
+
*
|
|
34
|
+
* Prints a warning when the resolved provider is marked experimental.
|
|
35
|
+
*/
|
|
36
|
+
export async function getActiveProvider(): Promise<ProviderAdapter> {
|
|
37
|
+
const provider = await resolveProvider();
|
|
38
|
+
const adapter = getProvider(provider);
|
|
39
|
+
if (adapter.experimental) {
|
|
40
|
+
showWarning(`${adapter.displayName} provider is in public preview — some features may not work as expected.`);
|
|
41
|
+
}
|
|
42
|
+
return adapter;
|
|
43
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AiProvider } from '@src/schemas/index.ts';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Spawn options & results (provider-agnostic)
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface SpawnSyncOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SpawnAsyncOptions {
|
|
14
|
+
cwd: string;
|
|
15
|
+
args?: string[];
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HeadlessSpawnOptions extends SpawnAsyncOptions {
|
|
20
|
+
prompt?: string;
|
|
21
|
+
resumeSessionId?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SpawnResult {
|
|
25
|
+
stdout: string;
|
|
26
|
+
stderr: string;
|
|
27
|
+
exitCode: number;
|
|
28
|
+
/** Session ID from CLI (available with --output-format json) */
|
|
29
|
+
sessionId: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SpawnInteractiveResult {
|
|
33
|
+
code: number;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Rate limit detection
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export interface RateLimitInfo {
|
|
42
|
+
rateLimited: boolean;
|
|
43
|
+
retryAfterMs: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Provider adapter interface
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
export interface ProviderAdapter {
|
|
51
|
+
readonly name: AiProvider;
|
|
52
|
+
readonly displayName: string;
|
|
53
|
+
readonly binary: string;
|
|
54
|
+
|
|
55
|
+
/** Base CLI args for permission/tool access. */
|
|
56
|
+
readonly baseArgs: string[];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether this provider is experimental (not fully stable).
|
|
60
|
+
* Copilot CLI is in public preview; Claude Code is GA.
|
|
61
|
+
*/
|
|
62
|
+
readonly experimental: boolean;
|
|
63
|
+
|
|
64
|
+
/** Build args for interactive mode (inherits stdio). */
|
|
65
|
+
buildInteractiveArgs(prompt: string, extraArgs?: string[]): string[];
|
|
66
|
+
|
|
67
|
+
/** Build args for headless/print mode (captures stdout). */
|
|
68
|
+
buildHeadlessArgs(extraArgs?: string[]): string[];
|
|
69
|
+
|
|
70
|
+
/** Parse JSON output from --output-format json. */
|
|
71
|
+
parseJsonOutput(stdout: string): { result: string; sessionId: string | null };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract a session ID after a headless process completes.
|
|
75
|
+
* Called when parseJsonOutput returns sessionId: null.
|
|
76
|
+
* Copilot: parses the --share output file; Claude: not needed (JSON output has it).
|
|
77
|
+
*/
|
|
78
|
+
extractSessionId?(cwd: string): Promise<string | null>;
|
|
79
|
+
|
|
80
|
+
/** Detect rate limit signals in stderr. */
|
|
81
|
+
detectRateLimit(stderr: string): RateLimitInfo;
|
|
82
|
+
|
|
83
|
+
/** Provider-specific env vars to set for a spawn. Claude-only example: CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD */
|
|
84
|
+
getSpawnEnv(): Record<string, string>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Sprint statuses (one-way transitions: draft → active → closed)
|
|
4
|
+
export const SprintStatusSchema = z.enum(['draft', 'active', 'closed']);
|
|
5
|
+
export type SprintStatus = z.infer<typeof SprintStatusSchema>;
|
|
6
|
+
|
|
7
|
+
// Task statuses (kanban flow: todo → in_progress → done)
|
|
8
|
+
export const TaskStatusSchema = z.enum(['todo', 'in_progress', 'done']);
|
|
9
|
+
export type TaskStatus = z.infer<typeof TaskStatusSchema>;
|
|
10
|
+
|
|
11
|
+
// Requirement status for tickets (pending → approved)
|
|
12
|
+
export const RequirementStatusSchema = z.enum(['pending', 'approved']);
|
|
13
|
+
export type RequirementStatus = z.infer<typeof RequirementStatusSchema>;
|
|
14
|
+
|
|
15
|
+
// Repository schema (a single repository within a project)
|
|
16
|
+
export const RepositorySchema = z.object({
|
|
17
|
+
name: z.string().min(1), // Auto-derived from basename(path)
|
|
18
|
+
path: z.string().min(1), // Absolute path
|
|
19
|
+
checkScript: z.string().optional(), // e.g., "pnpm install && pnpm typecheck && pnpm lint && pnpm test"
|
|
20
|
+
});
|
|
21
|
+
export type Repository = z.infer<typeof RepositorySchema>;
|
|
22
|
+
|
|
23
|
+
// Project schema (multi-repo project definition)
|
|
24
|
+
export const ProjectSchema = z.object({
|
|
25
|
+
name: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.regex(/^[a-z0-9-]+$/, 'Project name must be a slug (lowercase, numbers, hyphens only)'),
|
|
29
|
+
displayName: z.string().min(1),
|
|
30
|
+
repositories: z.array(RepositorySchema).min(1),
|
|
31
|
+
description: z.string().optional(),
|
|
32
|
+
});
|
|
33
|
+
export type Project = z.infer<typeof ProjectSchema>;
|
|
34
|
+
|
|
35
|
+
// Projects array schema
|
|
36
|
+
export const ProjectsSchema = z.array(ProjectSchema);
|
|
37
|
+
export type Projects = z.infer<typeof ProjectsSchema>;
|
|
38
|
+
|
|
39
|
+
// Ticket schema (ticket to be planned)
|
|
40
|
+
export const TicketSchema = z.object({
|
|
41
|
+
id: z.string().min(1), // Internal UUID8 (auto-generated)
|
|
42
|
+
title: z.string().min(1),
|
|
43
|
+
description: z.string().optional(),
|
|
44
|
+
link: z.url().optional(),
|
|
45
|
+
projectName: z.string().min(1), // References Project.name
|
|
46
|
+
affectedRepositories: z.array(z.string()).optional(), // Repository paths selected during planning
|
|
47
|
+
requirementStatus: RequirementStatusSchema.default('pending'),
|
|
48
|
+
requirements: z.string().optional(), // Refined requirements (set during sprint refine)
|
|
49
|
+
});
|
|
50
|
+
export type Ticket = z.infer<typeof TicketSchema>;
|
|
51
|
+
|
|
52
|
+
// Task schema
|
|
53
|
+
export const TaskSchema = z.object({
|
|
54
|
+
id: z.string().min(1), // UUID8
|
|
55
|
+
name: z.string().min(1),
|
|
56
|
+
description: z.string().optional(),
|
|
57
|
+
steps: z.array(z.string()).default([]),
|
|
58
|
+
status: TaskStatusSchema.default('todo'),
|
|
59
|
+
order: z.number().int().positive(),
|
|
60
|
+
ticketId: z.string().optional(), // References Ticket.id (internal)
|
|
61
|
+
blockedBy: z.array(z.string()).default([]),
|
|
62
|
+
projectPath: z.string().min(1), // Single path for execution
|
|
63
|
+
verified: z.boolean().default(false), // Whether verification passed
|
|
64
|
+
verificationOutput: z.string().optional(), // Output from verification run
|
|
65
|
+
});
|
|
66
|
+
export type Task = z.infer<typeof TaskSchema>;
|
|
67
|
+
|
|
68
|
+
// Tasks array schema
|
|
69
|
+
export const TasksSchema = z.array(TaskSchema);
|
|
70
|
+
export type Tasks = z.infer<typeof TasksSchema>;
|
|
71
|
+
|
|
72
|
+
// Import task schema (for task import from CLI or planning)
|
|
73
|
+
export const ImportTaskSchema = z.object({
|
|
74
|
+
id: z.string().optional(), // Local ID for referencing in blockedBy
|
|
75
|
+
name: z.string().min(1), // Required
|
|
76
|
+
description: z.string().optional(),
|
|
77
|
+
steps: z.array(z.string()).optional(),
|
|
78
|
+
ticketId: z.string().optional(),
|
|
79
|
+
blockedBy: z.array(z.string()).optional(),
|
|
80
|
+
projectPath: z.string().min(1), // Required - execution directory
|
|
81
|
+
});
|
|
82
|
+
export type ImportTask = z.infer<typeof ImportTaskSchema>;
|
|
83
|
+
|
|
84
|
+
// Import tasks array schema
|
|
85
|
+
export const ImportTasksSchema = z.array(ImportTaskSchema);
|
|
86
|
+
export type ImportTasks = z.infer<typeof ImportTasksSchema>;
|
|
87
|
+
|
|
88
|
+
// Refined requirement schema (for requirements refinement output)
|
|
89
|
+
export const RefinedRequirementSchema = z.object({
|
|
90
|
+
ref: z.string().min(1),
|
|
91
|
+
requirements: z.string().min(1),
|
|
92
|
+
});
|
|
93
|
+
export type RefinedRequirement = z.infer<typeof RefinedRequirementSchema>;
|
|
94
|
+
|
|
95
|
+
// Refined requirements array schema
|
|
96
|
+
export const RefinedRequirementsSchema = z.array(RefinedRequirementSchema);
|
|
97
|
+
export type RefinedRequirements = z.infer<typeof RefinedRequirementsSchema>;
|
|
98
|
+
|
|
99
|
+
// Ideate output schema (combined requirements + tasks from sprint ideate)
|
|
100
|
+
export const IdeateOutputSchema = z.object({
|
|
101
|
+
requirements: z.string().min(1),
|
|
102
|
+
tasks: ImportTasksSchema,
|
|
103
|
+
});
|
|
104
|
+
export type IdeateOutput = z.infer<typeof IdeateOutputSchema>;
|
|
105
|
+
|
|
106
|
+
// Sprint schema (was Scope)
|
|
107
|
+
export const SprintSchema = z.object({
|
|
108
|
+
id: z.string().regex(/^\d{8}-\d{6}-[a-z0-9-]+$/, 'Invalid sprint ID format'),
|
|
109
|
+
name: z.string().min(1),
|
|
110
|
+
status: SprintStatusSchema.default('draft'),
|
|
111
|
+
createdAt: z.iso.datetime(),
|
|
112
|
+
activatedAt: z.iso.datetime().nullable().default(null),
|
|
113
|
+
closedAt: z.iso.datetime().nullable().default(null),
|
|
114
|
+
tickets: z.array(TicketSchema).default([]),
|
|
115
|
+
checkRanAt: z.record(z.string(), z.iso.datetime()).default({}),
|
|
116
|
+
branch: z.string().nullable().default(null),
|
|
117
|
+
});
|
|
118
|
+
export type Sprint = z.infer<typeof SprintSchema>;
|
|
119
|
+
|
|
120
|
+
// AI provider enum
|
|
121
|
+
export const AiProviderSchema = z.enum(['claude', 'copilot']);
|
|
122
|
+
export type AiProvider = z.infer<typeof AiProviderSchema>;
|
|
123
|
+
|
|
124
|
+
// Config schema (root level configuration)
|
|
125
|
+
export const ConfigSchema = z.object({
|
|
126
|
+
currentSprint: z.string().nullable().default(null),
|
|
127
|
+
aiProvider: AiProviderSchema.nullable().default(null),
|
|
128
|
+
editor: z.string().nullable().default(null),
|
|
129
|
+
});
|
|
130
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { getConfigPath } from '@src/utils/paths.ts';
|
|
2
|
+
import { fileExists, readValidatedJson, writeValidatedJson } from '@src/utils/storage.ts';
|
|
3
|
+
import { type AiProvider, type Config, ConfigSchema } from '@src/schemas/index.ts';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG: Config = {
|
|
6
|
+
currentSprint: null,
|
|
7
|
+
aiProvider: null,
|
|
8
|
+
editor: null,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function getConfig(): Promise<Config> {
|
|
12
|
+
const configPath = getConfigPath();
|
|
13
|
+
if (!(await fileExists(configPath))) {
|
|
14
|
+
return DEFAULT_CONFIG;
|
|
15
|
+
}
|
|
16
|
+
return readValidatedJson(configPath, ConfigSchema);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function saveConfig(config: Config): Promise<void> {
|
|
20
|
+
await writeValidatedJson(getConfigPath(), config, ConfigSchema);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the current sprint ID (which sprint commands target).
|
|
25
|
+
*/
|
|
26
|
+
export async function getCurrentSprint(): Promise<string | null> {
|
|
27
|
+
const config = await getConfig();
|
|
28
|
+
return config.currentSprint;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set the current sprint ID.
|
|
33
|
+
*/
|
|
34
|
+
export async function setCurrentSprint(sprintId: string | null): Promise<void> {
|
|
35
|
+
const config = await getConfig();
|
|
36
|
+
config.currentSprint = sprintId;
|
|
37
|
+
await saveConfig(config);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the configured AI provider (claude or copilot).
|
|
42
|
+
* Returns null if not yet configured (first-run).
|
|
43
|
+
*/
|
|
44
|
+
export async function getAiProvider(): Promise<AiProvider | null> {
|
|
45
|
+
const config = await getConfig();
|
|
46
|
+
return config.aiProvider ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set the AI provider.
|
|
51
|
+
*/
|
|
52
|
+
export async function setAiProvider(provider: AiProvider): Promise<void> {
|
|
53
|
+
const config = await getConfig();
|
|
54
|
+
config.aiProvider = provider;
|
|
55
|
+
await saveConfig(config);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the configured editor command (e.g., "subl -w", "code --wait", "vim").
|
|
60
|
+
* Returns null if not yet configured (first-run).
|
|
61
|
+
*/
|
|
62
|
+
export async function getEditor(): Promise<string | null> {
|
|
63
|
+
const config = await getConfig();
|
|
64
|
+
return config.editor ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Set the editor command.
|
|
69
|
+
*/
|
|
70
|
+
export async function setEditor(editor: string): Promise<void> {
|
|
71
|
+
const config = await getConfig();
|
|
72
|
+
config.editor = editor;
|
|
73
|
+
await saveConfig(config);
|
|
74
|
+
}
|