capyai 0.4.1 → 0.5.1
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/AGENTS.md +9 -121
- package/README.md +3 -1
- package/bin/capy.ts +1 -0
- package/package.json +1 -1
- package/skills/capy/SKILL.md +244 -64
- package/src/api.ts +45 -18
- package/src/commands/_shared.ts +4 -0
- package/src/commands/diff-pr.ts +6 -1
- package/src/commands/monitoring.ts +16 -10
- package/src/commands/quality.ts +5 -6
- package/src/commands/setup.ts +45 -0
- package/src/commands/tasks.ts +4 -2
- package/src/commands/threads.ts +7 -3
- package/src/commands/triage.ts +16 -11
- package/src/config.ts +1 -3
- package/src/github.ts +1 -1
- package/src/mcp.ts +67 -26
- package/src/types.ts +0 -2
- package/src/watch.ts +4 -4
package/AGENTS.md
CHANGED
|
@@ -81,7 +81,7 @@ Config file locations:
|
|
|
81
81
|
- Claude Desktop: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
|
|
82
82
|
- Cursor: `.cursor/mcp.json`
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
25 MCP tools with full API parity:
|
|
85
85
|
|
|
86
86
|
| Tool | What it does | Annotations |
|
|
87
87
|
|------|-------------|-------------|
|
|
@@ -104,8 +104,12 @@ Config file locations:
|
|
|
104
104
|
| `capy_models` | List models | readOnly, idempotent |
|
|
105
105
|
| `capy_pool_status` | Warm pool config + VM status | readOnly, idempotent |
|
|
106
106
|
| `capy_pool_update` | Update warm pool config | openWorld |
|
|
107
|
+
| `capy_pool_test` | Test VM boot with setup commands | openWorld |
|
|
107
108
|
| `capy_pool_instances` | List warm pool VMs | readOnly, idempotent |
|
|
108
109
|
| `capy_pool_clear` | Clear/refresh warm pool | destructive |
|
|
110
|
+
| `capy_projects` | List all projects | readOnly, idempotent |
|
|
111
|
+
| `capy_project` | Get project details (repos, code, config) | readOnly, idempotent |
|
|
112
|
+
| `capy_triage` | Actionable triage with categories + recs (brief mode available) | readOnly |
|
|
109
113
|
|
|
110
114
|
Tools with predictable outputs (`capy_captain`, `capy_build`, `capy_review`, `capy_approve`, `capy_retry`) declare `outputSchema` for typed structured content per the 2025-03-26 MCP spec.
|
|
111
115
|
|
|
@@ -123,128 +127,12 @@ capy models --json
|
|
|
123
127
|
|
|
124
128
|
You are now fully configured.
|
|
125
129
|
|
|
126
|
-
|
|
130
|
+
## Usage
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
### Delegate work
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
capy captain "Implement feature X. Files: src/foo.ts. Tests required." --json
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Returns `{ "threadId": "...", "url": "..." }`. Save the `threadId`.
|
|
137
|
-
|
|
138
|
-
### Wait for completion
|
|
132
|
+
For the full operating manual (commands, workflows, decision trees, guardrails), install the capy skill:
|
|
139
133
|
|
|
140
134
|
```bash
|
|
141
|
-
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
Blocks until the thread reaches a terminal state. Returns the full thread object with `tasks` array. Each task has an `identifier` (like `SCO-1`).
|
|
145
|
-
|
|
146
|
-
### Review quality
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
capy review <taskId> --json
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
Returns `{ "task": "SCO-1", "quality": { "pass": true, "passed": 5, "total": 5, "gates": [...] } }`.
|
|
153
|
-
|
|
154
|
-
Read `quality.pass`. If `true`, approve. If `false`, read `quality.gates` for what failed.
|
|
155
|
-
|
|
156
|
-
### Approve or retry
|
|
157
|
-
|
|
158
|
-
```bash
|
|
159
|
-
# If quality.pass is true:
|
|
160
|
-
capy approve <taskId> --json
|
|
161
|
-
|
|
162
|
-
# If quality.pass is false:
|
|
163
|
-
capy retry <taskId> --fix="describe what to fix" --json
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
`retry` returns `{ "newThread": "..." }`. Wait on that new thread ID, then review again.
|
|
167
|
-
|
|
168
|
-
### The full loop
|
|
169
|
-
|
|
170
|
-
```bash
|
|
171
|
-
THREAD=$(capy captain "your prompt" --json | jq -r '.threadId')
|
|
172
|
-
capy wait "$THREAD" --timeout=600 --json
|
|
173
|
-
|
|
174
|
-
TASK=$(capy threads get "$THREAD" --json | jq -r '.tasks[0].identifier')
|
|
175
|
-
QUALITY=$(capy review "$TASK" --json)
|
|
176
|
-
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
177
|
-
|
|
178
|
-
while [ "$PASS" != "true" ]; do
|
|
179
|
-
GATES=$(echo "$QUALITY" | jq -r '.quality.gates[] | select(.pass == false) | .name + ": " + .detail')
|
|
180
|
-
NEW=$(capy retry "$TASK" --fix="Fix these failures: $GATES" --json | jq -r '.newThread')
|
|
181
|
-
capy wait "$NEW" --timeout=600 --json
|
|
182
|
-
TASK=$(capy threads get "$NEW" --json | jq -r '.tasks[0].identifier')
|
|
183
|
-
QUALITY=$(capy review "$TASK" --json)
|
|
184
|
-
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
185
|
-
done
|
|
186
|
-
|
|
187
|
-
capy approve "$TASK" --json
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
### Background monitoring
|
|
191
|
-
|
|
192
|
-
For async fire-and-forget work. Sets a cron job that polls and runs your notification command when done.
|
|
193
|
-
|
|
194
|
-
```bash
|
|
195
|
-
capy watch <threadId>
|
|
196
|
-
capy config notifyCommand "<your notification command> {text}"
|
|
135
|
+
npx skills add yazcaleb/capy-cli
|
|
197
136
|
```
|
|
198
137
|
|
|
199
|
-
`
|
|
200
|
-
- `openclaw system event --text {text} --mode now`
|
|
201
|
-
- `echo {text} >> ~/capy-notifications.log`
|
|
202
|
-
|
|
203
|
-
List watches: `capy watches --json`. Remove: `capy unwatch <id>`.
|
|
204
|
-
|
|
205
|
-
### All commands
|
|
206
|
-
|
|
207
|
-
Every command supports `--json` for structured output. Errors always return `{ "error": { "code": "...", "message": "..." } }`.
|
|
208
|
-
|
|
209
|
-
| Command | What it does |
|
|
210
|
-
|---------|-------------|
|
|
211
|
-
| `capy captain "<prompt>"` | Start Captain thread |
|
|
212
|
-
| `capy build "<prompt>"` | Start Build agent (small isolated tasks) |
|
|
213
|
-
| `capy start <id>` | Start/resume a backlog task |
|
|
214
|
-
| `capy stop <id> [reason]` | Stop a running task |
|
|
215
|
-
| `capy msg <id> "<text>"` | Message a running task |
|
|
216
|
-
| `capy wait <id> --timeout=N` | Block until terminal state |
|
|
217
|
-
| `capy review <id>` | Run quality gates (pass/fail) |
|
|
218
|
-
| `capy re-review <id>` | Trigger fresh Greptile review |
|
|
219
|
-
| `capy approve <id>` | Approve if gates pass |
|
|
220
|
-
| `capy retry <id> --fix="..."` | Retry with context from failure |
|
|
221
|
-
| `capy status` | Dashboard (all threads + tasks) |
|
|
222
|
-
| `capy list [status]` | List tasks (filter: in_progress, needs_review, backlog, archived) |
|
|
223
|
-
| `capy get <id>` | Task details (jams, PR state, credits) |
|
|
224
|
-
| `capy diff <id>` | View diff (file-by-file with patches) |
|
|
225
|
-
| `capy pr <id> [title]` | Create PR for a task |
|
|
226
|
-
| `capy watch <id>` | Cron poll + notify on completion |
|
|
227
|
-
| `capy unwatch <id>` | Stop watching |
|
|
228
|
-
| `capy watches` | List active watches |
|
|
229
|
-
| `capy threads list` | List Captain threads |
|
|
230
|
-
| `capy threads get <id>` | Thread details (tasks, PRs) |
|
|
231
|
-
| `capy threads msg <id> "<text>"` | Message a thread |
|
|
232
|
-
| `capy threads stop <id>` | Stop a thread |
|
|
233
|
-
| `capy threads messages <id>` | Read thread conversation history |
|
|
234
|
-
| `capy pool` | Warm pool status |
|
|
235
|
-
| `capy pool set --size=N --age=M` | Update warm pool config |
|
|
236
|
-
| `capy pool test` | Test VM boot |
|
|
237
|
-
| `capy pool instances [status]` | List pool VMs |
|
|
238
|
-
| `capy pool instance <id>` | VM detail + logs |
|
|
239
|
-
| `capy pool clear [--replenish]` | Clear/refresh pool |
|
|
240
|
-
| `capy config [key] [value]` | Get/set config |
|
|
241
|
-
| `capy models` | List available models |
|
|
242
|
-
| `capy tools` | Show all commands + env vars |
|
|
243
|
-
|
|
244
|
-
### Prompting tips
|
|
245
|
-
|
|
246
|
-
Bad: `"Fix the CI issue"`
|
|
247
|
-
|
|
248
|
-
Good: `"Fix CI for crypto-trading pack. The changeset file is missing. Add a changeset entry for @veto/crypto-trading. Run changeset validation. Reference: PLW-201."`
|
|
249
|
-
|
|
250
|
-
Always include: specific files, specific functions, acceptance criteria, references to related tasks/issues.
|
|
138
|
+
This loads `skills/capy/SKILL.md` into your context with everything you need to orchestrate Capy agents.
|
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ Every command supports `--json` for machine-readable output.
|
|
|
54
54
|
| `capy pr <id>` | Create PR for task |
|
|
55
55
|
| `capy watch <id>` | Cron poll + notify on completion |
|
|
56
56
|
| `capy threads [list\|get\|msg\|stop\|messages]` | Manage Captain threads |
|
|
57
|
+
| `capy projects [list\|get]` | List/get projects |
|
|
58
|
+
| `capy triage [ids] [--brief]` | Actionable triage with categories + recommendations |
|
|
57
59
|
| `capy pool [status\|set\|test\|instances\|clear]` | Manage warm pool VMs |
|
|
58
60
|
| `capy models` | List available models |
|
|
59
61
|
| `capy config [key] [value]` | Get/set config |
|
|
@@ -88,7 +90,7 @@ For agents that prefer MCP over CLI:
|
|
|
88
90
|
}
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
25 tools with full API parity, including projects, triage, and warm pool management.
|
|
92
94
|
|
|
93
95
|
## Config
|
|
94
96
|
|
package/bin/capy.ts
CHANGED
|
@@ -24,6 +24,7 @@ const main = defineCommand({
|
|
|
24
24
|
msg: () => import("../src/commands/tasks.js").then(m => m.msg),
|
|
25
25
|
diff: () => import("../src/commands/diff-pr.js").then(m => m.diff),
|
|
26
26
|
pr: () => import("../src/commands/diff-pr.js").then(m => m.pr),
|
|
27
|
+
projects: () => import("../src/commands/setup.js").then(m => m.projects),
|
|
27
28
|
models: () => import("../src/commands/setup.js").then(m => m.models),
|
|
28
29
|
tools: () => import("../src/commands/setup.js").then(m => m.tools),
|
|
29
30
|
status: () => import("../src/commands/setup.js").then(m => m.status),
|
package/package.json
CHANGED
package/skills/capy/SKILL.md
CHANGED
|
@@ -3,101 +3,281 @@ 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.5.0"
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# capy
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
You orchestrate Capy.ai coding agents. You start them, wait for them, gate their output on quality, then approve or retry. This skill makes you a 10x Capy orchestrator.
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## When to use this skill
|
|
14
|
+
|
|
15
|
+
Use capy when the user wants to:
|
|
16
|
+
- Delegate coding work to an AI agent (not do it locally)
|
|
17
|
+
- Check on tasks running on Capy (status, triage, review)
|
|
18
|
+
- Approve, retry, or stop Capy tasks
|
|
19
|
+
- Create PRs for completed Capy work
|
|
20
|
+
- Manage Capy projects, warm pool VMs, or configuration
|
|
21
|
+
|
|
22
|
+
Do NOT use capy when:
|
|
23
|
+
- The user wants you to write code yourself (just write it)
|
|
24
|
+
- The user is talking about a different CI/CD system
|
|
25
|
+
- The task is too small to delegate (a one-line fix, a config change)
|
|
26
|
+
|
|
27
|
+
## Core objects
|
|
28
|
+
|
|
29
|
+
Understand these three objects before doing anything.
|
|
30
|
+
|
|
31
|
+
**Thread** — A Captain session. You give it a prompt, it plans and may spawn multiple tasks. Threads have long opaque IDs (UUIDs). Terminal states: `idle`, `archived`, `completed`. You start threads with `capy captain`.
|
|
32
|
+
|
|
33
|
+
**Task** — A single unit of coding work. Has a short identifier like `SCO-15`. A task produces a diff (code changes) and optionally a pull request. Terminal states: `needs_review`, `archived`, `completed`, `failed`. You start standalone tasks with `capy build`.
|
|
34
|
+
|
|
35
|
+
**Jam** — An execution run inside a task. Each jam has a model, a status, and credit usage. A task can have multiple jams (one per retry). When `jam.status` is `"idle"` and credits are zero, that jam is finished. The task is done working. You cannot send it more messages.
|
|
36
|
+
|
|
37
|
+
**Lifecycle:**
|
|
14
38
|
|
|
15
|
-
```bash
|
|
16
|
-
npm i -g capyai
|
|
17
|
-
capy init
|
|
18
39
|
```
|
|
40
|
+
[you] captain/build
|
|
41
|
+
→ [capy agent] in_progress (writing code, running tests)
|
|
42
|
+
→ [capy agent] needs_review (agent stopped, diff may exist)
|
|
43
|
+
→ [you] check diff, create PR, run quality gates
|
|
44
|
+
→ [you] approve OR retry
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`needs_review` means the agent finished working. It does NOT mean the code is ready to merge. You must still check the diff, create a PR, review quality gates, and approve.
|
|
48
|
+
|
|
49
|
+
## Decision tree
|
|
50
|
+
|
|
51
|
+
When you encounter a task, look at its status and act accordingly. Do exactly one thing.
|
|
52
|
+
|
|
53
|
+
**If status is `in_progress`:**
|
|
54
|
+
→ Wait for it. `capy wait <id> --timeout=600 --json`
|
|
55
|
+
|
|
56
|
+
**If status is `needs_review`:**
|
|
57
|
+
1. Check if it has a diff: `capy get <id> --json` and look at `pullRequest` field
|
|
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`
|
|
60
|
+
3. If diff exists but no PR:
|
|
61
|
+
→ Create a PR first: `capy pr <id> --json`
|
|
62
|
+
→ Then review: `capy review <id> --json`
|
|
63
|
+
4. If diff exists and PR exists:
|
|
64
|
+
→ Review: `capy review <id> --json`
|
|
65
|
+
→ If `quality.pass` is true → `capy approve <id> --json`
|
|
66
|
+
→ If `quality.pass` is false → check which gates failed (see Quality Gates section)
|
|
67
|
+
|
|
68
|
+
**If status is `backlog`:**
|
|
69
|
+
→ Start it. `capy start <id> --json`
|
|
70
|
+
|
|
71
|
+
**If status is `failed`:**
|
|
72
|
+
→ Retry. `capy retry <id> --fix="..." --json`
|
|
73
|
+
|
|
74
|
+
**If status is `archived`:**
|
|
75
|
+
→ Ignore. This task is dead.
|
|
76
|
+
|
|
77
|
+
**If PR state is `merged`:**
|
|
78
|
+
→ Done. Nothing to do.
|
|
79
|
+
|
|
80
|
+
## Guardrails
|
|
81
|
+
|
|
82
|
+
These are the mistakes agents make. Do not make them.
|
|
83
|
+
|
|
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
|
+
|
|
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.
|
|
87
|
+
|
|
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
|
+
|
|
90
|
+
4. **Never retry infinitely.** Cap retries at 3 attempts. After 3 failures, stop the task with `capy stop <id>` and tell the user. Each retry costs LLM and VM credits.
|
|
91
|
+
|
|
92
|
+
5. **If Greptile says "Review still processing", wait and re-check.** Do NOT retry the task. The code is fine, the review just hasn't finished. Wait 60 seconds, then run `capy review <id> --json` again.
|
|
93
|
+
|
|
94
|
+
6. **Captain threads can spawn multiple tasks.** After `capy wait` on a thread, check ALL tasks in the response, not just `tasks[0]`. Review and approve each one.
|
|
95
|
+
|
|
96
|
+
7. **The Capy API reports merged PRs as "closed".** The CLI cross-references with GitHub to show the real state. Trust the CLI output.
|
|
97
|
+
|
|
98
|
+
## Workflow: Start new work
|
|
19
99
|
|
|
20
|
-
Or set env vars directly:
|
|
21
100
|
```bash
|
|
22
|
-
|
|
23
|
-
|
|
101
|
+
# 1. Start a Captain thread
|
|
102
|
+
RESULT=$(capy captain "Implement feature X. Files: src/foo.ts, src/bar.ts. Include tests. Run tests before finishing." --json)
|
|
103
|
+
THREAD_ID=$(echo "$RESULT" | jq -r '.id')
|
|
104
|
+
|
|
105
|
+
# 2. Wait for completion (use 600s for Captain, 300s for Build)
|
|
106
|
+
WAIT_RESULT=$(capy wait "$THREAD_ID" --timeout=600 --json)
|
|
107
|
+
|
|
108
|
+
# 3. Check if wait timed out
|
|
109
|
+
if echo "$WAIT_RESULT" | jq -e '.error' > /dev/null 2>&1; then
|
|
110
|
+
echo "Timed out. Last status: $(echo "$WAIT_RESULT" | jq -r '.error.lastStatus')"
|
|
111
|
+
# Decide: wait longer, or stop the thread
|
|
112
|
+
exit 1
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# 4. Get task identifiers from the thread
|
|
116
|
+
TASKS=$(echo "$WAIT_RESULT" | jq -r '.tasks[].identifier')
|
|
117
|
+
|
|
118
|
+
# 5. For each task: create PR if needed, review, approve/retry
|
|
119
|
+
for TASK in $TASKS; do
|
|
120
|
+
# Check if PR exists
|
|
121
|
+
HAS_PR=$(capy get "$TASK" --json | jq -r '.pullRequest.number // empty')
|
|
122
|
+
if [ -z "$HAS_PR" ]; then
|
|
123
|
+
capy pr "$TASK" --json
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Review quality
|
|
127
|
+
QUALITY=$(capy review "$TASK" --json)
|
|
128
|
+
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
129
|
+
|
|
130
|
+
# Retry loop (max 3)
|
|
131
|
+
ATTEMPTS=0
|
|
132
|
+
while [ "$PASS" != "true" ] && [ "$ATTEMPTS" -lt 3 ]; do
|
|
133
|
+
FAILING=$(echo "$QUALITY" | jq -r '.quality.gates[] | select(.pass == false) | .name + ": " + .detail')
|
|
134
|
+
RETRY=$(capy retry "$TASK" --fix="Fix these failures: $FAILING" --json)
|
|
135
|
+
NEW_THREAD=$(echo "$RETRY" | jq -r '.newThread')
|
|
136
|
+
|
|
137
|
+
capy wait "$NEW_THREAD" --timeout=600 --json
|
|
138
|
+
TASK=$(capy threads get "$NEW_THREAD" --json | jq -r '.tasks[0].identifier')
|
|
139
|
+
|
|
140
|
+
HAS_PR=$(capy get "$TASK" --json | jq -r '.pullRequest.number // empty')
|
|
141
|
+
[ -z "$HAS_PR" ] && capy pr "$TASK" --json
|
|
142
|
+
|
|
143
|
+
QUALITY=$(capy review "$TASK" --json)
|
|
144
|
+
PASS=$(echo "$QUALITY" | jq -r '.quality.pass')
|
|
145
|
+
ATTEMPTS=$((ATTEMPTS + 1))
|
|
146
|
+
done
|
|
147
|
+
|
|
148
|
+
if [ "$PASS" = "true" ]; then
|
|
149
|
+
capy approve "$TASK" --json
|
|
150
|
+
else
|
|
151
|
+
echo "Task $TASK failed after $ATTEMPTS retries"
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
24
154
|
```
|
|
25
155
|
|
|
26
|
-
##
|
|
156
|
+
## Workflow: Triage existing work
|
|
27
157
|
|
|
28
|
-
|
|
158
|
+
When the user asks "what's the status" or you need to check on existing tasks:
|
|
29
159
|
|
|
30
160
|
```bash
|
|
31
|
-
#
|
|
32
|
-
capy
|
|
161
|
+
# Fast overview (no diff fetching, 2x faster, good enough for status checks)
|
|
162
|
+
capy triage --brief --json
|
|
33
163
|
|
|
34
|
-
#
|
|
35
|
-
capy
|
|
164
|
+
# Full detail with diff stats (use when you need to decide actions)
|
|
165
|
+
capy triage --json
|
|
36
166
|
|
|
37
|
-
#
|
|
38
|
-
capy
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# 4a. If pass: approve
|
|
42
|
-
capy approve <task-id> --json
|
|
167
|
+
# Check specific tasks
|
|
168
|
+
capy triage SCO-15,SCO-24 --json
|
|
169
|
+
```
|
|
43
170
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
171
|
+
Triage returns:
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"summary": { "total": 26, "merged": 7, "ready": 2, "needs_pr": 11, "stuck": 3, "backlog": 3, "in_progress": 0 },
|
|
175
|
+
"tasks": [{ "identifier": "SCO-15", "category": "needs_pr", "title": "...", "pr": null, "diff": { "files": 5, "additions": 494 } }],
|
|
176
|
+
"recommendations": ["Create PRs: SCO-15, SCO-24", "Retry or stop: SCO-21, SCO-22"]
|
|
177
|
+
}
|
|
47
178
|
```
|
|
48
179
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
| `capy review <id>` | Run quality gates (pr_exists, ci, greptile, threads, tests) |
|
|
57
|
-
| `capy approve <id>` | Approve if all gates pass |
|
|
58
|
-
| `capy retry <id> --fix="..."` | Retry with failure context from previous attempt |
|
|
59
|
-
| `capy status` | Dashboard of all threads and tasks |
|
|
60
|
-
| `capy list [status]` | List tasks, optionally filtered |
|
|
61
|
-
| `capy get <id>` | Task or thread details |
|
|
62
|
-
| `capy diff <id>` | View diff from task |
|
|
63
|
-
| `capy pr <id>` | Create PR for task |
|
|
64
|
-
| `capy threads list` | List Captain threads |
|
|
65
|
-
| `capy threads get <id>` | Thread details |
|
|
66
|
-
| `capy threads msg <id> <text>` | Message a thread |
|
|
67
|
-
| `capy threads stop <id>` | Stop a thread |
|
|
68
|
-
| `capy threads messages <id>` | View thread messages |
|
|
69
|
-
|
|
70
|
-
All commands support `--json` for machine-readable output.
|
|
180
|
+
Map categories to actions:
|
|
181
|
+
- `in_progress` → wait
|
|
182
|
+
- `needs_pr` → `capy pr <id>` then review
|
|
183
|
+
- `ready` → `capy review <id>` then approve/retry
|
|
184
|
+
- `stuck` → `capy retry <id> --fix="..."` or `capy stop <id>`
|
|
185
|
+
- `backlog` → `capy start <id>` if the user wants it running
|
|
186
|
+
- `merged` → done, ignore
|
|
71
187
|
|
|
72
188
|
## Quality gates
|
|
73
189
|
|
|
74
|
-
`capy review`
|
|
190
|
+
`capy review <id> --json` returns `quality.pass` (boolean) and `quality.gates` (array of gate results).
|
|
75
191
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
-
|
|
192
|
+
| Gate | Checks | When it fails, do this |
|
|
193
|
+
|------|--------|----------------------|
|
|
194
|
+
| `pr_exists` | A PR was created | Run `capy pr <id> --json` first |
|
|
195
|
+
| `pr_open` | PR is open or merged | PR was closed. Check why. May need a new PR. |
|
|
196
|
+
| `ci` | CI checks are green | Retry: `capy retry <id> --fix="CI failing: <list failing checks>"` |
|
|
197
|
+
| `greptile` | No unaddressed code review issues | If "still processing": wait 60s, re-review. If issues listed: retry with issues in `--fix` |
|
|
198
|
+
| `threads` | No unresolved GitHub review threads | Retry with the unresolved comments in `--fix` |
|
|
199
|
+
| `tests` | Diff includes test files | Retry: `capy retry <id> --fix="Add tests for the changes"` |
|
|
82
200
|
|
|
83
|
-
|
|
201
|
+
## Commands reference
|
|
84
202
|
|
|
85
|
-
|
|
203
|
+
All commands support `--json` for structured output. All errors return `{ "error": { "code": "...", "message": "..." } }`.
|
|
86
204
|
|
|
87
|
-
|
|
205
|
+
### Start work
|
|
88
206
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
207
|
+
```bash
|
|
208
|
+
capy captain "<prompt>" --json # → { id, url }
|
|
209
|
+
capy build "<prompt>" --json # → { id, identifier, status }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Model shortcuts: `--opus`, `--sonnet`, `--mini`, `--fast`, `--kimi`, `--gemini`, `--grok`, `--qwen`, or `--model=<id>`.
|
|
213
|
+
|
|
214
|
+
### Wait and monitor
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
capy wait <id> --timeout=600 --json # → full object on success, { error: { code: "timeout", lastStatus } } on timeout
|
|
218
|
+
capy triage [ids] [--brief] --json # → { summary, tasks, recommendations }
|
|
219
|
+
capy get <id> --json # → full task or thread object
|
|
220
|
+
capy list [status] [--limit=N] --json # → { items, nextCursor, hasMore }
|
|
221
|
+
capy diff <id> --json # → { stats: { files, additions, deletions }, files: [...] }
|
|
222
|
+
capy status --json # → { threads, tasks, watches }
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Take action
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
capy pr <id> [--draft] [--description="..."] --json # → { url, number, title }
|
|
229
|
+
capy review <id> --json # → { task, quality: { pass, gates }, diff }
|
|
230
|
+
capy approve <id> [--force] --json # → { task, quality, approved }
|
|
231
|
+
capy retry <id> --fix="..." --json # → { originalTask, newThread, model }
|
|
232
|
+
capy start <id> --json # → task object
|
|
233
|
+
capy stop <id> --json # → task/thread object
|
|
234
|
+
capy msg <id> "<text>" --json # → { id, sent: true }
|
|
235
|
+
capy re-review <id> --json # → triggers fresh Greptile review
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Threads
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
capy threads list [--limit=N] --json # → { items, nextCursor, hasMore }
|
|
242
|
+
capy threads get <id> --json # → thread with tasks[] and pullRequests[]
|
|
243
|
+
capy threads msg <id> "<text>" --json # → message result
|
|
244
|
+
capy threads stop <id> --json # → stop result
|
|
245
|
+
capy threads messages <id> --json # → conversation history
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Admin
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
capy projects --json # → list of projects
|
|
252
|
+
capy projects get [id] --json # → project details (defaults to current)
|
|
253
|
+
capy models --json # → available models
|
|
254
|
+
capy config [key] [value] # → get/set config
|
|
255
|
+
capy pool [status|set|test|instances|instance|clear] # → warm pool management
|
|
256
|
+
```
|
|
93
257
|
|
|
94
258
|
## Prompting tips
|
|
95
259
|
|
|
96
|
-
|
|
97
|
-
|
|
260
|
+
Captain and Build need different prompting styles.
|
|
261
|
+
|
|
262
|
+
**Captain** has full codebase context and plans its own approach. Tell it WHAT to accomplish, not HOW. Link the issue, set the quality bar, get out of its way.
|
|
263
|
+
|
|
264
|
+
Bad: `"Fix CI for crypto-trading pack. The changeset file is missing. Add a changeset entry for @veto/crypto-trading with patch bump. Run 'npx changeset status' to validate. Files: packages/crypto-trading/."`
|
|
265
|
+
|
|
266
|
+
Good: `"We have a CI failure on the crypto-trading pack — missing changeset. Fix it. Reference: PLW-201. Don't finish until CI is green and tests pass."`
|
|
267
|
+
|
|
268
|
+
Good: `"https://linear.app/plaw/issue/PLW-201 — get this done. Make sure all tests pass, CI is green, and code review comes back clean."`
|
|
269
|
+
|
|
270
|
+
Captain prompts should include:
|
|
271
|
+
- What the problem or goal is (natural language)
|
|
272
|
+
- A link to the issue if one exists (GitHub, Linear)
|
|
273
|
+
- The quality bar ("tests pass", "CI green", "code review clean")
|
|
274
|
+
|
|
275
|
+
Do NOT include specific files, specific commands, or implementation details. Captain figures that out.
|
|
276
|
+
|
|
277
|
+
**Build** is isolated and has no codebase context. Be specific. Name the files, the commands, the acceptance criteria.
|
|
98
278
|
|
|
99
|
-
|
|
279
|
+
Good: `"Fix CI for crypto-trading pack. The changeset file is missing. Add a changeset entry for @veto/crypto-trading with patch bump. Run 'npx changeset status' to validate. Files: packages/crypto-trading/. Reference: PLW-201."`
|
|
100
280
|
|
|
101
281
|
## Triggers
|
|
102
282
|
|
|
103
|
-
Keywords: capy, captain, build agent, quality gates, delegate coding, orchestrate agents, send to capy, approve task, retry task
|
|
283
|
+
Keywords: capy, captain, build agent, quality gates, delegate coding, orchestrate agents, send to capy, approve task, retry task, triage tasks, check capy status, review PR, what's the status
|
package/src/api.ts
CHANGED
|
@@ -14,36 +14,58 @@ function fail(code: string, message: string): never {
|
|
|
14
14
|
throw new CapyError(code, message);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
18
|
+
const MAX_RETRIES = 3;
|
|
19
|
+
|
|
17
20
|
async function rawRequest(apiKey: string, server: string, method: string, path: string, body?: unknown): Promise<any> {
|
|
18
21
|
const url = `${server}${path}`;
|
|
19
22
|
const headers: Record<string, string> = {
|
|
20
23
|
"Authorization": `Bearer ${apiKey}`,
|
|
21
24
|
"Accept": "application/json",
|
|
22
25
|
};
|
|
23
|
-
const init: RequestInit = { method, headers };
|
|
24
26
|
if (body) {
|
|
25
27
|
headers["Content-Type"] = "application/json";
|
|
26
|
-
init.body = JSON.stringify(body);
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
let
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
let lastError: Error | null = null;
|
|
31
|
+
|
|
32
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
33
|
+
if (attempt > 0) {
|
|
34
|
+
const delay = Math.min(1000 * 2 ** (attempt - 1), 8000);
|
|
35
|
+
await new Promise(r => setTimeout(r, delay));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let res: Response;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetch(url, {
|
|
41
|
+
method,
|
|
42
|
+
headers,
|
|
43
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
44
|
+
});
|
|
45
|
+
} catch (e: unknown) {
|
|
46
|
+
lastError = e as Error;
|
|
47
|
+
if (attempt < MAX_RETRIES) continue;
|
|
48
|
+
fail("network_error", `request failed after ${MAX_RETRIES + 1} attempts — ${lastError.message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
let data: any;
|
|
57
|
+
try {
|
|
58
|
+
data = JSON.parse(text);
|
|
59
|
+
} catch {
|
|
60
|
+
fail("bad_response", `bad API response: ${text.slice(0, 200)}`);
|
|
61
|
+
}
|
|
62
|
+
if (data.error) {
|
|
63
|
+
fail(data.error.code || "api_error", data.error.message || "unknown API error");
|
|
64
|
+
}
|
|
65
|
+
return data;
|
|
34
66
|
}
|
|
35
67
|
|
|
36
|
-
|
|
37
|
-
let data: any;
|
|
38
|
-
try {
|
|
39
|
-
data = JSON.parse(text);
|
|
40
|
-
} catch {
|
|
41
|
-
fail("bad_response", `bad API response: ${text.slice(0, 200)}`);
|
|
42
|
-
}
|
|
43
|
-
if (data.error) {
|
|
44
|
-
fail(data.error.code || "api_error", data.error.message || "unknown API error");
|
|
45
|
-
}
|
|
46
|
-
return data;
|
|
68
|
+
fail("network_error", `request failed after ${MAX_RETRIES + 1} attempts — ${lastError?.message || "unknown"}`);
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
async function request(method: string, path: string, body?: unknown): Promise<any> {
|
|
@@ -73,6 +95,11 @@ export async function listModelsWithKey(apiKey: string, server = "https://capy.a
|
|
|
73
95
|
return data.models || [];
|
|
74
96
|
}
|
|
75
97
|
|
|
98
|
+
export async function listProjectsAuth(): Promise<Project[]> {
|
|
99
|
+
const data = await request("GET", "/projects");
|
|
100
|
+
return data.items || [];
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
export async function getProject(id?: string): Promise<Project & { createdAt?: string; updatedAt?: string }> {
|
|
77
104
|
const pid = id || config.load().projectId;
|
|
78
105
|
return request("GET", `/projects/${pid}`);
|
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 isThreadId(id: string): boolean {
|
|
21
|
+
return id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function resolveModel(args: Record<string, unknown>): string | null {
|
|
21
25
|
if (args.model) return String(args.model);
|
|
22
26
|
if (args.opus) return "claude-opus-4-6";
|
package/src/commands/diff-pr.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const pr = defineCommand({
|
|
|
30
30
|
args: {
|
|
31
31
|
id: { type: "positional", description: "Task ID", required: true },
|
|
32
32
|
title: { type: "positional", required: false, description: "PR title" },
|
|
33
|
+
description: { type: "string", description: "PR body/description" },
|
|
34
|
+
draft: { type: "boolean", description: "Create as draft PR", default: false },
|
|
33
35
|
...jsonArg,
|
|
34
36
|
},
|
|
35
37
|
async run({ args }) {
|
|
@@ -37,7 +39,10 @@ export const pr = defineCommand({
|
|
|
37
39
|
const fmt = await import("../output.js");
|
|
38
40
|
const { log } = await import("@clack/prompts");
|
|
39
41
|
|
|
40
|
-
const body
|
|
42
|
+
const body: Record<string, unknown> = {};
|
|
43
|
+
if (args.title) body.title = args.title;
|
|
44
|
+
if (args.description) body.description = args.description;
|
|
45
|
+
if (args.draft) body.draft = true;
|
|
41
46
|
const data = await api.createPR(args.id, body);
|
|
42
47
|
if (args.json) { fmt.out(data); return; }
|
|
43
48
|
log.success(`PR: ${data.url}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineCommand } from "citty";
|
|
2
|
-
import { jsonArg } from "./_shared.js";
|
|
2
|
+
import { jsonArg, isThreadId } from "./_shared.js";
|
|
3
3
|
|
|
4
4
|
export const watch = defineCommand({
|
|
5
5
|
meta: { name: "watch", description: "Poll + notify on completion" },
|
|
@@ -14,7 +14,7 @@ export const watch = defineCommand({
|
|
|
14
14
|
const fmt = await import("../output.js");
|
|
15
15
|
|
|
16
16
|
const interval = Math.max(1, Math.min(parseInt(args.interval) || config.load().watchInterval, 30));
|
|
17
|
-
const type = (args.id
|
|
17
|
+
const type = isThreadId(args.id) ? "thread" : "task";
|
|
18
18
|
const added = w.add(args.id, type, interval);
|
|
19
19
|
|
|
20
20
|
if (args.json) { fmt.out({ id: args.id, type, interval, added }); return; }
|
|
@@ -70,7 +70,7 @@ export const wait = defineCommand({
|
|
|
70
70
|
|
|
71
71
|
const timeoutMs = Math.max(10, parseInt(args.timeout) || 300) * 1000;
|
|
72
72
|
const intervalMs = Math.max(5, Math.min(parseInt(args.interval) || 10, 60)) * 1000;
|
|
73
|
-
const isThread =
|
|
73
|
+
const isThread = isThreadId(args.id);
|
|
74
74
|
const terminalTask = new Set(["needs_review", "archived", "completed", "failed"]);
|
|
75
75
|
const terminalThread = new Set(["idle", "archived", "completed"]);
|
|
76
76
|
const terminal = isThread ? terminalThread : terminalTask;
|
|
@@ -78,19 +78,25 @@ 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
|
+
let lastStatus = "unknown";
|
|
81
82
|
while (Date.now() - start < timeoutMs) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
try {
|
|
84
|
+
const data = isThread ? await api.getThread(args.id) : await api.getTask(args.id);
|
|
85
|
+
lastStatus = data.status;
|
|
86
|
+
if (terminal.has(data.status)) {
|
|
87
|
+
if (args.json) { fmt.out(data); return; }
|
|
88
|
+
console.log(`\n${isThread ? "Thread" : "Task"} ${data.status}.`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// transient error during poll, keep trying
|
|
87
93
|
}
|
|
88
94
|
if (!args.json) process.stderr.write(".");
|
|
89
95
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
90
96
|
}
|
|
91
97
|
|
|
92
|
-
if (args.json) { fmt.out({ error: { code: "timeout", message: `Timed out after ${args.timeout}s
|
|
93
|
-
console.error(`\ncapy: timed out after ${args.timeout}s`);
|
|
98
|
+
if (args.json) { fmt.out({ error: { code: "timeout", message: `Timed out after ${args.timeout}s`, lastStatus } }); process.exit(1); }
|
|
99
|
+
console.error(`\ncapy: timed out after ${args.timeout}s (last status: ${lastStatus})`);
|
|
94
100
|
process.exit(1);
|
|
95
101
|
},
|
|
96
102
|
});
|
package/src/commands/quality.ts
CHANGED
|
@@ -209,13 +209,12 @@ export const approve = defineCommand({
|
|
|
209
209
|
const approveCmd = cfg.approveCommand;
|
|
210
210
|
if (approveCmd) {
|
|
211
211
|
try {
|
|
212
|
-
const {
|
|
213
|
-
const
|
|
212
|
+
const { execSync } = await import("node:child_process");
|
|
213
|
+
const expanded = approveCmd
|
|
214
214
|
.replace("{task}", task.identifier || task.id)
|
|
215
|
-
.replace("{title}", task.title || "")
|
|
216
|
-
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
217
|
-
|
|
218
|
-
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
215
|
+
.replace("{title}", (task.title || "").replace(/'/g, "'\\''"))
|
|
216
|
+
.replace("{pr}", String(task.pullRequest?.number || ""));
|
|
217
|
+
execSync(expanded, { timeout: 15000, stdio: "pipe" });
|
|
219
218
|
log.info("Post-approve hook ran.");
|
|
220
219
|
} catch {}
|
|
221
220
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -133,6 +133,50 @@ export const config = defineCommand({
|
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
const projectsList = defineCommand({
|
|
137
|
+
meta: { name: "list", description: "List projects" },
|
|
138
|
+
args: { ...jsonArg },
|
|
139
|
+
async run({ args }) {
|
|
140
|
+
const api = await import("../api.js");
|
|
141
|
+
const fmt = await import("../output.js");
|
|
142
|
+
|
|
143
|
+
const projects = await api.listProjectsAuth();
|
|
144
|
+
if (args.json) { fmt.out(projects); return; }
|
|
145
|
+
if (!projects.length) { console.log("No projects."); return; }
|
|
146
|
+
fmt.table(["ID", "NAME", "CODE", "REPOS"], projects.map(p => [
|
|
147
|
+
p.id.slice(0, 16), p.name, p.taskCode, String(p.repos.length),
|
|
148
|
+
]));
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const projectsGet = defineCommand({
|
|
153
|
+
meta: { name: "get", description: "Project details" },
|
|
154
|
+
args: {
|
|
155
|
+
id: { type: "positional", required: false, description: "Project ID (defaults to current)" },
|
|
156
|
+
...jsonArg,
|
|
157
|
+
},
|
|
158
|
+
async run({ args }) {
|
|
159
|
+
const api = await import("../api.js");
|
|
160
|
+
const fmt = await import("../output.js");
|
|
161
|
+
const { log } = await import("@clack/prompts");
|
|
162
|
+
|
|
163
|
+
const data = await api.getProject(args.id);
|
|
164
|
+
if (args.json) { fmt.out(data); return; }
|
|
165
|
+
log.info(`Project: ${data.name}\nID: ${data.id}\nCode: ${data.taskCode}`);
|
|
166
|
+
if (data.repos.length) {
|
|
167
|
+
console.log(`Repos:`);
|
|
168
|
+
data.repos.forEach(r => console.log(` ${r.repoFullName} (${r.branch})`));
|
|
169
|
+
}
|
|
170
|
+
if (data.createdAt) console.log(`Created: ${data.createdAt}`);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
export const projects = defineCommand({
|
|
175
|
+
meta: { name: "projects", description: "List and manage projects" },
|
|
176
|
+
default: "list",
|
|
177
|
+
subCommands: { list: projectsList, get: projectsGet },
|
|
178
|
+
});
|
|
179
|
+
|
|
136
180
|
export const models = defineCommand({
|
|
137
181
|
meta: { name: "models", description: "List available models" },
|
|
138
182
|
args: { ...jsonArg },
|
|
@@ -179,6 +223,7 @@ export const tools = defineCommand({
|
|
|
179
223
|
watch: { args: "<id>", desc: "Poll + notify on completion" },
|
|
180
224
|
unwatch: { args: "<id>", desc: "Stop watching" },
|
|
181
225
|
watches: { args: "", desc: "List watches" },
|
|
226
|
+
projects: { args: "[list|get]", desc: "List/get projects" },
|
|
182
227
|
pool: { args: "[status|set|test|...]", desc: "Manage warm pool VMs" },
|
|
183
228
|
models: { args: "", desc: "List models" },
|
|
184
229
|
tools: { args: "", desc: "This list" },
|
package/src/commands/tasks.ts
CHANGED
|
@@ -5,14 +5,16 @@ export const list = defineCommand({
|
|
|
5
5
|
meta: { name: "list", description: "List tasks", alias: "ls" },
|
|
6
6
|
args: {
|
|
7
7
|
status: { type: "positional", required: false, description: "Filter by status" },
|
|
8
|
+
limit: { type: "string", description: "Max results (default 30)" },
|
|
9
|
+
cursor: { type: "string", description: "Pagination cursor from previous response" },
|
|
8
10
|
...jsonArg,
|
|
9
11
|
},
|
|
10
12
|
async run({ args }) {
|
|
11
13
|
const api = await import("../api.js");
|
|
12
14
|
const fmt = await import("../output.js");
|
|
13
15
|
|
|
14
|
-
const data = await api.listTasks({ status: args.status });
|
|
15
|
-
if (args.json) { fmt.out(data.items || []); return; }
|
|
16
|
+
const data = await api.listTasks({ status: args.status, limit: args.limit ? parseInt(args.limit) : undefined, cursor: args.cursor });
|
|
17
|
+
if (args.json) { fmt.out({ items: data.items || [], nextCursor: data.nextCursor, hasMore: data.hasMore }); return; }
|
|
16
18
|
if (!data.items?.length) { console.log("No tasks."); return; }
|
|
17
19
|
fmt.table(["ID", "STATUS", "TITLE", "PR"], data.items.map(t => [
|
|
18
20
|
t.identifier,
|
package/src/commands/threads.ts
CHANGED
|
@@ -3,13 +3,17 @@ import { jsonArg } from "./_shared.js";
|
|
|
3
3
|
|
|
4
4
|
const list = defineCommand({
|
|
5
5
|
meta: { name: "list", description: "List threads" },
|
|
6
|
-
args: {
|
|
6
|
+
args: {
|
|
7
|
+
limit: { type: "string", description: "Max results (default 10)" },
|
|
8
|
+
cursor: { type: "string", description: "Pagination cursor from previous response" },
|
|
9
|
+
...jsonArg,
|
|
10
|
+
},
|
|
7
11
|
async run({ args }) {
|
|
8
12
|
const api = await import("../api.js");
|
|
9
13
|
const fmt = await import("../output.js");
|
|
10
14
|
|
|
11
|
-
const data = await api.listThreads();
|
|
12
|
-
if (args.json) { fmt.out(data.items || []); return; }
|
|
15
|
+
const data = await api.listThreads({ limit: args.limit ? parseInt(args.limit) : undefined, cursor: args.cursor });
|
|
16
|
+
if (args.json) { fmt.out({ items: data.items || [], nextCursor: data.nextCursor, hasMore: data.hasMore }); return; }
|
|
13
17
|
if (!data.items?.length) { console.log("No threads."); return; }
|
|
14
18
|
fmt.table(["ID", "STATUS", "TITLE"], data.items.map(t => [
|
|
15
19
|
t.id.slice(0, 16), t.status, (t.title || "(untitled)").slice(0, 40),
|
package/src/commands/triage.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const triage = defineCommand({
|
|
|
24
24
|
meta: { name: "triage", description: "Actionable status for all tasks with diffs, PR state, and recommendations" },
|
|
25
25
|
args: {
|
|
26
26
|
ids: { type: "positional", required: false, description: "Specific task IDs (comma-separated or space-separated)" },
|
|
27
|
+
brief: { type: "boolean", description: "Fast mode: skip diff fetching, just categorize by status + PR state", default: false },
|
|
27
28
|
...jsonArg,
|
|
28
29
|
},
|
|
29
30
|
async run({ args }) {
|
|
@@ -52,13 +53,15 @@ export const triage = defineCommand({
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
// Fetch detail + diff in parallel for all tasks
|
|
56
|
+
const brief = !!args.brief;
|
|
57
|
+
let results: Awaited<ReturnType<typeof enrichTasks>>;
|
|
55
58
|
if (!args.json) {
|
|
56
59
|
const s = spinner();
|
|
57
|
-
s.start(`Loading ${tasks.length} tasks (details + diffs)...`);
|
|
58
|
-
|
|
60
|
+
s.start(`Loading ${tasks.length} tasks${brief ? "" : " (details + diffs)"}...`);
|
|
61
|
+
results = await enrichTasks(api, tasks, cfg, brief);
|
|
59
62
|
s.stop(`${tasks.length} tasks loaded`);
|
|
60
63
|
} else {
|
|
61
|
-
|
|
64
|
+
results = await enrichTasks(api, tasks, cfg, brief);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
// Cross-ref PR state with GitHub
|
|
@@ -85,10 +88,12 @@ export const triage = defineCommand({
|
|
|
85
88
|
category = "ready";
|
|
86
89
|
} else if (r.diff && r.diff.files > 0 && !r.pr) {
|
|
87
90
|
category = "needs_pr";
|
|
88
|
-
} else if (r.status === "needs_review" && (!r.diff || r.diff.files === 0)) {
|
|
89
|
-
category = "stuck";
|
|
91
|
+
} else if (r.status === "needs_review" && !r.pr && (!r.diff || r.diff.files === 0)) {
|
|
92
|
+
category = brief ? "needs_pr" : "stuck";
|
|
90
93
|
} else if (r.status === "needs_review" && r.pr) {
|
|
91
94
|
category = "ready";
|
|
95
|
+
} else if (r.status === "needs_review" && !r.pr) {
|
|
96
|
+
category = "needs_pr";
|
|
92
97
|
} else {
|
|
93
98
|
category = "stuck";
|
|
94
99
|
}
|
|
@@ -212,23 +217,23 @@ export const triage = defineCommand({
|
|
|
212
217
|
},
|
|
213
218
|
});
|
|
214
219
|
|
|
215
|
-
async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: any) {
|
|
216
|
-
// Fetch full details and diffs in parallel batches
|
|
220
|
+
async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: any, brief = false) {
|
|
217
221
|
const enriched = await Promise.all(tasks.map(async (task) => {
|
|
218
222
|
const id = task.identifier || task.id;
|
|
219
223
|
let detail: any = task;
|
|
220
224
|
let diff: any = null;
|
|
221
225
|
|
|
222
226
|
try {
|
|
223
|
-
// Only fetch detail if list response (no jams field)
|
|
224
227
|
if (!task.jams) {
|
|
225
228
|
detail = await api.getTask(id);
|
|
226
229
|
}
|
|
227
230
|
} catch {}
|
|
228
231
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
+
if (!brief) {
|
|
233
|
+
try {
|
|
234
|
+
diff = await api.getDiff(id);
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
232
237
|
|
|
233
238
|
const lastJam = (detail.jams || []).at(-1);
|
|
234
239
|
const credits = lastJam?.credits;
|
package/src/config.ts
CHANGED
|
@@ -13,10 +13,8 @@ const DEFAULTS: CapyConfig = {
|
|
|
13
13
|
repos: [],
|
|
14
14
|
defaultModel: "gpt-5.4",
|
|
15
15
|
quality: {
|
|
16
|
-
minReviewScore: 4,
|
|
17
16
|
requireCI: true,
|
|
18
17
|
requireTests: true,
|
|
19
|
-
requireLinearLink: true,
|
|
20
18
|
reviewProvider: "greptile",
|
|
21
19
|
},
|
|
22
20
|
watchInterval: 3,
|
|
@@ -56,7 +54,7 @@ export function load(): CapyConfig {
|
|
|
56
54
|
|
|
57
55
|
export function save(cfg: CapyConfig): void {
|
|
58
56
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
59
|
-
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
57
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
export function get(key: string): unknown {
|
package/src/github.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function getCIStatus(repo: string, number: number, prData?: PRData | null
|
|
|
42
42
|
const checks: StatusCheck[] = pr.statusCheckRollup || [];
|
|
43
43
|
const total = checks.length;
|
|
44
44
|
const passing = checks.filter(c =>
|
|
45
|
-
c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL"
|
|
45
|
+
c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL"
|
|
46
46
|
).length;
|
|
47
47
|
const failing = checks.filter(c =>
|
|
48
48
|
c.conclusion === "FAILURE" || c.conclusion === "ERROR" || c.conclusion === "TIMED_OUT"
|
package/src/mcp.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import * as api from "./api.js";
|
|
6
6
|
import { CapyError } from "./api.js";
|
|
7
7
|
import * as config from "./config.js";
|
|
8
|
+
import { isThreadId } from "./commands/_shared.js";
|
|
8
9
|
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const { version } = require("../package.json");
|
|
@@ -26,10 +27,6 @@ function structured(data: Record<string, unknown>) {
|
|
|
26
27
|
return { structuredContent: data, content: [{ type: "text" as const, text: JSON.stringify(data) }] };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function isThreadId(id: string): boolean {
|
|
30
|
-
return id.length > 20 || (id.length > 10 && !id.match(/^[A-Z]+-\d+$/));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
30
|
// --- Orchestration ---
|
|
34
31
|
|
|
35
32
|
server.registerTool("capy_captain", {
|
|
@@ -89,12 +86,17 @@ server.registerTool("capy_wait", {
|
|
|
89
86
|
: new Set(["needs_review", "archived", "completed", "failed"]);
|
|
90
87
|
|
|
91
88
|
const start = Date.now();
|
|
89
|
+
let lastData: any = null;
|
|
92
90
|
while (Date.now() - start < timeoutMs) {
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
try {
|
|
92
|
+
lastData = isThread ? await api.getThread(id) : await api.getTask(id);
|
|
93
|
+
if (terminal.has(lastData.status)) return text(lastData);
|
|
94
|
+
} catch {
|
|
95
|
+
// transient error during poll, keep trying
|
|
96
|
+
}
|
|
95
97
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
96
98
|
}
|
|
97
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ error: { code: "timeout", message: `Timed out after ${timeout || 300}s
|
|
99
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: { code: "timeout", message: `Timed out after ${timeout || 300}s`, lastStatus: lastData?.status || "unknown" } }) }], isError: true as const };
|
|
98
100
|
} catch (e) { return err(e); }
|
|
99
101
|
});
|
|
100
102
|
|
|
@@ -103,15 +105,6 @@ server.registerTool("capy_review", {
|
|
|
103
105
|
inputSchema: {
|
|
104
106
|
id: z.string().describe("Task ID"),
|
|
105
107
|
},
|
|
106
|
-
outputSchema: {
|
|
107
|
-
task: z.string(),
|
|
108
|
-
quality: z.object({
|
|
109
|
-
pass: z.boolean(),
|
|
110
|
-
passed: z.number(),
|
|
111
|
-
total: z.number(),
|
|
112
|
-
summary: z.string(),
|
|
113
|
-
}),
|
|
114
|
-
},
|
|
115
108
|
annotations: { readOnlyHint: true },
|
|
116
109
|
}, async ({ id }) => {
|
|
117
110
|
try {
|
|
@@ -134,6 +127,12 @@ server.registerTool("capy_approve", {
|
|
|
134
127
|
outputSchema: {
|
|
135
128
|
task: z.string(),
|
|
136
129
|
approved: z.boolean(),
|
|
130
|
+
quality: z.object({
|
|
131
|
+
pass: z.boolean(),
|
|
132
|
+
passed: z.number(),
|
|
133
|
+
total: z.number(),
|
|
134
|
+
summary: z.string(),
|
|
135
|
+
}),
|
|
137
136
|
},
|
|
138
137
|
annotations: { openWorldHint: true },
|
|
139
138
|
}, async ({ id, force }) => {
|
|
@@ -146,13 +145,12 @@ server.registerTool("capy_approve", {
|
|
|
146
145
|
|
|
147
146
|
if (approved && cfg.approveCommand) {
|
|
148
147
|
try {
|
|
149
|
-
const {
|
|
150
|
-
const
|
|
148
|
+
const { execSync } = await import("node:child_process");
|
|
149
|
+
const expanded = cfg.approveCommand
|
|
151
150
|
.replace("{task}", task.identifier || task.id)
|
|
152
|
-
.replace("{title}", task.title || "")
|
|
153
|
-
.replace("{pr}", String(task.pullRequest?.number || ""))
|
|
154
|
-
|
|
155
|
-
execFileSync(parts[0], parts.slice(1), { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
151
|
+
.replace("{title}", (task.title || "").replace(/'/g, "'\\''"))
|
|
152
|
+
.replace("{pr}", String(task.pullRequest?.number || ""));
|
|
153
|
+
execSync(expanded, { timeout: 15000, stdio: "pipe" });
|
|
156
154
|
} catch {}
|
|
157
155
|
}
|
|
158
156
|
|
|
@@ -201,12 +199,13 @@ server.registerTool("capy_retry", {
|
|
|
201
199
|
});
|
|
202
200
|
|
|
203
201
|
server.registerTool("capy_triage", {
|
|
204
|
-
description: "Actionable triage of all tasks.
|
|
202
|
+
description: "Actionable triage of all tasks. Categorizes into: merged, ready, needs_pr, stuck, backlog, in_progress. Use brief=true for fast mode (skips diff fetching, ~2x faster).",
|
|
205
203
|
inputSchema: {
|
|
206
204
|
ids: z.array(z.string()).optional().describe("Specific task IDs to triage. Omit for all tasks."),
|
|
205
|
+
brief: z.boolean().optional().describe("Skip diff fetching for speed. Categories based on status + PR state only."),
|
|
207
206
|
},
|
|
208
207
|
annotations: { readOnlyHint: true },
|
|
209
|
-
}, async ({ ids }) => {
|
|
208
|
+
}, async ({ ids, brief }) => {
|
|
210
209
|
try {
|
|
211
210
|
const github = await import("./github.js");
|
|
212
211
|
const cfg = config.load();
|
|
@@ -224,7 +223,7 @@ server.registerTool("capy_triage", {
|
|
|
224
223
|
let detail: any = task;
|
|
225
224
|
let diff: any = null;
|
|
226
225
|
try { if (!task.jams) detail = await api.getTask(id); } catch {}
|
|
227
|
-
try { diff = await api.getDiff(id); } catch {}
|
|
226
|
+
if (!brief) { try { diff = await api.getDiff(id); } catch {} }
|
|
228
227
|
|
|
229
228
|
if (detail.pullRequest?.number && detail.pullRequest.state === "closed") {
|
|
230
229
|
const repo = detail.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
|
|
@@ -245,7 +244,9 @@ server.registerTool("capy_triage", {
|
|
|
245
244
|
else if (pr?.state === "merged") category = "merged";
|
|
246
245
|
else if (pr && pr.state === "open") category = "ready";
|
|
247
246
|
else if (diffStats && diffStats.files > 0 && !pr) category = "needs_pr";
|
|
248
|
-
else if (detail.status === "needs_review" && (!diffStats || diffStats.files === 0)) category = "stuck";
|
|
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";
|
|
249
250
|
else category = "stuck";
|
|
250
251
|
|
|
251
252
|
return {
|
|
@@ -464,6 +465,35 @@ server.registerTool("capy_re_review", {
|
|
|
464
465
|
} catch (e) { return err(e); }
|
|
465
466
|
});
|
|
466
467
|
|
|
468
|
+
server.registerTool("capy_projects", {
|
|
469
|
+
description: "List all projects accessible with the current API key",
|
|
470
|
+
inputSchema: {},
|
|
471
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
472
|
+
}, async () => {
|
|
473
|
+
try {
|
|
474
|
+
const data = await api.listProjectsAuth();
|
|
475
|
+
return text(data);
|
|
476
|
+
} catch (e) { return err(e); }
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
server.registerTool("capy_project", {
|
|
480
|
+
description: "Get project details (repos, task code, config)",
|
|
481
|
+
inputSchema: {
|
|
482
|
+
id: z.string().optional().describe("Project ID (defaults to current project)"),
|
|
483
|
+
},
|
|
484
|
+
outputSchema: {
|
|
485
|
+
id: z.string(),
|
|
486
|
+
name: z.string(),
|
|
487
|
+
taskCode: z.string(),
|
|
488
|
+
},
|
|
489
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
490
|
+
}, async ({ id }) => {
|
|
491
|
+
try {
|
|
492
|
+
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 });
|
|
494
|
+
} catch (e) { return err(e); }
|
|
495
|
+
});
|
|
496
|
+
|
|
467
497
|
server.registerTool("capy_models", {
|
|
468
498
|
description: "List available AI models",
|
|
469
499
|
inputSchema: {},
|
|
@@ -505,6 +535,17 @@ server.registerTool("capy_pool_update", {
|
|
|
505
535
|
} catch (e) { return err(e); }
|
|
506
536
|
});
|
|
507
537
|
|
|
538
|
+
server.registerTool("capy_pool_test", {
|
|
539
|
+
description: "Test warm pool VM boot with setup commands",
|
|
540
|
+
inputSchema: {},
|
|
541
|
+
annotations: { openWorldHint: true },
|
|
542
|
+
}, async () => {
|
|
543
|
+
try {
|
|
544
|
+
const data = await api.testWarmPool();
|
|
545
|
+
return text(data);
|
|
546
|
+
} catch (e) { return err(e); }
|
|
547
|
+
});
|
|
548
|
+
|
|
508
549
|
server.registerTool("capy_pool_instances", {
|
|
509
550
|
description: "List warm pool VM instances",
|
|
510
551
|
inputSchema: {
|
package/src/types.ts
CHANGED
package/src/watch.ts
CHANGED
|
@@ -51,11 +51,11 @@ export function list(): WatchEntry[] {
|
|
|
51
51
|
|
|
52
52
|
export function notify(text: string): boolean {
|
|
53
53
|
const cfg = config.load();
|
|
54
|
-
const cmd = cfg.notifyCommand
|
|
54
|
+
const cmd = cfg.notifyCommand;
|
|
55
|
+
if (!cmd) return false;
|
|
55
56
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
encoding: "utf8", timeout: 15000, stdio: "pipe",
|
|
57
|
+
execSync(cmd.replace("{text}", text.replace(/'/g, "'\\''")), {
|
|
58
|
+
timeout: 15000, stdio: "pipe",
|
|
59
59
|
});
|
|
60
60
|
return true;
|
|
61
61
|
} catch { return false; }
|