capyai 0.5.4 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capyai",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Unofficial Capy.ai CLI for agent orchestration with quality gates",
6
6
  "bin": {
@@ -3,7 +3,7 @@ name: capy
3
3
  description: Orchestrate Capy.ai coding agents with quality gates. Delegate coding work, wait for completion, review quality, approve or retry.
4
4
  metadata:
5
5
  author: yazcaleb
6
- version: "0.5.0"
6
+ version: "0.6.0"
7
7
  ---
8
8
 
9
9
  # capy
@@ -56,7 +56,7 @@ When you encounter a task, look at its status and act accordingly. Do exactly on
56
56
  **If status is `needs_review`:**
57
57
  1. Check if it has a diff: `capy get <id> --json` and look at `pullRequest` field
58
58
  2. If no diff was produced (no `pullRequest`, and `capy diff <id> --json` returns `stats.files: 0`):
59
- → Task is stuck. Retry with instructions: `capy retry <id> --fix="describe what went wrong" --json`
59
+ → Task is stuck. Resume with instructions: `capy captain --resume <id> --fix="describe what went wrong" --json`
60
60
  3. If diff exists but no PR:
61
61
  → Create a PR first: `capy pr <id> --json`
62
62
  → Then review: `capy review <id> --json`
@@ -69,7 +69,7 @@ When you encounter a task, look at its status and act accordingly. Do exactly on
69
69
  → Start it. `capy start <id> --json`
70
70
 
71
71
  **If status is `failed`:**
72
- Retry. `capy retry <id> --fix="..." --json`
72
+ Resume. `capy captain --resume <id> --fix="..." --json`
73
73
 
74
74
  **If status is `archived`:**
75
75
  → Ignore. This task is dead.
@@ -83,7 +83,7 @@ These are the mistakes agents make. Do not make them.
83
83
 
84
84
  1. **Never message a task with idle jams.** If the last jam has `status: "idle"` and zero credits, the task is finished. It cannot receive messages. Sending `capy msg` will appear to succeed but nothing happens. If you need to change something, use `capy retry` to start a new attempt.
85
85
 
86
- 2. **Never create a new Captain thread for work that already has a diff.** If a task produced code changes, that work exists. Create a PR for the existing task with `capy pr <id>`. Starting a new Captain thread throws away the existing work and burns credits.
86
+ 2. **Always check for existing work before starting a new Captain thread.** Run `capy triage --brief --json` or `capy list --json` first. If a task or thread already exists for the same work (in any state: `in_progress`, `needs_review`, `backlog`, `stuck`), resume it instead of creating a new thread. Use `capy captain --resume <id> --fix="..."` to resume stuck/failed tasks, `capy wait <id>` for in-progress ones, `capy pr <id>` for ones needing a PR. Only start a new Captain thread when no related work exists. Creating duplicate threads throws away existing progress and burns credits.
87
87
 
88
88
  3. **Never call `capy review` on a task with no PR.** It will fail with `error.code: "no_pr"`. Always create the PR first with `capy pr <id> --json`, then review.
89
89
 
@@ -97,9 +97,13 @@ These are the mistakes agents make. Do not make them.
97
97
 
98
98
  ## Workflow: Start new work
99
99
 
100
- Default to `capy captain`. It plans, orchestrates, and can spawn multiple tasks. Only use `capy build` for small single-task work where Captain is overkill.
100
+ Before creating a new Captain thread, check if related work already exists. If it does, resume it (see Decision tree). Only start new work when nothing related is in flight.
101
101
 
102
102
  ```bash
103
+ # 0. Check for existing related work first
104
+ capy triage --brief --json
105
+ # If something related exists → use retry/wait/pr on that task instead of starting new
106
+
103
107
  # 1. Start a Captain thread (the default for almost everything)
104
108
  RESULT=$(capy captain "We need feature X implemented. Make sure tests pass and CI is green." --json)
105
109
  THREAD_ID=$(echo "$RESULT" | jq -r '.id')
@@ -129,15 +133,15 @@ for TASK in $TASKS; do
129
133
  QUALITY=$(capy review "$TASK" --json)
130
134
  PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
131
135
 
132
- # Retry loop (max 3)
136
+ # Resume loop (max 3)
133
137
  ATTEMPTS=0
134
138
  while [ "$PASS" != "true" ] && [ "$ATTEMPTS" -lt 3 ]; do
135
139
  FAILING=$(echo "$QUALITY" | jq -r '.quality.gates[] | select(.pass == false) | .name + ": " + .detail')
136
- RETRY=$(capy retry "$TASK" --fix="Fix these failures: $FAILING" --json)
137
- NEW_THREAD=$(echo "$RETRY" | jq -r '.newThread')
140
+ RESUME=$(capy captain --resume "$TASK" --fix="Fix these failures: $FAILING" --json)
141
+ RESUME_THREAD=$(echo "$RESUME" | jq -r '.threadId')
138
142
 
139
- capy wait "$NEW_THREAD" --timeout=600 --json
140
- TASK=$(capy threads get "$NEW_THREAD" --json | jq -r '.tasks[0].identifier')
143
+ capy wait "$RESUME_THREAD" --timeout=600 --json
144
+ TASK=$(capy threads get "$RESUME_THREAD" --json | jq -r '.tasks[-1].identifier')
141
145
 
142
146
  HAS_PR=$(capy get "$TASK" --json | jq -r '.pullRequest.number // empty')
143
147
  [ -z "$HAS_PR" ] && capy pr "$TASK" --json
@@ -183,7 +187,7 @@ Map categories to actions:
183
187
  - `in_progress` → wait
184
188
  - `needs_pr` → `capy pr <id>` then review
185
189
  - `ready` → `capy review <id>` then approve/retry
186
- - `stuck` → `capy retry <id> --fix="..."` or `capy stop <id>`
190
+ - `stuck` → `capy captain --resume <id> --fix="..."` or `capy stop <id>`
187
191
  - `backlog` → `capy start <id>` if the user wants it running
188
192
  - `merged` → done, ignore
189
193
 
@@ -195,10 +199,10 @@ Map categories to actions:
195
199
  |------|--------|----------------------|
196
200
  | `pr_exists` | A PR was created | Run `capy pr <id> --json` first |
197
201
  | `pr_open` | PR is open or merged | PR was closed. Check why. May need a new PR. |
198
- | `ci` | CI checks are green | Retry: `capy retry <id> --fix="CI failing: <list failing checks>"` |
199
- | `greptile` | No unaddressed code review issues | If "still processing": wait 60s, re-review. If issues listed: retry with issues in `--fix` |
200
- | `threads` | No unresolved GitHub review threads | Retry with the unresolved comments in `--fix` |
201
- | `tests` | Diff includes test files | Retry: `capy retry <id> --fix="Add tests for the changes"` |
202
+ | `ci` | CI checks are green | Resume: `capy captain --resume <id> --fix="CI failing: <list failing checks>"` |
203
+ | `greptile` | No unaddressed code review issues | If "still processing": wait 60s, re-review. If issues listed: resume with issues in `--fix` |
204
+ | `threads` | No unresolved GitHub review threads | Resume with the unresolved comments in `--fix` |
205
+ | `tests` | Diff includes test files | Resume: `capy captain --resume <id> --fix="Add tests for the changes"` |
202
206
 
203
207
  ## Commands reference
204
208
 
@@ -207,10 +211,15 @@ All commands support `--json` for structured output. All errors return `{ "error
207
211
  ### Start work
208
212
 
209
213
  ```bash
210
- capy captain "<prompt>" --json # → { id, url } default, use for almost everything
211
- capy build "<prompt>" --json # → { id, identifier, status } — small isolated tasks only
214
+ capy captain "<prompt>" --json # → { id, projectId, status, title, url, createdAt }
215
+ capy captain --resume <id> --fix="..." --json # → { originalTask, threadId, resumed, model }
216
+ capy build "<prompt>" --json # → { id, identifier, status, url, createdAt }
217
+ capy build --resume <id> --fix="..." --json # → { originalTask, newTask, model }
218
+ capy retry <id> --fix="..." --json # → alias for captain --resume
212
219
  ```
213
220
 
221
+ `--resume` messages the existing Captain thread directly (Captain already has full context). If the task has no parent thread (standalone Build), it falls back to creating a new thread with gathered context. If the previous task is still `in_progress`, it stops it first.
222
+
214
223
  Model shortcuts: `--opus`, `--sonnet`, `--mini`, `--fast`, `--kimi`, `--gemini`, `--grok`, `--qwen`, or `--model=<id>`.
215
224
 
216
225
  ### Wait and monitor
@@ -228,9 +237,8 @@ capy status --json # → { threads, tasks, watches }
228
237
 
229
238
  ```bash
230
239
  capy pr <id> [--draft] [--description="..."] --json # → { url, number, title }
231
- capy review <id> --json # → { task, quality: { pass, gates }, diff }
240
+ capy review <id> --json # → { task, quality: { pass, passed, total, gates, summary }, diff, unaddressed, reviewProvider }
232
241
  capy approve <id> [--force] --json # → { task, quality, approved }
233
- capy retry <id> --fix="..." --json # → { originalTask, newThread, model }
234
242
  capy start <id> --json # → task object
235
243
  capy stop <id> --json # → task/thread object
236
244
  capy msg <id> "<text>" --json # → { id, sent: true }
package/src/api.ts CHANGED
@@ -15,6 +15,7 @@ function fail(code: string, message: string): never {
15
15
  }
16
16
 
17
17
  const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
18
+ const SAFE_METHODS = new Set(["GET", "HEAD"]);
18
19
  const MAX_RETRIES = 3;
19
20
 
20
21
  async function rawRequest(apiKey: string, server: string, method: string, path: string, body?: unknown): Promise<any> {
@@ -27,12 +28,14 @@ async function rawRequest(apiKey: string, server: string, method: string, path:
27
28
  headers["Content-Type"] = "application/json";
28
29
  }
29
30
 
31
+ const isSafe = SAFE_METHODS.has(method.toUpperCase());
30
32
  let lastError: Error | null = null;
31
33
 
32
34
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
33
35
  if (attempt > 0) {
34
- const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
35
- await new Promise(r => setTimeout(r, delay));
36
+ const base = Math.min(1000 * 2 ** (attempt - 1), 8000);
37
+ const jitter = Math.random() * base * 0.5;
38
+ await new Promise(r => setTimeout(r, base + jitter));
36
39
  }
37
40
 
38
41
  let res: Response;
@@ -44,11 +47,12 @@ async function rawRequest(apiKey: string, server: string, method: string, path:
44
47
  });
45
48
  } catch (e: unknown) {
46
49
  lastError = e as Error;
47
- if (attempt < MAX_RETRIES) continue;
48
- fail("network_error", `request failed after ${MAX_RETRIES + 1} attempts — ${lastError.message}`);
50
+ if (isSafe && attempt < MAX_RETRIES) continue;
51
+ fail("network_error", `request failed after ${attempt + 1} attempts — ${lastError.message}`);
49
52
  }
50
53
 
51
- if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
54
+ const canRetryStatus = res.status === 429 || (isSafe && RETRYABLE_STATUS.has(res.status));
55
+ if (canRetryStatus && attempt < MAX_RETRIES) {
52
56
  continue;
53
57
  }
54
58
 
@@ -17,6 +17,10 @@ export const jsonArg = {
17
17
  json: { type: "boolean", description: "Machine-readable JSON output", default: false },
18
18
  } as const satisfies ArgsDef;
19
19
 
20
+ export function shellEscape(s: string): string {
21
+ return "'" + s.replace(/'/g, "'\\''") + "'";
22
+ }
23
+
20
24
  export function isThreadId(id: string): boolean {
21
25
  return id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
22
26
  }
@@ -4,7 +4,9 @@ import { modelArgs, jsonArg, resolveModel } from "./_shared.js";
4
4
  export const captain = defineCommand({
5
5
  meta: { name: "captain", description: "Start Captain thread", alias: "plan" },
6
6
  args: {
7
- prompt: { type: "positional", description: "Task prompt", required: true },
7
+ prompt: { type: "positional", description: "Task prompt", required: false },
8
+ resume: { type: "string", description: "Resume from a previous task ID (messages the existing thread)" },
9
+ fix: { type: "string", description: "Specific fix instructions (used with --resume)" },
8
10
  ...modelArgs,
9
11
  ...jsonArg,
10
12
  },
@@ -16,10 +18,32 @@ export const captain = defineCommand({
16
18
 
17
19
  const cfg = config.load();
18
20
  const model = resolveModel(args) || cfg.defaultModel;
21
+
22
+ if (args.resume) {
23
+ const { resumeTask } = await import("../resume.js");
24
+ const r = await resumeTask(args.resume, { prompt: args.prompt, fix: args.fix, model, mode: "captain" });
25
+
26
+ if (IS_JSON) { out({ originalTask: r.originalTask, threadId: r.threadId, resumed: r.resumed, model }); return; }
27
+ if (r.resumed) {
28
+ log.success(`Messaged existing thread for ${r.originalTask}: https://capy.ai/project/${cfg.projectId}/captain/${r.threadId}`);
29
+ } else {
30
+ log.success(`Resumed ${r.originalTask} (new thread): https://capy.ai/project/${cfg.projectId}/captain/${r.threadId}`);
31
+ }
32
+ log.info(`Thread: ${r.threadId} Model: ${model}`);
33
+ return;
34
+ }
35
+
36
+ if (!args.prompt) {
37
+ if (IS_JSON) { out({ error: { code: "missing_prompt", message: "Prompt is required (or use --resume <id>)" } }); process.exit(1); }
38
+ console.error("capy: prompt is required (or use --resume <id>)");
39
+ process.exit(1);
40
+ }
41
+
19
42
  const data = await api.createThread(args.prompt, model);
43
+ const url = `https://capy.ai/project/${cfg.projectId}/captain/${data.id}`;
20
44
 
21
- if (IS_JSON) { out(data); return; }
22
- log.success(`Captain started: https://capy.ai/project/${cfg.projectId}/captain/${data.id}`);
45
+ if (IS_JSON) { out({ ...data, url }); return; }
46
+ log.success(`Captain started: ${url}`);
23
47
  log.info(`Thread: ${data.id} Model: ${model}`);
24
48
  },
25
49
  });
@@ -27,7 +51,9 @@ export const captain = defineCommand({
27
51
  export const build = defineCommand({
28
52
  meta: { name: "build", description: "Start Build agent (isolated)", alias: "run" },
29
53
  args: {
30
- prompt: { type: "positional", description: "Task prompt", required: true },
54
+ prompt: { type: "positional", description: "Task prompt", required: false },
55
+ resume: { type: "string", description: "Resume from a previous task ID" },
56
+ fix: { type: "string", description: "Specific fix instructions (used with --resume)" },
31
57
  ...modelArgs,
32
58
  ...jsonArg,
33
59
  },
@@ -39,10 +65,28 @@ export const build = defineCommand({
39
65
 
40
66
  const cfg = config.load();
41
67
  const model = resolveModel(args) || cfg.defaultModel;
68
+
69
+ if (args.resume) {
70
+ const { resumeTask } = await import("../resume.js");
71
+ const r = await resumeTask(args.resume, { prompt: args.prompt, fix: args.fix, model, mode: "build" });
72
+
73
+ if (IS_JSON) { out({ originalTask: r.originalTask, newTask: r.threadId, model }); return; }
74
+ log.success(`Resumed ${r.originalTask}: https://capy.ai/project/${cfg.projectId}/tasks/${r.threadId}`);
75
+ log.info(`ID: ${r.threadId} Model: ${model}`);
76
+ return;
77
+ }
78
+
79
+ if (!args.prompt) {
80
+ if (IS_JSON) { out({ error: { code: "missing_prompt", message: "Prompt is required (or use --resume <id>)" } }); process.exit(1); }
81
+ console.error("capy: prompt is required (or use --resume <id>)");
82
+ process.exit(1);
83
+ }
84
+
42
85
  const data = await api.createTask(args.prompt, model);
86
+ const url = `https://capy.ai/project/${cfg.projectId}/tasks/${data.id}`;
43
87
 
44
- if (IS_JSON) { out(data); return; }
45
- log.success(`Build started: https://capy.ai/project/${cfg.projectId}/tasks/${data.id}`);
88
+ if (IS_JSON) { out({ ...data, url }); return; }
89
+ log.success(`Build started: ${url}`);
46
90
  log.info(`ID: ${data.identifier} Model: ${model}`);
47
91
  },
48
92
  });
@@ -78,6 +78,8 @@ export const wait = defineCommand({
78
78
  const start = Date.now();
79
79
  if (!args.json) process.stderr.write(`Waiting for ${args.id} (${isThread ? "thread" : "task"})...`);
80
80
 
81
+ const PERMANENT_ERRORS = new Set(["no_api_key", "not_found", "unauthorized", "forbidden"]);
82
+
81
83
  let lastStatus = "unknown";
82
84
  while (Date.now() - start < timeoutMs) {
83
85
  try {
@@ -88,8 +90,13 @@ export const wait = defineCommand({
88
90
  console.log(`\n${isThread ? "Thread" : "Task"} ${data.status}.`);
89
91
  return;
90
92
  }
91
- } catch {
92
- // transient error during poll, keep trying
93
+ } catch (e: unknown) {
94
+ const { CapyError } = await import("../api.js");
95
+ if (e instanceof CapyError && PERMANENT_ERRORS.has(e.code)) {
96
+ if (args.json) { fmt.out({ error: { code: e.code, message: e.message } }); process.exit(1); }
97
+ console.error(`\ncapy: ${e.message}`);
98
+ process.exit(1);
99
+ }
93
100
  }
94
101
  if (!args.json) process.stderr.write(".");
95
102
  await new Promise(r => setTimeout(r, intervalMs));
@@ -210,10 +210,11 @@ export const approve = defineCommand({
210
210
  if (approveCmd) {
211
211
  try {
212
212
  const { execSync } = await import("node:child_process");
213
+ const { shellEscape } = await import("./_shared.js");
213
214
  const expanded = approveCmd
214
- .replace("{task}", task.identifier || task.id)
215
- .replace("{title}", (task.title || "").replace(/'/g, "'\\''"))
216
- .replace("{pr}", String(task.pullRequest?.number || ""));
215
+ .replace("{task}", shellEscape(task.identifier || task.id))
216
+ .replace("{title}", shellEscape(task.title || ""))
217
+ .replace("{pr}", shellEscape(String(task.pullRequest?.number || "")));
217
218
  execSync(expanded, { timeout: 15000, stdio: "pipe" });
218
219
  log.info("Post-approve hook ran.");
219
220
  } catch {}
@@ -223,7 +224,7 @@ export const approve = defineCommand({
223
224
  });
224
225
 
225
226
  export const retry = defineCommand({
226
- meta: { name: "retry", description: "Retry with failure context" },
227
+ meta: { name: "retry", description: "Alias for: capy captain --resume <id> --fix='...'" },
227
228
  args: {
228
229
  id: { type: "positional", description: "Task ID", required: true },
229
230
  fix: { type: "string", description: "Specific fix instructions" },
@@ -231,96 +232,25 @@ export const retry = defineCommand({
231
232
  ...jsonArg,
232
233
  },
233
234
  async run({ args }) {
234
- const api = await import("../api.js");
235
235
  const config = await import("../config.js");
236
- const github = await import("../github.js");
237
- const greptileApi = await import("../greptile.js");
236
+ const { resumeTask } = await import("../resume.js");
238
237
  const fmt = await import("../output.js");
239
238
  const { log } = await import("@clack/prompts");
240
239
 
241
- const task = await api.getTask(args.id);
242
240
  const cfg = config.load();
243
-
244
- let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
245
-
246
- try {
247
- const d = await api.getDiff(args.id);
248
- if (d.stats?.files && d.stats.files > 0) {
249
- context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
250
- context += `Files changed: ${(d.files || []).map(f => f.path).join(", ")}\n`;
251
- } else {
252
- context += `\nPrevious diff: empty (agent produced no changes)\n`;
253
- }
254
- } catch { context += "\nPrevious diff: unavailable\n"; }
255
-
256
- if (task.pullRequest?.number) {
257
- const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
258
- const prNum = task.pullRequest.number;
259
- const defaultBranch = cfg.repos.find(r => r.repoFullName === repo)?.branch || "main";
260
- const reviewComments = github.getPRReviewComments(repo, prNum);
261
- const ci = github.getCIStatus(repo, prNum);
262
-
263
- const reviewProvider = cfg.quality?.reviewProvider || "greptile";
264
- const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
265
-
266
- if (reviewProvider === "greptile" && hasGreptileKey) {
267
- const unaddressed = await greptileApi.getUnaddressedIssues(repo, prNum, defaultBranch);
268
- if (unaddressed.length > 0) {
269
- context += `\nUnaddressed Greptile issues (${unaddressed.length}):\n`;
270
- unaddressed.forEach(u => {
271
- context += ` ${u.file}:${u.line}: ${u.body}\n`;
272
- if (u.suggestedCode) context += ` Suggested fix: ${u.suggestedCode.slice(0, 200)}\n`;
273
- });
274
- } else {
275
- context += `\nGreptile: all issues addressed\n`;
276
- }
277
- } else {
278
- const issueComments = github.getPRIssueComments(repo, prNum);
279
- const greptileReview = github.parseGreptileReview(issueComments);
280
- if (greptileReview) {
281
- context += `\nGreptile review: ${greptileReview.score}/5 (stale, may not reflect latest)\n`;
282
- }
283
- }
284
-
285
- if (ci && !ci.allGreen) {
286
- context += `\nCI failures: ${ci.failing.map(f => f.name).join(", ")}\n`;
287
- }
288
- if (reviewComments.length) {
289
- context += `\nReview comments (${reviewComments.length}):\n`;
290
- reviewComments.slice(0, 5).forEach((c: any) => {
291
- context += ` ${c.path}:${c.line || "?"}: ${(c.body || "").slice(0, 150)}\n`;
292
- });
293
- }
294
- }
295
-
296
- const originalPrompt = task.prompt || task.title;
297
- let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.\n\n`;
298
- retryPrompt += `Original task: ${originalPrompt}\n\n`;
299
- retryPrompt += `--- CONTEXT FROM PREVIOUS ATTEMPT ---\n${context}\n`;
300
-
301
- if (args.fix) {
302
- retryPrompt += `--- SPECIFIC FIX REQUESTED ---\n${args.fix}\n\n`;
303
- }
304
-
305
- retryPrompt += `--- INSTRUCTIONS ---\n`;
306
- retryPrompt += `Fix the issues from the previous attempt. Do not repeat the same mistakes.\n`;
307
- retryPrompt += `Include tests. Run tests before completing. Verify CI will pass.\n`;
308
-
309
- if (task.status === "in_progress") {
310
- await api.stopTask(args.id, "Retrying with fixes");
311
- if (!args.json) log.info(`Stopped ${task.identifier}.`);
312
- }
313
-
314
241
  const model = resolveModel(args) || cfg.defaultModel;
315
- const data = await api.createThread(retryPrompt, model);
242
+ const r = await resumeTask(args.id, { fix: args.fix, model, mode: "captain" });
316
243
 
317
244
  if (args.json) {
318
- fmt.out({ originalTask: task.identifier, newThread: data.id, model, contextLines: context.split("\n").length });
245
+ fmt.out({ originalTask: r.originalTask, threadId: r.threadId, resumed: r.resumed, model });
319
246
  return;
320
247
  }
321
248
 
322
- log.success(`Retry started: https://capy.ai/project/${cfg.projectId}/captain/${data.id}`);
323
- log.info(`Thread: ${data.id} Model: ${model}`);
324
- log.info(`Context included: ${context.split("\n").length} lines from previous attempt.`);
249
+ if (r.resumed) {
250
+ log.success(`Messaged existing thread for ${r.originalTask}: https://capy.ai/project/${cfg.projectId}/captain/${r.threadId}`);
251
+ } else {
252
+ log.success(`Retry started (new thread): https://capy.ai/project/${cfg.projectId}/captain/${r.threadId}`);
253
+ }
254
+ log.info(`Thread: ${r.threadId} Model: ${model}`);
325
255
  },
326
256
  });
@@ -203,8 +203,8 @@ export const tools = defineCommand({
203
203
  const { log } = await import("@clack/prompts");
204
204
 
205
205
  const all: Record<string, { args: string; desc: string }> = {
206
- captain: { args: "<prompt>", desc: "Start Captain thread" },
207
- build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
206
+ captain: { args: "<prompt> [--resume <id>]", desc: "Start Captain thread (or resume)" },
207
+ build: { args: "<prompt> [--resume <id>]", desc: "Start Build agent (or resume)" },
208
208
  threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
209
209
  triage: { args: "[id,...]", desc: "Actionable triage with diffs + recs" },
210
210
  status: { args: "", desc: "Dashboard" },
@@ -218,7 +218,7 @@ export const tools = defineCommand({
218
218
  review: { args: "<id>", desc: "Quality gates check" },
219
219
  "re-review":{ args: "<id>", desc: "Trigger Greptile re-review" },
220
220
  approve: { args: "<id>", desc: "Approve if gates pass" },
221
- retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
221
+ retry: { args: "<id> [--fix=...]", desc: "Alias for captain --resume" },
222
222
  wait: { args: "<id>", desc: "Block until done" },
223
223
  watch: { args: "<id>", desc: "Poll + notify on completion" },
224
224
  unwatch: { args: "<id>", desc: "Stop watching" },
@@ -64,13 +64,14 @@ export const triage = defineCommand({
64
64
  results = await enrichTasks(api, tasks, cfg, brief);
65
65
  }
66
66
 
67
- // Cross-ref PR state with GitHub
68
- for (const r of results) {
69
- if (r.pr && r.pr.state === "closed") {
67
+ // Cross-ref closed PRs with GitHub to detect merged state (skip in brief mode)
68
+ if (!brief) {
69
+ const closed = results.filter(r => r.pr && r.pr.state === "closed");
70
+ for (const r of closed) {
70
71
  const repo = r._raw?.pullRequest?.repoFullName || cfg.repos[0]?.repoFullName;
71
72
  if (repo) {
72
- const ghPR = github.getPR(repo, r.pr.number);
73
- if (ghPR) r.pr.state = ghPR.state.toLowerCase();
73
+ const ghPR = github.getPR(repo, r.pr!.number);
74
+ if (ghPR) r.pr!.state = ghPR.state.toLowerCase();
74
75
  }
75
76
  }
76
77
  }
@@ -86,13 +87,11 @@ export const triage = defineCommand({
86
87
  category = "merged";
87
88
  } else if (r.pr && r.pr.state === "open") {
88
89
  category = "ready";
89
- } else if (r.diff && r.diff.files > 0 && !r.pr) {
90
- category = "needs_pr";
91
- } else if (r.status === "needs_review" && !r.pr && (!r.diff || r.diff.files === 0)) {
92
- category = brief ? "needs_pr" : "stuck";
93
90
  } else if (r.status === "needs_review" && r.pr) {
94
91
  category = "ready";
95
92
  } else if (r.status === "needs_review" && !r.pr) {
93
+ category = (!brief && (!r.diff || r.diff.files === 0)) ? "stuck" : "needs_pr";
94
+ } else if (r.diff && r.diff.files > 0 && !r.pr) {
96
95
  category = "needs_pr";
97
96
  } else {
98
97
  category = "stuck";
@@ -217,23 +216,46 @@ export const triage = defineCommand({
217
216
  },
218
217
  });
219
218
 
219
+ function pLimit(concurrency: number) {
220
+ let active = 0;
221
+ const queue: (() => void)[] = [];
222
+ return <T>(fn: () => Promise<T>): Promise<T> =>
223
+ new Promise<T>((resolve, reject) => {
224
+ const run = () => { active++; fn().then(resolve, reject).finally(() => { active--; queue.length && queue.shift()!(); }); };
225
+ active < concurrency ? run() : queue.push(run);
226
+ });
227
+ }
228
+
220
229
  async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: any, brief = false) {
221
- const enriched = await Promise.all(tasks.map(async (task) => {
230
+ if (brief) {
231
+ // Brief mode: listTasks already returns status + pullRequest, no extra fetches needed
232
+ return tasks.map(task => ({
233
+ identifier: task.identifier || task.id,
234
+ title: task.title || "",
235
+ status: task.status,
236
+ labels: task.labels || [],
237
+ createdAt: task.createdAt,
238
+ updatedAt: task.updatedAt,
239
+ pr: task.pullRequest?.number ? {
240
+ number: task.pullRequest.number,
241
+ state: task.pullRequest.state || "?",
242
+ url: task.pullRequest.url,
243
+ } : null,
244
+ diff: null,
245
+ jam: null,
246
+ _raw: task,
247
+ }));
248
+ }
249
+
250
+ const limit = pLimit(5);
251
+ const enriched = await Promise.all(tasks.map(task => limit(async () => {
222
252
  const id = task.identifier || task.id;
223
- let detail: any = task;
224
- let diff: any = null;
225
-
226
- try {
227
- if (!task.jams) {
228
- detail = await api.getTask(id);
229
- }
230
- } catch {}
231
253
 
232
- if (!brief) {
233
- try {
234
- diff = await api.getDiff(id);
235
- } catch {}
236
- }
254
+ // Fetch detail + diff in parallel
255
+ const [detail, diff] = await Promise.all([
256
+ task.jams ? task : api.getTask(id).catch(() => task),
257
+ api.getDiff(id).catch(() => null),
258
+ ]);
237
259
 
238
260
  const lastJam = (detail.jams || []).at(-1);
239
261
  const credits = lastJam?.credits;
@@ -265,7 +287,7 @@ async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: a
265
287
  } : null,
266
288
  _raw: detail,
267
289
  };
268
- }));
290
+ })));
269
291
 
270
292
  return enriched;
271
293
  }
package/src/mcp.ts CHANGED
@@ -10,6 +10,16 @@ import { isThreadId } from "./commands/_shared.js";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../package.json");
12
12
 
13
+ function pLimit(concurrency: number) {
14
+ let active = 0;
15
+ const queue: (() => void)[] = [];
16
+ return <T>(fn: () => Promise<T>): Promise<T> =>
17
+ new Promise<T>((resolve, reject) => {
18
+ const run = () => { active++; fn().then(resolve, reject).finally(() => { active--; queue.length && queue.shift()!(); }); };
19
+ active < concurrency ? run() : queue.push(run);
20
+ });
21
+ }
22
+
13
23
  const server = new McpServer({ name: "capy", version });
14
24
 
15
25
  function err(e: unknown) {
@@ -30,41 +40,59 @@ function structured(data: Record<string, unknown>) {
30
40
  // --- Orchestration ---
31
41
 
32
42
  server.registerTool("capy_captain", {
33
- description: "Start a Captain thread to delegate coding work to a Capy agent",
43
+ description: "Start a Captain thread. Use resume to continue from a previous task with auto-gathered context (diff, CI, reviews).",
34
44
  inputSchema: {
35
- prompt: z.string().describe("What the agent should do. Be specific: files, functions, acceptance criteria."),
45
+ prompt: z.string().optional().describe("What the agent should do. Required unless using resume."),
36
46
  model: z.string().optional().describe("Model ID override (default: config defaultModel)"),
47
+ resume: z.string().optional().describe("Task ID to resume from. Auto-gathers context from previous attempt."),
48
+ fix: z.string().optional().describe("Specific fix instructions (used with resume)"),
37
49
  },
38
50
  outputSchema: {
39
51
  threadId: z.string(),
40
52
  url: z.string(),
41
53
  },
42
54
  annotations: { openWorldHint: true },
43
- }, async ({ prompt, model }) => {
55
+ }, async ({ prompt, model, resume, fix }) => {
44
56
  try {
45
57
  const cfg = config.load();
58
+ if (resume) {
59
+ const { resumeTask } = await import("./resume.js");
60
+ const r = await resumeTask(resume, { prompt, fix, model, mode: "captain" });
61
+ return structured({ threadId: r.threadId, url: `https://capy.ai/project/${cfg.projectId}/captain/${r.threadId}`, originalTask: r.originalTask, resumed: r.resumed });
62
+ }
63
+ if (!prompt) return err(new Error("prompt is required (or use resume with a task ID)"));
46
64
  const data = await api.createThread(prompt, model);
47
65
  return structured({ threadId: data.id, url: `https://capy.ai/project/${cfg.projectId}/captain/${data.id}` });
48
66
  } catch (e) { return err(e); }
49
67
  });
50
68
 
51
69
  server.registerTool("capy_build", {
52
- description: "Start a Build agent for small isolated tasks (single-file fixes, scripts)",
70
+ description: "Start a Build agent for small isolated tasks. Use resume to continue from a previous task.",
53
71
  inputSchema: {
54
- prompt: z.string().describe("What to build. Be specific."),
72
+ prompt: z.string().optional().describe("What to build. Required unless using resume."),
55
73
  model: z.string().optional().describe("Model ID override"),
56
74
  title: z.string().optional().describe("Short task title"),
75
+ resume: z.string().optional().describe("Task ID to resume from."),
76
+ fix: z.string().optional().describe("Specific fix instructions (used with resume)"),
57
77
  },
58
78
  outputSchema: {
59
79
  id: z.string(),
60
80
  identifier: z.string(),
61
81
  status: z.string(),
82
+ url: z.string(),
62
83
  },
63
84
  annotations: { openWorldHint: true },
64
- }, async ({ prompt, model, title }) => {
85
+ }, async ({ prompt, model, title, resume, fix }) => {
65
86
  try {
87
+ const cfg = config.load();
88
+ if (resume) {
89
+ const { resumeTask } = await import("./resume.js");
90
+ const r = await resumeTask(resume, { prompt, fix, model, mode: "build" });
91
+ return structured({ id: r.threadId, identifier: r.originalTask, status: "in_progress", url: `https://capy.ai/project/${cfg.projectId}/tasks/${r.threadId}`, originalTask: r.originalTask });
92
+ }
93
+ if (!prompt) return err(new Error("prompt is required (or use resume with a task ID)"));
66
94
  const data = await api.createTask(prompt, model, { title, start: true });
67
- return structured({ id: data.id, identifier: data.identifier, status: data.status });
95
+ return structured({ id: data.id, identifier: data.identifier, status: data.status, url: `https://capy.ai/project/${cfg.projectId}/tasks/${data.id}` });
68
96
  } catch (e) { return err(e); }
69
97
  });
70
98
 
@@ -91,8 +119,10 @@ server.registerTool("capy_wait", {
91
119
  try {
92
120
  lastData = isThread ? await api.getThread(id) : await api.getTask(id);
93
121
  if (terminal.has(lastData.status)) return text(lastData);
94
- } catch {
95
- // transient error during poll, keep trying
122
+ } catch (e) {
123
+ if (e instanceof CapyError && ["not_found", "unauthorized", "forbidden", "no_api_key"].includes(e.code)) {
124
+ return err(e);
125
+ }
96
126
  }
97
127
  await new Promise(r => setTimeout(r, intervalMs));
98
128
  }
@@ -146,10 +176,11 @@ server.registerTool("capy_approve", {
146
176
  if (approved && cfg.approveCommand) {
147
177
  try {
148
178
  const { execSync } = await import("node:child_process");
179
+ const { shellEscape } = await import("./commands/_shared.js");
149
180
  const expanded = cfg.approveCommand
150
- .replace("{task}", task.identifier || task.id)
151
- .replace("{title}", (task.title || "").replace(/'/g, "'\\''"))
152
- .replace("{pr}", String(task.pullRequest?.number || ""));
181
+ .replace("{task}", shellEscape(task.identifier || task.id))
182
+ .replace("{title}", shellEscape(task.title || ""))
183
+ .replace("{pr}", shellEscape(String(task.pullRequest?.number || "")));
153
184
  execSync(expanded, { timeout: 15000, stdio: "pipe" });
154
185
  } catch {}
155
186
  }
@@ -159,7 +190,7 @@ server.registerTool("capy_approve", {
159
190
  });
160
191
 
161
192
  server.registerTool("capy_retry", {
162
- description: "Retry a failed task with context from previous attempt. Creates a new Captain thread.",
193
+ description: "Alias for capy_captain with resume. Retry a failed task with auto-gathered context.",
163
194
  inputSchema: {
164
195
  id: z.string().describe("Task ID to retry"),
165
196
  fix: z.string().optional().describe("Specific fix instructions"),
@@ -167,34 +198,17 @@ server.registerTool("capy_retry", {
167
198
  },
168
199
  outputSchema: {
169
200
  originalTask: z.string(),
170
- newThread: z.string(),
201
+ threadId: z.string(),
171
202
  model: z.string(),
172
203
  },
173
204
  annotations: { openWorldHint: true },
174
205
  }, async ({ id, fix, model }) => {
175
206
  try {
176
- const task = await api.getTask(id);
207
+ const { resumeTask } = await import("./resume.js");
177
208
  const cfg = config.load();
178
-
179
- let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
180
- try {
181
- const d = await api.getDiff(id);
182
- if (d.stats?.files && d.stats.files > 0) {
183
- context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
184
- }
185
- } catch {}
186
-
187
- let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.\n\nOriginal task: ${task.prompt || task.title}\n\n--- CONTEXT ---\n${context}\n`;
188
- if (fix) retryPrompt += `--- FIX ---\n${fix}\n\n`;
189
- retryPrompt += `Fix the issues. Include tests. Run tests before completing.\n`;
190
-
191
- if (task.status === "in_progress") {
192
- await api.stopTask(id, "Retrying with fixes");
193
- }
194
-
195
209
  const m = model || cfg.defaultModel;
196
- const data = await api.createThread(retryPrompt, m);
197
- return structured({ originalTask: task.identifier, newThread: data.id, model: m });
210
+ const r = await resumeTask(id, { fix, model: m, mode: "captain" });
211
+ return structured({ originalTask: r.originalTask, threadId: r.threadId, model: m, resumed: r.resumed });
198
212
  } catch (e) { return err(e); }
199
213
  });
200
214
 
@@ -218,48 +232,73 @@ server.registerTool("capy_triage", {
218
232
  tasks = data.items || [];
219
233
  }
220
234
 
221
- const enriched = await Promise.all(tasks.map(async (task: any) => {
222
- const id = task.identifier || task.id;
223
- let detail: any = task;
224
- let diff: any = null;
225
- try { if (!task.jams) detail = await api.getTask(id); } catch {}
226
- if (!brief) { try { diff = await api.getDiff(id); } catch {} }
235
+ function categorize(status: string, pr: any, diffStats: any, brief: boolean) {
236
+ if (status === "backlog") return "backlog";
237
+ if (status === "in_progress") return "in_progress";
238
+ if (pr?.state === "merged") return "merged";
239
+ if (pr && pr.state === "open") return "ready";
240
+ if (status === "needs_review" && pr) return "ready";
241
+ if (status === "needs_review" && !pr) return (!brief && (!diffStats || diffStats.files === 0)) ? "stuck" : "needs_pr";
242
+ if (diffStats && diffStats.files > 0 && !pr) return "needs_pr";
243
+ return "stuck";
244
+ }
227
245
 
228
- if (detail.pullRequest?.number && detail.pullRequest.state === "closed") {
229
- const repo = detail.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
230
- if (repo) {
231
- const ghPR = github.getPR(repo, detail.pullRequest.number);
232
- if (ghPR) detail.pullRequest.state = ghPR.state.toLowerCase();
246
+ let enriched: any[];
247
+ if (brief) {
248
+ enriched = tasks.map((task: any) => {
249
+ if (task.pullRequest?.number && task.pullRequest.state === "closed") {
250
+ const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
251
+ if (repo) {
252
+ const ghPR = github.getPR(repo, task.pullRequest.number);
253
+ if (ghPR) task.pullRequest.state = ghPR.state.toLowerCase();
254
+ }
255
+ }
256
+ const pr = task.pullRequest?.number ? { number: task.pullRequest.number, state: task.pullRequest.state || "?", url: task.pullRequest.url } : null;
257
+ return {
258
+ identifier: task.identifier || task.id,
259
+ title: task.title || "",
260
+ status: task.status,
261
+ labels: task.labels || [],
262
+ category: categorize(task.status, pr, null, true),
263
+ pr,
264
+ diff: null,
265
+ jam: null,
266
+ };
267
+ });
268
+ } else {
269
+ const limit = pLimit(5);
270
+ enriched = await Promise.all(tasks.map((task: any) => limit(async () => {
271
+ const id = task.identifier || task.id;
272
+ const [detail, diff] = await Promise.all([
273
+ task.jams ? task : api.getTask(id).catch(() => task),
274
+ api.getDiff(id).catch(() => null),
275
+ ]);
276
+
277
+ if (detail.pullRequest?.number && detail.pullRequest.state === "closed") {
278
+ const repo = detail.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
279
+ if (repo) {
280
+ const ghPR = github.getPR(repo, detail.pullRequest.number);
281
+ if (ghPR) detail.pullRequest.state = ghPR.state.toLowerCase();
282
+ }
233
283
  }
234
- }
235
284
 
236
- const lastJam = (detail.jams || []).at(-1);
237
- const credits = lastJam?.credits;
238
- const pr = detail.pullRequest?.number ? { number: detail.pullRequest.number, state: detail.pullRequest.state || "?", url: detail.pullRequest.url } : null;
239
- const diffStats = diff?.stats ? { files: diff.stats.files || 0, additions: diff.stats.additions || 0, deletions: diff.stats.deletions || 0 } : null;
240
-
241
- let category: string;
242
- if (detail.status === "backlog") category = "backlog";
243
- else if (detail.status === "in_progress") category = "in_progress";
244
- else if (pr?.state === "merged") category = "merged";
245
- else if (pr && pr.state === "open") category = "ready";
246
- else if (diffStats && diffStats.files > 0 && !pr) category = "needs_pr";
247
- else if (detail.status === "needs_review" && !pr && (!diffStats || diffStats.files === 0)) category = brief ? "needs_pr" : "stuck";
248
- else if (detail.status === "needs_review" && pr) category = "ready";
249
- else if (detail.status === "needs_review" && !pr) category = "needs_pr";
250
- else category = "stuck";
251
-
252
- return {
253
- identifier: detail.identifier || id,
254
- title: detail.title || "",
255
- status: detail.status,
256
- labels: detail.labels || [],
257
- category,
258
- pr,
259
- diff: diffStats,
260
- jam: lastJam ? { model: lastJam.model || "?", status: lastJam.status || "?", credits: { llm: typeof credits === "object" ? (credits?.llm ?? 0) : (credits || 0), vm: typeof credits === "object" ? (credits?.vm ?? 0) : 0 } } : null,
261
- };
262
- }));
285
+ const lastJam = (detail.jams || []).at(-1);
286
+ const credits = lastJam?.credits;
287
+ const pr = detail.pullRequest?.number ? { number: detail.pullRequest.number, state: detail.pullRequest.state || "?", url: detail.pullRequest.url } : null;
288
+ const diffStats = diff?.stats ? { files: diff.stats.files || 0, additions: diff.stats.additions || 0, deletions: diff.stats.deletions || 0 } : null;
289
+
290
+ return {
291
+ identifier: detail.identifier || id,
292
+ title: detail.title || "",
293
+ status: detail.status,
294
+ labels: detail.labels || [],
295
+ category: categorize(detail.status, pr, diffStats, false),
296
+ pr,
297
+ diff: diffStats,
298
+ jam: lastJam ? { model: lastJam.model || "?", status: lastJam.status || "?", credits: { llm: typeof credits === "object" ? (credits?.llm ?? 0) : (credits || 0), vm: typeof credits === "object" ? (credits?.vm ?? 0) : 0 } } : null,
299
+ };
300
+ })));
301
+ }
263
302
 
264
303
  const summary = {
265
304
  total: enriched.length,
@@ -485,12 +524,15 @@ server.registerTool("capy_project", {
485
524
  id: z.string(),
486
525
  name: z.string(),
487
526
  taskCode: z.string(),
527
+ repos: z.array(z.object({ repoFullName: z.string(), branch: z.string() })),
528
+ createdAt: z.string().optional(),
529
+ updatedAt: z.string().optional(),
488
530
  },
489
531
  annotations: { readOnlyHint: true, idempotentHint: true },
490
532
  }, async ({ id }) => {
491
533
  try {
492
534
  const data = await api.getProject(id);
493
- return structured({ id: data.id, name: data.name, taskCode: data.taskCode, repos: data.repos as unknown as Record<string, unknown>, createdAt: data.createdAt, updatedAt: data.updatedAt });
535
+ return structured({ id: data.id, name: data.name, taskCode: data.taskCode, repos: data.repos as unknown as Record<string, unknown>[], createdAt: data.createdAt, updatedAt: data.updatedAt });
494
536
  } catch (e) { return err(e); }
495
537
  });
496
538
 
package/src/resume.ts ADDED
@@ -0,0 +1,139 @@
1
+ export interface ResumeResult {
2
+ threadId: string;
3
+ originalTask: string;
4
+ resumed: boolean;
5
+ }
6
+
7
+ export async function resumeTask(taskId: string, opts: {
8
+ prompt?: string;
9
+ fix?: string;
10
+ model?: string;
11
+ mode: "captain" | "build";
12
+ }): Promise<ResumeResult> {
13
+ const api = await import("./api.js");
14
+ const config = await import("./config.js");
15
+
16
+ const task = await api.getTask(taskId);
17
+ const cfg = config.load();
18
+ const model = opts.model || cfg.defaultModel;
19
+
20
+ if (task.status === "in_progress") {
21
+ await api.stopTask(taskId, "Resuming with fixes");
22
+ }
23
+
24
+ // Find parent Captain thread by searching recent threads for this task
25
+ if (opts.mode === "captain") {
26
+ const parentThreadId = await findParentThread(api, taskId, task.id);
27
+ if (parentThreadId) {
28
+ const message = buildMessage(task, opts.prompt, opts.fix);
29
+ await api.messageThread(parentThreadId, message, { model });
30
+ return { threadId: parentThreadId, originalTask: task.identifier, resumed: true };
31
+ }
32
+ }
33
+
34
+ // Fallback: no parent thread found (standalone build task, or old task)
35
+ const context = await gatherContext(taskId, task, cfg);
36
+ const fullPrompt = buildNewPrompt(task, context, opts.prompt, opts.fix);
37
+
38
+ if (opts.mode === "build") {
39
+ const data = await api.createTask(fullPrompt, model);
40
+ return { threadId: data.id, originalTask: task.identifier, resumed: false };
41
+ }
42
+
43
+ const data = await api.createThread(fullPrompt, model);
44
+ return { threadId: data.id, originalTask: task.identifier, resumed: false };
45
+ }
46
+
47
+ async function findParentThread(api: typeof import("./api.js"), taskIdentifier: string, taskUuid: string): Promise<string | null> {
48
+ try {
49
+ const threads = await api.listThreads({ limit: 50 });
50
+ for (const thread of threads.items || []) {
51
+ if (thread.tasks?.some(t => t.identifier === taskIdentifier || t.id === taskUuid)) {
52
+ return thread.id;
53
+ }
54
+ }
55
+ } catch {}
56
+ return null;
57
+ }
58
+
59
+ function buildMessage(task: any, prompt?: string, fix?: string): string {
60
+ let msg = "";
61
+ if (fix) msg += fix;
62
+ if (prompt && prompt !== (task.prompt || task.title)) {
63
+ msg += (msg ? "\n\n" : "") + prompt;
64
+ }
65
+ if (!msg) msg = "Fix the issues from the previous attempt. Include tests. Run tests before completing.";
66
+ return msg;
67
+ }
68
+
69
+ async function gatherContext(taskId: string, task: any, cfg: any): Promise<string> {
70
+ const api = await import("./api.js");
71
+ const github = await import("./github.js");
72
+ const greptileApi = await import("./greptile.js");
73
+
74
+ let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]\n`;
75
+
76
+ try {
77
+ const d = await api.getDiff(taskId);
78
+ if (d.stats?.files && d.stats.files > 0) {
79
+ context += `\nPrevious diff: +${d.stats.additions} -${d.stats.deletions} in ${d.stats.files} files\n`;
80
+ context += `Files changed: ${(d.files || []).map((f: any) => f.path).join(", ")}\n`;
81
+ } else {
82
+ context += `\nPrevious diff: empty (agent produced no changes)\n`;
83
+ }
84
+ } catch { context += "\nPrevious diff: unavailable\n"; }
85
+
86
+ if (task.pullRequest?.number) {
87
+ const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
88
+ const prNum = task.pullRequest.number;
89
+ const defaultBranch = cfg.repos.find((r: any) => r.repoFullName === repo)?.branch || "main";
90
+ const reviewComments = github.getPRReviewComments(repo, prNum);
91
+ const ci = github.getCIStatus(repo, prNum);
92
+
93
+ const reviewProvider = cfg.quality?.reviewProvider || "greptile";
94
+ const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
95
+
96
+ if ((reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey) {
97
+ const unaddressed = await greptileApi.getUnaddressedIssues(repo, prNum, defaultBranch);
98
+ if (unaddressed.length > 0) {
99
+ context += `\nUnaddressed Greptile issues (${unaddressed.length}):\n`;
100
+ unaddressed.forEach((u: any) => {
101
+ context += ` ${u.file}:${u.line}: ${u.body}\n`;
102
+ if (u.suggestedCode) context += ` Suggested fix: ${u.suggestedCode.slice(0, 200)}\n`;
103
+ });
104
+ } else {
105
+ context += `\nGreptile: all issues addressed\n`;
106
+ }
107
+ } else {
108
+ const issueComments = github.getPRIssueComments(repo, prNum);
109
+ const greptileReview = github.parseGreptileReview(issueComments);
110
+ if (greptileReview) {
111
+ context += `\nGreptile review: ${greptileReview.score}/5 (stale, may not reflect latest)\n`;
112
+ }
113
+ }
114
+
115
+ if (ci && !ci.allGreen) {
116
+ context += `\nCI failures: ${ci.failing.map((f: any) => f.name).join(", ")}\n`;
117
+ }
118
+ if (reviewComments.length) {
119
+ context += `\nReview comments (${reviewComments.length}):\n`;
120
+ reviewComments.slice(0, 5).forEach((c: any) => {
121
+ context += ` ${c.path}:${c.line || "?"}: ${(c.body || "").slice(0, 150)}\n`;
122
+ });
123
+ }
124
+ }
125
+
126
+ return context;
127
+ }
128
+
129
+ function buildNewPrompt(task: any, context: string, prompt?: string, fix?: string): string {
130
+ let p = `RESUME: Continuing from a previous attempt.\n\n`;
131
+ p += `Original task: ${task.prompt || task.title}\n\n`;
132
+ p += `--- CONTEXT FROM PREVIOUS ATTEMPT ---\n${context}\n`;
133
+ if (fix) p += `--- SPECIFIC FIX REQUESTED ---\n${fix}\n\n`;
134
+ if (prompt && prompt !== (task.prompt || task.title)) p += `--- UPDATED INSTRUCTIONS ---\n${prompt}\n\n`;
135
+ p += `--- INSTRUCTIONS ---\n`;
136
+ p += `Fix the issues from the previous attempt. Do not repeat the same mistakes.\n`;
137
+ p += `Include tests. Run tests before completing. Verify CI will pass.\n`;
138
+ return p;
139
+ }
package/src/watch.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execSync, execFileSync } from "node:child_process";
4
4
  import * as config from "./config.js";
5
+ import { shellEscape } from "./commands/_shared.js";
5
6
  import type { WatchEntry } from "./types.js";
6
7
 
7
8
  function getCrontab(): string {
@@ -54,7 +55,7 @@ export function notify(text: string): boolean {
54
55
  const cmd = cfg.notifyCommand;
55
56
  if (!cmd) return false;
56
57
  try {
57
- execSync(cmd.replace("{text}", text.replace(/'/g, "'\\''")), {
58
+ execSync(cmd.replace("{text}", shellEscape(text)), {
58
59
  timeout: 15000, stdio: "pipe",
59
60
  });
60
61
  return true;