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 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
- 21 MCP tools with full API parity:
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
- ## How to use capyai
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
- capy wait <threadId> --timeout=600 --json
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
- `{text}` is replaced with a summary when the task completes. Examples:
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
- 21 tools with full API parity, including warm pool management.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capyai",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Unofficial Capy.ai CLI for agent orchestration with quality gates",
6
6
  "bin": {
@@ -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.4.1"
6
+ version: "0.5.0"
7
7
  ---
8
8
 
9
9
  # capy
10
10
 
11
- Orchestrate Capy.ai coding agents. Start tasks, wait for them, enforce quality gates, approve or retry. Works with any AI agent (Claude Code, Codex, OpenClaw, Poke).
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
- ## Install
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
- export CAPY_API_KEY=capy_...
23
- export CAPY_PROJECT_ID=...
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
- ## Agent workflow
156
+ ## Workflow: Triage existing work
27
157
 
28
- The core loop for any agent:
158
+ When the user asks "what's the status" or you need to check on existing tasks:
29
159
 
30
160
  ```bash
31
- # 1. Start work
32
- capy captain "Implement feature X. Files: src/foo.ts. Tests required." --json
161
+ # Fast overview (no diff fetching, 2x faster, good enough for status checks)
162
+ capy triage --brief --json
33
163
 
34
- # 2. Wait for completion (blocks until done)
35
- capy wait <thread-id> --timeout=600 --json
164
+ # Full detail with diff stats (use when you need to decide actions)
165
+ capy triage --json
36
166
 
37
- # 3. Check quality gates
38
- capy review <task-id> --json
39
- # Parse quality.pass boolean
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
- # 4b. If fail: retry with context
45
- capy retry <task-id> --fix="fix the failing CI check" --json
46
- # Go back to step 2
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
- ## Commands
50
-
51
- | Command | What it does |
52
- |---------|-------------|
53
- | `capy captain "<prompt>"` | Start Captain thread (primary agent) |
54
- | `capy build "<prompt>"` | Start Build agent (isolated, small tasks) |
55
- | `capy wait <id>` | Block until task/thread reaches terminal state |
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` checks pass/fail gates:
190
+ `capy review <id> --json` returns `quality.pass` (boolean) and `quality.gates` (array of gate results).
75
191
 
76
- - **pr_exists** PR was created
77
- - **pr_open** — PR is OPEN or MERGED
78
- - **ci** CI checks passing
79
- - **greptile** No unaddressed Greptile issues
80
- - **threads** No unresolved GitHub review threads
81
- - **tests** Diff includes test files
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
- Configure which run via `capy config quality.reviewProvider greptile|capy|both|none`.
201
+ ## Commands reference
84
202
 
85
- ## JSON output
203
+ All commands support `--json` for structured output. All errors return `{ "error": { "code": "...", "message": "..." } }`.
86
204
 
87
- Every command returns structured JSON with `--json`. Errors return `{ "error": { "code": "...", "message": "..." } }`.
205
+ ### Start work
88
206
 
89
- Key fields for agents:
90
- - `capy review --json``quality.pass` (boolean), `quality.gates` (array)
91
- - `capy wait --json`full task/thread object on completion, `error.code: "timeout"` on timeout
92
- - `capy retry --json` → `newThread` (thread ID to wait on)
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
- Bad: "Fix the CI issue"
97
- 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."
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
- Specific files, specific functions, specific acceptance criteria.
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 res: Response;
30
- try {
31
- res = await fetch(url, init);
32
- } catch (e: unknown) {
33
- fail("network_error", `request failed ${(e as Error).message}`);
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
- const text = await res.text();
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}`);
@@ -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";
@@ -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 = args.title ? { title: args.title } : {};
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.length > 20 || (args.id.length > 10 && !args.id.match(/^[A-Z]+-\d+$/))) ? "thread" : "task";
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 = args.id.length > 20 || (args.id.length > 10 && !args.id.match(/^[A-Z]+-\d+$/));
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
- const data = isThread ? await api.getThread(args.id) : await api.getTask(args.id);
83
- if (terminal.has(data.status)) {
84
- if (args.json) { fmt.out(data); return; }
85
- console.log(`\n${isThread ? "Thread" : "Task"} ${data.status}.`);
86
- return;
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` } }); process.exit(1); }
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
  });
@@ -209,13 +209,12 @@ export const approve = defineCommand({
209
209
  const approveCmd = cfg.approveCommand;
210
210
  if (approveCmd) {
211
211
  try {
212
- const { execFileSync } = await import("node:child_process");
213
- const parts = approveCmd
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
- .split(/\s+/);
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
  }
@@ -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" },
@@ -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,
@@ -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: { ...jsonArg },
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),
@@ -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
- var results = await enrichTasks(api, tasks, cfg);
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
- var results = await enrichTasks(api, tasks, cfg);
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
- try {
230
- diff = await api.getDiff(id);
231
- } catch {}
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" || c.status === "COMPLETED"
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
- const data = isThread ? await api.getThread(id) : await api.getTask(id);
94
- if (terminal.has(data.status)) return text(data);
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` } }) }], isError: true as const };
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 { execFileSync } = await import("node:child_process");
150
- const parts = cfg.approveCommand
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
- .split(/\s+/);
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. Fetches details + diffs in parallel. Categorizes into: merged, ready, needs_pr, stuck, backlog, in_progress. Includes diff stats, PR state, credit usage, and recommendations.",
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
@@ -1,8 +1,6 @@
1
1
  export interface QualityConfig {
2
- minReviewScore: number;
3
2
  requireCI: boolean;
4
3
  requireTests: boolean;
5
- requireLinearLink: boolean;
6
4
  reviewProvider: string;
7
5
  }
8
6
 
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 || "openclaw system event --text {text} --mode now";
54
+ const cmd = cfg.notifyCommand;
55
+ if (!cmd) return false;
55
56
  try {
56
- const parts = cmd.replace("{text}", text).split(/\s+/);
57
- execFileSync(parts[0], parts.slice(1), {
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; }