capyai 0.5.5 → 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 +1 -1
- package/skills/capy/SKILL.md +22 -18
- package/src/api.ts +9 -5
- package/src/commands/_shared.ts +4 -0
- package/src/commands/agents.ts +50 -6
- package/src/commands/monitoring.ts +9 -2
- package/src/commands/quality.ts +14 -84
- package/src/commands/setup.ts +3 -3
- package/src/commands/triage.ts +46 -24
- package/src/mcp.ts +116 -74
- package/src/resume.ts +139 -0
- package/src/watch.ts +2 -1
package/package.json
CHANGED
package/skills/capy/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
→
|
|
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. **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
|
|
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
|
|
|
@@ -133,15 +133,15 @@ for TASK in $TASKS; do
|
|
|
133
133
|
QUALITY=$(capy review "$TASK" --json)
|
|
134
134
|
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
135
135
|
|
|
136
|
-
#
|
|
136
|
+
# Resume loop (max 3)
|
|
137
137
|
ATTEMPTS=0
|
|
138
138
|
while [ "$PASS" != "true" ] && [ "$ATTEMPTS" -lt 3 ]; do
|
|
139
139
|
FAILING=$(echo "$QUALITY" | jq -r '.quality.gates[] | select(.pass == false) | .name + ": " + .detail')
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
RESUME=$(capy captain --resume "$TASK" --fix="Fix these failures: $FAILING" --json)
|
|
141
|
+
RESUME_THREAD=$(echo "$RESUME" | jq -r '.threadId')
|
|
142
142
|
|
|
143
|
-
capy wait "$
|
|
144
|
-
TASK=$(capy threads get "$
|
|
143
|
+
capy wait "$RESUME_THREAD" --timeout=600 --json
|
|
144
|
+
TASK=$(capy threads get "$RESUME_THREAD" --json | jq -r '.tasks[-1].identifier')
|
|
145
145
|
|
|
146
146
|
HAS_PR=$(capy get "$TASK" --json | jq -r '.pullRequest.number // empty')
|
|
147
147
|
[ -z "$HAS_PR" ] && capy pr "$TASK" --json
|
|
@@ -187,7 +187,7 @@ Map categories to actions:
|
|
|
187
187
|
- `in_progress` → wait
|
|
188
188
|
- `needs_pr` → `capy pr <id>` then review
|
|
189
189
|
- `ready` → `capy review <id>` then approve/retry
|
|
190
|
-
- `stuck` → `capy
|
|
190
|
+
- `stuck` → `capy captain --resume <id> --fix="..."` or `capy stop <id>`
|
|
191
191
|
- `backlog` → `capy start <id>` if the user wants it running
|
|
192
192
|
- `merged` → done, ignore
|
|
193
193
|
|
|
@@ -199,10 +199,10 @@ Map categories to actions:
|
|
|
199
199
|
|------|--------|----------------------|
|
|
200
200
|
| `pr_exists` | A PR was created | Run `capy pr <id> --json` first |
|
|
201
201
|
| `pr_open` | PR is open or merged | PR was closed. Check why. May need a new PR. |
|
|
202
|
-
| `ci` | CI checks are green |
|
|
203
|
-
| `greptile` | No unaddressed code review issues | If "still processing": wait 60s, re-review. If issues listed:
|
|
204
|
-
| `threads` | No unresolved GitHub review threads |
|
|
205
|
-
| `tests` | Diff includes test files |
|
|
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"` |
|
|
206
206
|
|
|
207
207
|
## Commands reference
|
|
208
208
|
|
|
@@ -211,10 +211,15 @@ All commands support `--json` for structured output. All errors return `{ "error
|
|
|
211
211
|
### Start work
|
|
212
212
|
|
|
213
213
|
```bash
|
|
214
|
-
capy captain "<prompt>" --json
|
|
215
|
-
capy
|
|
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
|
|
216
219
|
```
|
|
217
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
|
+
|
|
218
223
|
Model shortcuts: `--opus`, `--sonnet`, `--mini`, `--fast`, `--kimi`, `--gemini`, `--grok`, `--qwen`, or `--model=<id>`.
|
|
219
224
|
|
|
220
225
|
### Wait and monitor
|
|
@@ -232,9 +237,8 @@ capy status --json # → { threads, tasks, watches }
|
|
|
232
237
|
|
|
233
238
|
```bash
|
|
234
239
|
capy pr <id> [--draft] [--description="..."] --json # → { url, number, title }
|
|
235
|
-
capy review <id> --json # → { task, quality: { pass, gates }, diff }
|
|
240
|
+
capy review <id> --json # → { task, quality: { pass, passed, total, gates, summary }, diff, unaddressed, reviewProvider }
|
|
236
241
|
capy approve <id> [--force] --json # → { task, quality, approved }
|
|
237
|
-
capy retry <id> --fix="..." --json # → { originalTask, newThread, model }
|
|
238
242
|
capy start <id> --json # → task object
|
|
239
243
|
capy stop <id> --json # → task/thread object
|
|
240
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
|
|
35
|
-
|
|
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 ${
|
|
50
|
+
if (isSafe && attempt < MAX_RETRIES) continue;
|
|
51
|
+
fail("network_error", `request failed after ${attempt + 1} attempts — ${lastError.message}`);
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
|
|
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
|
|
package/src/commands/_shared.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/agents.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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));
|
package/src/commands/quality.ts
CHANGED
|
@@ -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 || "")
|
|
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: "
|
|
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
|
|
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
|
|
242
|
+
const r = await resumeTask(args.id, { fix: args.fix, model, mode: "captain" });
|
|
316
243
|
|
|
317
244
|
if (args.json) {
|
|
318
|
-
fmt.out({ originalTask:
|
|
245
|
+
fmt.out({ originalTask: r.originalTask, threadId: r.threadId, resumed: r.resumed, model });
|
|
319
246
|
return;
|
|
320
247
|
}
|
|
321
248
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
});
|
package/src/commands/setup.ts
CHANGED
|
@@ -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>",
|
|
207
|
-
build: { args: "<prompt>",
|
|
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: "
|
|
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" },
|
package/src/commands/triage.ts
CHANGED
|
@@ -64,13 +64,14 @@ export const triage = defineCommand({
|
|
|
64
64
|
results = await enrichTasks(api, tasks, cfg, brief);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Cross-ref
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
if (ghPR) r.pr
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 || "")
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
|
197
|
-
return structured({ originalTask:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
58
|
+
execSync(cmd.replace("{text}", shellEscape(text)), {
|
|
58
59
|
timeout: 15000, stdio: "pipe",
|
|
59
60
|
});
|
|
60
61
|
return true;
|