capyai 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 },
@@ -162,6 +206,7 @@ export const tools = defineCommand({
162
206
  captain: { args: "<prompt>", desc: "Start Captain thread" },
163
207
  build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
164
208
  threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
209
+ triage: { args: "[id,...]", desc: "Actionable triage with diffs + recs" },
165
210
  status: { args: "", desc: "Dashboard" },
166
211
  list: { args: "[status]", desc: "List tasks" },
167
212
  get: { args: "<id>", desc: "Task details" },
@@ -178,6 +223,7 @@ export const tools = defineCommand({
178
223
  watch: { args: "<id>", desc: "Poll + notify on completion" },
179
224
  unwatch: { args: "<id>", desc: "Stop watching" },
180
225
  watches: { args: "", desc: "List watches" },
226
+ projects: { args: "[list|get]", desc: "List/get projects" },
181
227
  pool: { args: "[status|set|test|...]", desc: "Manage warm pool VMs" },
182
228
  models: { args: "", desc: "List models" },
183
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),
@@ -0,0 +1,271 @@
1
+ import { defineCommand } from "citty";
2
+ import { jsonArg } from "./_shared.js";
3
+
4
+ interface TriageTask {
5
+ identifier: string;
6
+ title: string;
7
+ status: string;
8
+ labels: string[];
9
+ category: "merged" | "ready" | "needs_pr" | "stuck" | "backlog" | "in_progress";
10
+ pr?: { number: number; state: string; url?: string };
11
+ diff?: { files: number; additions: number; deletions: number };
12
+ jam?: { model: string; status: string; credits: { llm: number; vm: number } };
13
+ createdAt?: string;
14
+ updatedAt?: string;
15
+ }
16
+
17
+ interface TriageResult {
18
+ summary: { total: number; merged: number; ready: number; needs_pr: number; stuck: number; backlog: number; in_progress: number };
19
+ tasks: TriageTask[];
20
+ recommendations: string[];
21
+ }
22
+
23
+ export const triage = defineCommand({
24
+ meta: { name: "triage", description: "Actionable status for all tasks with diffs, PR state, and recommendations" },
25
+ args: {
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 },
28
+ ...jsonArg,
29
+ },
30
+ async run({ args }) {
31
+ const api = await import("../api.js");
32
+ const config = await import("../config.js");
33
+ const github = await import("../github.js");
34
+ const fmt = await import("../output.js");
35
+ const { log, spinner } = await import("@clack/prompts");
36
+
37
+ const cfg = config.load();
38
+
39
+ // Get base task list
40
+ let tasks: any[];
41
+ if (args.ids) {
42
+ const ids = args.ids.split(/[,\s]+/).filter(Boolean);
43
+ tasks = await Promise.all(ids.map(id => api.getTask(id)));
44
+ } else {
45
+ const data = await api.listTasks({ limit: 100 });
46
+ tasks = data.items || [];
47
+ }
48
+
49
+ if (!tasks.length) {
50
+ if (args.json) { fmt.out({ summary: { total: 0 }, tasks: [], recommendations: [] }); return; }
51
+ console.log("No tasks.");
52
+ return;
53
+ }
54
+
55
+ // Fetch detail + diff in parallel for all tasks
56
+ const brief = !!args.brief;
57
+ let results: Awaited<ReturnType<typeof enrichTasks>>;
58
+ if (!args.json) {
59
+ const s = spinner();
60
+ s.start(`Loading ${tasks.length} tasks${brief ? "" : " (details + diffs)"}...`);
61
+ results = await enrichTasks(api, tasks, cfg, brief);
62
+ s.stop(`${tasks.length} tasks loaded`);
63
+ } else {
64
+ results = await enrichTasks(api, tasks, cfg, brief);
65
+ }
66
+
67
+ // Cross-ref PR state with GitHub
68
+ for (const r of results) {
69
+ if (r.pr && r.pr.state === "closed") {
70
+ const repo = r._raw?.pullRequest?.repoFullName || cfg.repos[0]?.repoFullName;
71
+ if (repo) {
72
+ const ghPR = github.getPR(repo, r.pr.number);
73
+ if (ghPR) r.pr.state = ghPR.state.toLowerCase();
74
+ }
75
+ }
76
+ }
77
+
78
+ // Categorize
79
+ const triaged: TriageTask[] = results.map(r => {
80
+ let category: TriageTask["category"];
81
+ if (r.status === "backlog") {
82
+ category = "backlog";
83
+ } else if (r.status === "in_progress") {
84
+ category = "in_progress";
85
+ } else if (r.pr?.state === "merged") {
86
+ category = "merged";
87
+ } else if (r.pr && r.pr.state === "open") {
88
+ category = "ready";
89
+ } else if (r.diff && r.diff.files > 0 && !r.pr) {
90
+ category = "needs_pr";
91
+ } else if (r.status === "needs_review" && !r.pr && (!r.diff || r.diff.files === 0)) {
92
+ category = brief ? "needs_pr" : "stuck";
93
+ } else if (r.status === "needs_review" && r.pr) {
94
+ category = "ready";
95
+ } else if (r.status === "needs_review" && !r.pr) {
96
+ category = "needs_pr";
97
+ } else {
98
+ category = "stuck";
99
+ }
100
+
101
+ return {
102
+ identifier: r.identifier,
103
+ title: r.title,
104
+ status: r.status,
105
+ labels: r.labels || [],
106
+ category,
107
+ ...(r.pr ? { pr: { number: r.pr.number, state: r.pr.state, url: r.pr.url } } : {}),
108
+ ...(r.diff ? { diff: r.diff } : {}),
109
+ ...(r.jam ? { jam: r.jam } : {}),
110
+ createdAt: r.createdAt,
111
+ updatedAt: r.updatedAt,
112
+ };
113
+ });
114
+
115
+ // Sort: in_progress first, then needs_pr, ready, stuck, backlog, merged
116
+ const order: Record<string, number> = { in_progress: 0, needs_pr: 1, ready: 2, stuck: 3, backlog: 4, merged: 5 };
117
+ triaged.sort((a, b) => (order[a.category] ?? 9) - (order[b.category] ?? 9));
118
+
119
+ // Build summary
120
+ const summary = {
121
+ total: triaged.length,
122
+ merged: triaged.filter(t => t.category === "merged").length,
123
+ ready: triaged.filter(t => t.category === "ready").length,
124
+ needs_pr: triaged.filter(t => t.category === "needs_pr").length,
125
+ stuck: triaged.filter(t => t.category === "stuck").length,
126
+ backlog: triaged.filter(t => t.category === "backlog").length,
127
+ in_progress: triaged.filter(t => t.category === "in_progress").length,
128
+ };
129
+
130
+ // Build recommendations
131
+ const recs: string[] = [];
132
+ const needsPr = triaged.filter(t => t.category === "needs_pr");
133
+ const stuck = triaged.filter(t => t.category === "stuck");
134
+ const ready = triaged.filter(t => t.category === "ready");
135
+ const inProgress = triaged.filter(t => t.category === "in_progress");
136
+
137
+ if (needsPr.length) {
138
+ recs.push(`Create PRs: ${needsPr.map(t => t.identifier).join(", ")} (have diffs, no PR)`);
139
+ }
140
+ if (ready.length) {
141
+ recs.push(`Review + approve: ${ready.map(t => t.identifier).join(", ")}`);
142
+ }
143
+ if (stuck.length) {
144
+ // Detect duplicates by similar titles
145
+ const stuckTitles = stuck.map(t => t.title.replace(/^(Implement |PLW-\d+ (BLOCKER|MEDIUM|LOW): )/i, "").slice(0, 40));
146
+ const dupes = stuck.filter((t, i) => {
147
+ const norm = stuckTitles[i];
148
+ return triaged.some(other =>
149
+ other.identifier !== t.identifier &&
150
+ (other.category === "needs_pr" || other.category === "ready" || other.category === "merged") &&
151
+ other.title.replace(/^(Implement |PLW-\d+ (BLOCKER|MEDIUM|LOW): )/i, "").slice(0, 40) === norm
152
+ );
153
+ });
154
+ if (dupes.length) {
155
+ recs.push(`Stop duplicates: ${dupes.map(t => t.identifier).join(", ")} (no output, duplicates of working tasks)`);
156
+ }
157
+ const realStuck = stuck.filter(t => !dupes.includes(t));
158
+ if (realStuck.length) {
159
+ recs.push(`Retry or stop: ${realStuck.map(t => t.identifier).join(", ")} (no diff produced)`);
160
+ }
161
+ }
162
+
163
+ const result: TriageResult = { summary, tasks: triaged, recommendations: recs };
164
+
165
+ if (args.json) { fmt.out(result); return; }
166
+
167
+ // Human output
168
+ const groups: Record<string, TriageTask[]> = {};
169
+ triaged.forEach(t => { (groups[t.category] = groups[t.category] || []).push(t); });
170
+
171
+ const sectionNames: Record<string, string> = {
172
+ in_progress: "IN PROGRESS",
173
+ needs_pr: "HAS CODE, NEEDS PR",
174
+ ready: "READY TO REVIEW",
175
+ stuck: "STUCK (no output)",
176
+ backlog: "BACKLOG",
177
+ merged: "MERGED",
178
+ };
179
+
180
+ for (const cat of ["in_progress", "needs_pr", "ready", "stuck", "backlog", "merged"]) {
181
+ const items = groups[cat];
182
+ if (!items?.length) continue;
183
+
184
+ fmt.section(`${sectionNames[cat]} (${items.length})`);
185
+ items.forEach(t => {
186
+ let line = ` ${fmt.pad(t.identifier, 8)}`;
187
+
188
+ if (t.pr) {
189
+ line += ` PR#${fmt.pad(String(t.pr.number), 4)} [${fmt.pad(t.pr.state, 6)}]`;
190
+ } else {
191
+ line += ` `;
192
+ }
193
+
194
+ if (t.diff && t.diff.files > 0) {
195
+ line += ` +${fmt.pad(String(t.diff.additions), 5)} -${fmt.pad(String(t.diff.deletions), 5)} ${fmt.pad(t.diff.files + " files", 8)}`;
196
+ } else {
197
+ line += ` `;
198
+ }
199
+
200
+ if (t.jam) {
201
+ line += ` ${fmt.pad(t.jam.model, 12)}`;
202
+ }
203
+
204
+ line += ` ${(t.title || "").slice(0, 45)}`;
205
+ console.log(line);
206
+ });
207
+ }
208
+
209
+ console.log();
210
+ log.info(`Summary: ${summary.total} tasks — ${summary.in_progress} active, ${summary.needs_pr} need PR, ${summary.ready} to review, ${summary.stuck} stuck, ${summary.merged} merged`);
211
+
212
+ if (recs.length) {
213
+ console.log();
214
+ log.step("Recommendations");
215
+ recs.forEach((r, i) => console.log(` ${i + 1}. ${r}`));
216
+ }
217
+ },
218
+ });
219
+
220
+ async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: any, brief = false) {
221
+ const enriched = await Promise.all(tasks.map(async (task) => {
222
+ const id = task.identifier || task.id;
223
+ let detail: any = task;
224
+ let diff: any = null;
225
+
226
+ try {
227
+ if (!task.jams) {
228
+ detail = await api.getTask(id);
229
+ }
230
+ } catch {}
231
+
232
+ if (!brief) {
233
+ try {
234
+ diff = await api.getDiff(id);
235
+ } catch {}
236
+ }
237
+
238
+ const lastJam = (detail.jams || []).at(-1);
239
+ const credits = lastJam?.credits;
240
+
241
+ return {
242
+ identifier: detail.identifier || id,
243
+ title: detail.title || task.title || "",
244
+ status: detail.status || task.status,
245
+ labels: detail.labels || task.labels || [],
246
+ createdAt: detail.createdAt || task.createdAt,
247
+ updatedAt: detail.updatedAt || task.updatedAt,
248
+ pr: detail.pullRequest?.number ? {
249
+ number: detail.pullRequest.number,
250
+ state: detail.pullRequest.state || "?",
251
+ url: detail.pullRequest.url,
252
+ } : null,
253
+ diff: diff?.stats ? {
254
+ files: diff.stats.files || 0,
255
+ additions: diff.stats.additions || 0,
256
+ deletions: diff.stats.deletions || 0,
257
+ } : null,
258
+ jam: lastJam ? {
259
+ model: lastJam.model || "?",
260
+ status: lastJam.status || "?",
261
+ credits: {
262
+ llm: typeof credits === "object" ? (credits?.llm ?? 0) : (credits || 0),
263
+ vm: typeof credits === "object" ? (credits?.vm ?? 0) : 0,
264
+ },
265
+ } : null,
266
+ _raw: detail,
267
+ };
268
+ }));
269
+
270
+ return enriched;
271
+ }
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"