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,319 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { ProcessManager } from '@src/ai/process-manager.ts';
|
|
3
|
+
import { assertSafeCwd } from '@src/utils/paths.ts';
|
|
4
|
+
import { type ProviderAdapter } from '@src/providers/types.ts';
|
|
5
|
+
import { getActiveProvider } from '@src/providers/index.ts';
|
|
6
|
+
|
|
7
|
+
// Re-export types from providers for backward compatibility
|
|
8
|
+
export type { HeadlessSpawnOptions, SpawnResult } from '@src/providers/types.ts';
|
|
9
|
+
export type { SpawnSyncOptions, SpawnAsyncOptions } from '@src/providers/types.ts';
|
|
10
|
+
|
|
11
|
+
// Local import aliases for use in function signatures
|
|
12
|
+
import type { HeadlessSpawnOptions, SpawnResult, SpawnSyncOptions, SpawnAsyncOptions } from '@src/providers/types.ts';
|
|
13
|
+
|
|
14
|
+
/** Parsed JSON result from provider CLI --output-format json */
|
|
15
|
+
export interface ProviderJsonResult {
|
|
16
|
+
type: string;
|
|
17
|
+
subtype: string;
|
|
18
|
+
is_error: boolean;
|
|
19
|
+
result: string;
|
|
20
|
+
session_id: string;
|
|
21
|
+
duration_ms: number;
|
|
22
|
+
total_cost_usd: number;
|
|
23
|
+
num_turns: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SpawnError extends Error {
|
|
27
|
+
public readonly stderr: string;
|
|
28
|
+
public readonly exitCode: number;
|
|
29
|
+
public readonly rateLimited: boolean;
|
|
30
|
+
public readonly retryAfterMs: number | null;
|
|
31
|
+
/** Session ID if available (for resume after rate limit) */
|
|
32
|
+
public readonly sessionId: string | null;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
message: string,
|
|
36
|
+
stderr: string,
|
|
37
|
+
exitCode: number,
|
|
38
|
+
sessionId?: string | null,
|
|
39
|
+
provider?: ProviderAdapter
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'SpawnError';
|
|
43
|
+
this.stderr = stderr;
|
|
44
|
+
this.exitCode = exitCode;
|
|
45
|
+
this.sessionId = sessionId ?? null;
|
|
46
|
+
const rl = provider ? provider.detectRateLimit(stderr) : detectRateLimitFallback(stderr);
|
|
47
|
+
this.rateLimited = rl.rateLimited;
|
|
48
|
+
this.retryAfterMs = rl.retryAfterMs;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Fallback rate limit detection (used when no provider is available).
|
|
54
|
+
*/
|
|
55
|
+
function detectRateLimitFallback(stderr: string): { rateLimited: boolean; retryAfterMs: number | null } {
|
|
56
|
+
const patterns = [/rate.?limit/i, /\b429\b/, /too many requests/i, /overloaded/i, /\b529\b/];
|
|
57
|
+
const isRateLimited = patterns.some((p) => p.test(stderr));
|
|
58
|
+
if (!isRateLimited) {
|
|
59
|
+
return { rateLimited: false, retryAfterMs: null };
|
|
60
|
+
}
|
|
61
|
+
const retryMatch = /retry.?after:?\s*(\d+)/i.exec(stderr);
|
|
62
|
+
const retryAfterMs = retryMatch?.[1] ? parseInt(retryMatch[1], 10) * 1000 : null;
|
|
63
|
+
return { rateLimited: true, retryAfterMs };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detect rate limit signals in stderr output.
|
|
68
|
+
* @deprecated Use provider.detectRateLimit() instead.
|
|
69
|
+
*/
|
|
70
|
+
export function detectRateLimit(stderr: string): { rateLimited: boolean; retryAfterMs: number | null } {
|
|
71
|
+
return detectRateLimitFallback(stderr);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse JSON output from provider CLI --output-format json.
|
|
76
|
+
* @deprecated Use provider.parseJsonOutput() instead.
|
|
77
|
+
*/
|
|
78
|
+
export function parseJsonOutput(stdout: string): { result: string; sessionId: string | null } {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(stdout) as Partial<ProviderJsonResult>;
|
|
81
|
+
return {
|
|
82
|
+
result: parsed.result ?? stdout,
|
|
83
|
+
sessionId: parsed.session_id ?? null,
|
|
84
|
+
};
|
|
85
|
+
} catch {
|
|
86
|
+
return { result: stdout, sessionId: null };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Spawn AI CLI for interactive session.
|
|
92
|
+
*
|
|
93
|
+
* Starts a single interactive session with an optional initial prompt.
|
|
94
|
+
* The prompt is passed as a CLI argument, keeping everything in one session.
|
|
95
|
+
* User sees and interacts with the AI directly in the terminal.
|
|
96
|
+
*
|
|
97
|
+
* @param prompt - Optional initial prompt to start the session with.
|
|
98
|
+
* @param options - Spawn options (cwd, args, env).
|
|
99
|
+
* @param provider - Provider adapter (defaults to active provider resolved from config).
|
|
100
|
+
*/
|
|
101
|
+
export function spawnInteractive(
|
|
102
|
+
prompt: string,
|
|
103
|
+
options: SpawnSyncOptions,
|
|
104
|
+
provider?: ProviderAdapter
|
|
105
|
+
): { code: number; error?: string } {
|
|
106
|
+
assertSafeCwd(options.cwd);
|
|
107
|
+
|
|
108
|
+
// If no provider given, use a synchronous fallback (claude) since we can't await here
|
|
109
|
+
const p =
|
|
110
|
+
provider ??
|
|
111
|
+
({
|
|
112
|
+
binary: 'claude',
|
|
113
|
+
baseArgs: ['--permission-mode', 'acceptEdits'],
|
|
114
|
+
buildInteractiveArgs: (pr: string, extra: string[] = []) => [
|
|
115
|
+
...['--permission-mode', 'acceptEdits'],
|
|
116
|
+
...extra,
|
|
117
|
+
'--',
|
|
118
|
+
pr,
|
|
119
|
+
],
|
|
120
|
+
} as Pick<ProviderAdapter, 'binary' | 'baseArgs' | 'buildInteractiveArgs'>);
|
|
121
|
+
|
|
122
|
+
const args = prompt ? p.buildInteractiveArgs(prompt, options.args ?? []) : [...p.baseArgs, ...(options.args ?? [])];
|
|
123
|
+
|
|
124
|
+
const env = options.env ? { ...process.env, ...options.env } : undefined;
|
|
125
|
+
|
|
126
|
+
const result = spawnSync(p.binary, args, {
|
|
127
|
+
cwd: options.cwd,
|
|
128
|
+
stdio: 'inherit',
|
|
129
|
+
env,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (result.error) {
|
|
133
|
+
return { code: 1, error: `Failed to spawn ${p.binary} CLI: ${result.error.message}` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { code: result.status ?? 1 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Spawn AI CLI in print mode for headless execution.
|
|
141
|
+
* Captures stdout and returns the text result.
|
|
142
|
+
*
|
|
143
|
+
* Uses --output-format json internally to capture session IDs.
|
|
144
|
+
* The returned string is the extracted `result` field from the JSON output.
|
|
145
|
+
*/
|
|
146
|
+
export async function spawnHeadless(
|
|
147
|
+
options: SpawnAsyncOptions & { prompt?: string },
|
|
148
|
+
provider?: ProviderAdapter
|
|
149
|
+
): Promise<string> {
|
|
150
|
+
const result = await spawnHeadlessRaw(options as HeadlessSpawnOptions, provider);
|
|
151
|
+
return result.stdout;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Low-level headless spawn returning structured result.
|
|
156
|
+
*
|
|
157
|
+
* Uses --output-format json to capture session_id for resumability.
|
|
158
|
+
* Extracts the text result from JSON and returns it in stdout.
|
|
159
|
+
* Session ID is available in the returned SpawnResult.
|
|
160
|
+
*
|
|
161
|
+
* Throws SpawnError on non-zero exit (includes rate limit detection + session ID).
|
|
162
|
+
*/
|
|
163
|
+
export async function spawnHeadlessRaw(
|
|
164
|
+
options: HeadlessSpawnOptions,
|
|
165
|
+
provider?: ProviderAdapter
|
|
166
|
+
): Promise<SpawnResult> {
|
|
167
|
+
assertSafeCwd(options.cwd);
|
|
168
|
+
const p = provider ?? (await getActiveProvider());
|
|
169
|
+
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const allArgs = p.buildHeadlessArgs(options.args ?? []);
|
|
172
|
+
|
|
173
|
+
// Add --resume if resuming a session (validate format to prevent argument injection)
|
|
174
|
+
if (options.resumeSessionId) {
|
|
175
|
+
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,127}$/.test(options.resumeSessionId)) {
|
|
176
|
+
reject(new SpawnError('Invalid session ID format', '', 1, null, p));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
allArgs.push('--resume', options.resumeSessionId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const child = spawn(p.binary, allArgs, {
|
|
183
|
+
cwd: options.cwd,
|
|
184
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
185
|
+
env: options.env ? { ...process.env, ...options.env } : undefined,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Register child with ProcessManager for signal handling
|
|
189
|
+
const manager = ProcessManager.getInstance();
|
|
190
|
+
try {
|
|
191
|
+
manager.registerChild(child);
|
|
192
|
+
} catch {
|
|
193
|
+
reject(new SpawnError('Cannot spawn during shutdown', '', 1, null, p));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Write prompt to stdin if provided, then close
|
|
198
|
+
const MAX_PROMPT_SIZE = 1_000_000; // 1MB
|
|
199
|
+
if (options.prompt) {
|
|
200
|
+
if (options.prompt.length > MAX_PROMPT_SIZE) {
|
|
201
|
+
reject(new SpawnError('Prompt exceeds maximum size (1MB)', '', 1, null, p));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
child.stdin.write(options.prompt);
|
|
205
|
+
}
|
|
206
|
+
child.stdin.end();
|
|
207
|
+
|
|
208
|
+
let rawStdout = '';
|
|
209
|
+
let stderr = '';
|
|
210
|
+
|
|
211
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
212
|
+
rawStdout += data.toString();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
216
|
+
stderr += data.toString();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
child.on('close', (code) => {
|
|
220
|
+
void (async () => {
|
|
221
|
+
const exitCode = code ?? 1;
|
|
222
|
+
|
|
223
|
+
// Parse output to extract result text and session ID.
|
|
224
|
+
// For Claude: JSON output contains session_id directly.
|
|
225
|
+
// For Copilot: plain text output; session ID captured via --share file.
|
|
226
|
+
const { result, sessionId: parsedSessionId } = p.parseJsonOutput(rawStdout);
|
|
227
|
+
const sessionId = parsedSessionId ?? (await p.extractSessionId?.(options.cwd)) ?? null;
|
|
228
|
+
|
|
229
|
+
if (exitCode !== 0) {
|
|
230
|
+
reject(
|
|
231
|
+
new SpawnError(
|
|
232
|
+
`${p.displayName} CLI exited with code ${String(exitCode)}: ${stderr}`,
|
|
233
|
+
stderr,
|
|
234
|
+
exitCode,
|
|
235
|
+
sessionId,
|
|
236
|
+
p
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
} else {
|
|
240
|
+
resolve({ stdout: result, stderr, exitCode: 0, sessionId });
|
|
241
|
+
}
|
|
242
|
+
})().catch((err: unknown) => {
|
|
243
|
+
reject(new SpawnError(`Unexpected error in close handler: ${String(err)}`, '', 1, null, p));
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
child.on('error', (err) => {
|
|
248
|
+
reject(new SpawnError(`Failed to spawn ${p.binary} CLI: ${err.message}`, '', 1, null, p));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
254
|
+
const BASE_DELAY_MS = 2000;
|
|
255
|
+
const MAX_DELAY_MS = 120_000;
|
|
256
|
+
const DEFAULT_TOTAL_TIMEOUT_MS = 600_000; // 10 minutes across all retries
|
|
257
|
+
|
|
258
|
+
function sleep(ms: number): Promise<void> {
|
|
259
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function jitter(): number {
|
|
263
|
+
return Math.floor(Math.random() * 1000);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Spawn AI CLI with automatic retry on rate limit errors.
|
|
268
|
+
* Uses exponential backoff with jitter.
|
|
269
|
+
*
|
|
270
|
+
* On rate limit failures, automatically resumes the session using the
|
|
271
|
+
* captured session ID so the AI picks up where it left off.
|
|
272
|
+
*/
|
|
273
|
+
export async function spawnWithRetry(
|
|
274
|
+
options: HeadlessSpawnOptions,
|
|
275
|
+
retryOptions?: {
|
|
276
|
+
maxRetries?: number;
|
|
277
|
+
totalTimeoutMs?: number;
|
|
278
|
+
onRetry?: (attempt: number, delayMs: number, error: SpawnError) => void;
|
|
279
|
+
},
|
|
280
|
+
provider?: ProviderAdapter
|
|
281
|
+
): Promise<SpawnResult> {
|
|
282
|
+
const p = provider ?? (await getActiveProvider());
|
|
283
|
+
const maxRetries = retryOptions?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
284
|
+
const totalTimeoutMs = retryOptions?.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS;
|
|
285
|
+
const startTime = Date.now();
|
|
286
|
+
let resumeSessionId = options.resumeSessionId;
|
|
287
|
+
|
|
288
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
289
|
+
// Check total elapsed time before each attempt
|
|
290
|
+
const elapsed = Date.now() - startTime;
|
|
291
|
+
if (attempt > 0 && elapsed >= totalTimeoutMs) {
|
|
292
|
+
throw new SpawnError(`Total retry timeout exceeded (${String(totalTimeoutMs)}ms)`, '', 1, resumeSessionId, p);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
return await spawnHeadlessRaw({ ...options, resumeSessionId }, p);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (!(err instanceof SpawnError) || !err.rateLimited) {
|
|
299
|
+
throw err;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Capture session ID for resume on next attempt
|
|
303
|
+
if (err.sessionId) {
|
|
304
|
+
resumeSessionId = err.sessionId;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (attempt >= maxRetries) {
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const delay = Math.min(err.retryAfterMs ?? BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS) + jitter();
|
|
312
|
+
retryOptions?.onRetry?.(attempt + 1, delay, err);
|
|
313
|
+
await sleep(delay);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Unreachable, but satisfies TypeScript
|
|
318
|
+
throw new Error('Max retries exceeded');
|
|
319
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { muted, warning } from '@src/theme/index.ts';
|
|
5
|
+
import { checkTaskPermissions } from '@src/ai/permissions.ts';
|
|
6
|
+
import { getProject, ProjectNotFoundError } from '@src/store/project.ts';
|
|
7
|
+
import type { AiProvider, Project, Sprint, Task } from '@src/schemas/index.ts';
|
|
8
|
+
import { assertSafeCwd } from '@src/utils/paths.ts';
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// TYPES
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export interface TaskContext {
|
|
15
|
+
sprint: Sprint;
|
|
16
|
+
task: Task;
|
|
17
|
+
project?: Project;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Outcome of a check script for a single project path. */
|
|
21
|
+
export type CheckStatus = { ran: true; script: string } | { ran: false; reason: 'no-script' };
|
|
22
|
+
|
|
23
|
+
/** Map from projectPath → CheckStatus, populated by runCheckScripts. */
|
|
24
|
+
export type CheckResults = Map<string, CheckStatus>;
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// UTILITY FUNCTIONS
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get recent git history for a project path.
|
|
32
|
+
*/
|
|
33
|
+
export function getRecentGitHistory(projectPath: string, count = 20): string {
|
|
34
|
+
try {
|
|
35
|
+
assertSafeCwd(projectPath);
|
|
36
|
+
const result = execSync(`git log -${String(count)} --oneline --no-decorate`, {
|
|
37
|
+
cwd: projectPath,
|
|
38
|
+
encoding: 'utf-8',
|
|
39
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
40
|
+
});
|
|
41
|
+
return result.trim();
|
|
42
|
+
} catch {
|
|
43
|
+
return '(Unable to retrieve git history)';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get check script from explicit repository config only.
|
|
49
|
+
* Returns null if no check script is configured — no runtime auto-detection.
|
|
50
|
+
* Heuristic detection is used only as suggestions during `project add`.
|
|
51
|
+
*/
|
|
52
|
+
export function getEffectiveCheckScript(project: Project | undefined, projectPath: string): string | null {
|
|
53
|
+
if (project) {
|
|
54
|
+
const repo = project.repositories.find((r) => r.path === projectPath);
|
|
55
|
+
if (repo?.checkScript) {
|
|
56
|
+
return repo.checkScript;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatTask(ctx: TaskContext): string {
|
|
63
|
+
const lines: string[] = [];
|
|
64
|
+
|
|
65
|
+
// ═══ TASK DIRECTIVE (highest attention) ═══
|
|
66
|
+
lines.push('## Task Directive');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(`**Task:** ${ctx.task.name}`);
|
|
69
|
+
lines.push(`**ID:** ${ctx.task.id}`);
|
|
70
|
+
lines.push(`**Project:** ${ctx.task.projectPath}`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
lines.push('**ONE TASK ONLY.** Complete THIS task and nothing else. Do not continue to other tasks.');
|
|
73
|
+
|
|
74
|
+
if (ctx.task.description) {
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push(ctx.task.description);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ═══ TASK STEPS (primary content — positioned first for maximum attention) ═══
|
|
80
|
+
if (ctx.task.steps.length > 0) {
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push('## Implementation Steps');
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push('Follow these steps precisely and in order:');
|
|
85
|
+
lines.push('');
|
|
86
|
+
ctx.task.steps.forEach((step, i) => {
|
|
87
|
+
lines.push(`${String(i + 1)}. ${step}`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the full task context with primacy/recency optimization.
|
|
96
|
+
*
|
|
97
|
+
* Layout applies the primacy/recency effect:
|
|
98
|
+
* - HIGH ATTENTION (start): Task directive, steps, check script
|
|
99
|
+
* - REFERENCE (middle): Prior learnings, ticket requirements, git history
|
|
100
|
+
* - HIGH ATTENTION (end): Instructions (appended by writeTaskContextFile)
|
|
101
|
+
*/
|
|
102
|
+
export function buildFullTaskContext(
|
|
103
|
+
ctx: TaskContext,
|
|
104
|
+
progressSummary: string | null,
|
|
105
|
+
gitHistory: string,
|
|
106
|
+
checkScript: string | null,
|
|
107
|
+
checkStatus?: CheckStatus
|
|
108
|
+
): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
|
|
111
|
+
// ═══ HIGH ATTENTION ZONE (beginning) ═══
|
|
112
|
+
|
|
113
|
+
lines.push(formatTask(ctx));
|
|
114
|
+
|
|
115
|
+
// Branch awareness — tell the agent which branch it's on
|
|
116
|
+
if (ctx.sprint.branch) {
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push('## Branch');
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push(
|
|
121
|
+
`You are working on branch \`${ctx.sprint.branch}\`. All commits go to this branch. Do not switch branches.`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Check script — near the top so it's easy to find
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('## Check Script');
|
|
128
|
+
lines.push('');
|
|
129
|
+
if (checkScript) {
|
|
130
|
+
lines.push('The harness runs this command at sprint start and after every task as a post-task gate:');
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push('```bash');
|
|
133
|
+
lines.push(checkScript);
|
|
134
|
+
lines.push('```');
|
|
135
|
+
lines.push('');
|
|
136
|
+
lines.push('Your task is NOT marked done unless this command passes after completion.');
|
|
137
|
+
} else {
|
|
138
|
+
lines.push('No check script is configured. Read CLAUDE.md in the project root to find verification commands.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check status awareness — tell the agent what happened during stage zero
|
|
142
|
+
if (checkStatus) {
|
|
143
|
+
lines.push('');
|
|
144
|
+
lines.push('## Environment Status');
|
|
145
|
+
lines.push('');
|
|
146
|
+
if (checkStatus.ran) {
|
|
147
|
+
lines.push('The check script ran successfully at sprint start. Dependencies are current.');
|
|
148
|
+
lines.push('Do not re-run the install portion unless you encounter dependency errors.');
|
|
149
|
+
} else {
|
|
150
|
+
lines.push(
|
|
151
|
+
'No check script is configured for this repository. ' +
|
|
152
|
+
'Read CLAUDE.md or project configuration files (package.json, pyproject.toml, etc.) ' +
|
|
153
|
+
'to discover build, test, and lint commands.'
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ═══ REFERENCE ZONE (middle — lower attention is OK) ═══
|
|
159
|
+
|
|
160
|
+
lines.push('');
|
|
161
|
+
lines.push('---');
|
|
162
|
+
lines.push('');
|
|
163
|
+
|
|
164
|
+
// Prior task learnings (summarized, not raw progress dump)
|
|
165
|
+
if (progressSummary) {
|
|
166
|
+
lines.push('## Prior Task Learnings');
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push('_Reference — consult when relevant to your implementation._');
|
|
169
|
+
lines.push('');
|
|
170
|
+
lines.push(progressSummary);
|
|
171
|
+
lines.push('');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Ticket requirements (reference only, explicitly deprioritized)
|
|
175
|
+
if (ctx.task.ticketId) {
|
|
176
|
+
const ticket = ctx.sprint.tickets.find((t) => t.id === ctx.task.ticketId);
|
|
177
|
+
if (ticket?.requirements) {
|
|
178
|
+
lines.push('## Ticket Requirements');
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push(
|
|
181
|
+
'_Reference — these describe the full ticket scope. This task implements a specific part. ' +
|
|
182
|
+
'Use to validate your work and understand constraints, but follow the Implementation Steps above. ' +
|
|
183
|
+
'Do not expand scope beyond declared steps._'
|
|
184
|
+
);
|
|
185
|
+
lines.push('');
|
|
186
|
+
lines.push(ticket.requirements);
|
|
187
|
+
lines.push('');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Git history — awareness of recent changes
|
|
192
|
+
lines.push('## Git History (recent commits)');
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push('```');
|
|
195
|
+
lines.push(gitHistory);
|
|
196
|
+
lines.push('```');
|
|
197
|
+
|
|
198
|
+
// ═══ HIGH ATTENTION ZONE (end) — Instructions appended by writeTaskContextFile ═══
|
|
199
|
+
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getContextFileName(sprintId: string, taskId: string): string {
|
|
204
|
+
return `.ralphctl-sprint-${sprintId}-task-${taskId}-context.md`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function writeTaskContextFile(
|
|
208
|
+
projectPath: string,
|
|
209
|
+
taskContent: string,
|
|
210
|
+
instructions: string,
|
|
211
|
+
sprintId: string,
|
|
212
|
+
taskId: string
|
|
213
|
+
): Promise<string> {
|
|
214
|
+
const contextFile = join(projectPath, getContextFileName(sprintId, taskId));
|
|
215
|
+
const warning = `<!-- TEMPORARY FILE - DO NOT COMMIT -->
|
|
216
|
+
<!-- This file is auto-generated by ralphctl for task execution context -->
|
|
217
|
+
<!-- It will be automatically cleaned up after task completion -->
|
|
218
|
+
|
|
219
|
+
`;
|
|
220
|
+
const fullContent = `${warning}${taskContent}\n\n---\n\n## Instructions\n\n${instructions}`;
|
|
221
|
+
await writeFile(contextFile, fullContent, { encoding: 'utf-8', mode: 0o600 });
|
|
222
|
+
return contextFile;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Try to get the project for a task (via ticket reference).
|
|
227
|
+
*/
|
|
228
|
+
export async function getProjectForTask(task: Task, sprint: Sprint): Promise<Project | undefined> {
|
|
229
|
+
if (!task.ticketId) return undefined;
|
|
230
|
+
|
|
231
|
+
const ticket = sprint.tickets.find((t) => t.id === task.ticketId);
|
|
232
|
+
if (!ticket) return undefined;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
return await getProject(ticket.projectName);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
if (err instanceof ProjectNotFoundError) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ============================================================================
|
|
245
|
+
// PERMISSION CHECKS
|
|
246
|
+
// ============================================================================
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Run permission checks and display any warnings.
|
|
250
|
+
*
|
|
251
|
+
* For Claude: warns about operations that may need approval in settings files.
|
|
252
|
+
* For Copilot: no-op — all tools are granted via --allow-all-tools.
|
|
253
|
+
*/
|
|
254
|
+
export function runPermissionCheck(ctx: TaskContext, noCommit: boolean, provider?: AiProvider): void {
|
|
255
|
+
const checkScript = getEffectiveCheckScript(ctx.project, ctx.task.projectPath);
|
|
256
|
+
|
|
257
|
+
const warnings = checkTaskPermissions(ctx.task.projectPath, {
|
|
258
|
+
checkScript,
|
|
259
|
+
needsCommit: !noCommit,
|
|
260
|
+
provider,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (warnings.length > 0) {
|
|
264
|
+
console.log(warning('\n Permission warnings:'));
|
|
265
|
+
for (const w of warnings) {
|
|
266
|
+
console.log(muted(` - ${w.message}`));
|
|
267
|
+
}
|
|
268
|
+
console.log(muted(' Consider adjusting tool permissions for your AI provider\n'));
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { showBanner } from '@src/theme/ui.ts';
|
|
3
|
+
import { interactiveMode } from '@src/interactive/index.ts';
|
|
4
|
+
import { registerProjectCommands } from '@src/commands/project/index.ts';
|
|
5
|
+
import { registerSprintCommands } from '@src/commands/sprint/index.ts';
|
|
6
|
+
import { registerTaskCommands } from '@src/commands/task/index.ts';
|
|
7
|
+
import { registerTicketCommands } from '@src/commands/ticket/index.ts';
|
|
8
|
+
import { registerProgressCommands } from '@src/commands/progress/index.ts';
|
|
9
|
+
import { registerDashboardCommands } from '@src/commands/dashboard/index.ts';
|
|
10
|
+
import { registerConfigCommands } from '@src/commands/config/index.ts';
|
|
11
|
+
import { registerCompletionCommands } from '@src/commands/completion/index.ts';
|
|
12
|
+
import { registerDoctorCommands } from '@src/commands/doctor/index.ts';
|
|
13
|
+
import { error } from '@src/theme/index.ts';
|
|
14
|
+
import { cliMetadata } from '@src/cli-metadata.ts';
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
program
|
|
18
|
+
.name(cliMetadata.name)
|
|
19
|
+
.description(cliMetadata.description)
|
|
20
|
+
.version(cliMetadata.version)
|
|
21
|
+
.addHelpText(
|
|
22
|
+
'after',
|
|
23
|
+
`
|
|
24
|
+
Examples:
|
|
25
|
+
$ ralphctl # Interactive mode
|
|
26
|
+
$ ralphctl status # Show current sprint status
|
|
27
|
+
$ ralphctl sprint create --name "v1.0" # Create sprint
|
|
28
|
+
$ ralphctl ticket add --project api # Add ticket
|
|
29
|
+
$ ralphctl task list -b # Brief task list
|
|
30
|
+
|
|
31
|
+
Run any command with --help for details.
|
|
32
|
+
`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
registerProjectCommands(program);
|
|
36
|
+
registerSprintCommands(program);
|
|
37
|
+
registerTaskCommands(program);
|
|
38
|
+
registerTicketCommands(program);
|
|
39
|
+
registerProgressCommands(program);
|
|
40
|
+
registerDashboardCommands(program);
|
|
41
|
+
registerConfigCommands(program);
|
|
42
|
+
registerCompletionCommands(program);
|
|
43
|
+
registerDoctorCommands(program);
|
|
44
|
+
|
|
45
|
+
async function main(): Promise<void> {
|
|
46
|
+
// Shell completion: intercept before any output (banner, interactive mode)
|
|
47
|
+
if (process.env['COMP_CWORD'] && process.env['COMP_POINT'] && process.env['COMP_LINE']) {
|
|
48
|
+
const { handleCompletionRequest } = await import('@src/completion/handle.ts');
|
|
49
|
+
if (await handleCompletionRequest(program)) return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// No args or 'interactive' → interactive mode
|
|
53
|
+
if (process.argv.length <= 2 || process.argv[2] === 'interactive') {
|
|
54
|
+
// Interactive mode shows its own banner
|
|
55
|
+
await interactiveMode();
|
|
56
|
+
} else {
|
|
57
|
+
showBanner();
|
|
58
|
+
await program.parseAsync(process.argv);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch((err: unknown) => {
|
|
63
|
+
console.error(error('Fatal error:'), err);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { showSuccess } from '@src/theme/ui.ts';
|
|
3
|
+
|
|
4
|
+
export function registerCompletionCommands(program: Command): void {
|
|
5
|
+
const completion = program.command('completion').description('Manage shell tab-completion');
|
|
6
|
+
|
|
7
|
+
completion.addHelpText(
|
|
8
|
+
'after',
|
|
9
|
+
`
|
|
10
|
+
Examples:
|
|
11
|
+
$ ralphctl completion install # Enable tab-completion for your shell
|
|
12
|
+
$ ralphctl completion uninstall # Remove tab-completion
|
|
13
|
+
`
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
completion
|
|
17
|
+
.command('install')
|
|
18
|
+
.description('Install shell tab-completion (bash, zsh, fish)')
|
|
19
|
+
.action(async () => {
|
|
20
|
+
const tabtab = (await import('tabtab')).default;
|
|
21
|
+
await tabtab.install({ name: 'ralphctl', completer: 'ralphctl' });
|
|
22
|
+
showSuccess('Shell completion installed. Restart your shell or source your profile to activate.');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
completion
|
|
26
|
+
.command('uninstall')
|
|
27
|
+
.description('Remove shell tab-completion')
|
|
28
|
+
.action(async () => {
|
|
29
|
+
const tabtab = (await import('tabtab')).default;
|
|
30
|
+
await tabtab.uninstall({ name: 'ralphctl' });
|
|
31
|
+
showSuccess('Shell completion removed.');
|
|
32
|
+
});
|
|
33
|
+
}
|