capyai 0.4.0 → 0.4.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/bin/capy.ts +1 -0
- package/package.json +1 -1
- package/skills/capy/SKILL.md +1 -1
- package/src/commands/setup.ts +1 -0
- package/src/commands/triage.ts +266 -0
- package/src/mcp.ts +82 -0
package/bin/capy.ts
CHANGED
|
@@ -27,6 +27,7 @@ const main = defineCommand({
|
|
|
27
27
|
models: () => import("../src/commands/setup.js").then(m => m.models),
|
|
28
28
|
tools: () => import("../src/commands/setup.js").then(m => m.tools),
|
|
29
29
|
status: () => import("../src/commands/setup.js").then(m => m.status),
|
|
30
|
+
triage: () => import("../src/commands/triage.js").then(m => m.triage),
|
|
30
31
|
review: () => import("../src/commands/quality.js").then(m => m.review),
|
|
31
32
|
"re-review": () => import("../src/commands/quality.js").then(m => m.reReview),
|
|
32
33
|
approve: () => import("../src/commands/quality.js").then(m => m.approve),
|
package/package.json
CHANGED
package/skills/capy/SKILL.md
CHANGED
package/src/commands/setup.ts
CHANGED
|
@@ -162,6 +162,7 @@ export const tools = defineCommand({
|
|
|
162
162
|
captain: { args: "<prompt>", desc: "Start Captain thread" },
|
|
163
163
|
build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
|
|
164
164
|
threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
|
|
165
|
+
triage: { args: "[id,...]", desc: "Actionable triage with diffs + recs" },
|
|
165
166
|
status: { args: "", desc: "Dashboard" },
|
|
166
167
|
list: { args: "[status]", desc: "List tasks" },
|
|
167
168
|
get: { args: "<id>", desc: "Task details" },
|
|
@@ -0,0 +1,266 @@
|
|
|
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
|
+
...jsonArg,
|
|
28
|
+
},
|
|
29
|
+
async run({ args }) {
|
|
30
|
+
const api = await import("../api.js");
|
|
31
|
+
const config = await import("../config.js");
|
|
32
|
+
const github = await import("../github.js");
|
|
33
|
+
const fmt = await import("../output.js");
|
|
34
|
+
const { log, spinner } = await import("@clack/prompts");
|
|
35
|
+
|
|
36
|
+
const cfg = config.load();
|
|
37
|
+
|
|
38
|
+
// Get base task list
|
|
39
|
+
let tasks: any[];
|
|
40
|
+
if (args.ids) {
|
|
41
|
+
const ids = args.ids.split(/[,\s]+/).filter(Boolean);
|
|
42
|
+
tasks = await Promise.all(ids.map(id => api.getTask(id)));
|
|
43
|
+
} else {
|
|
44
|
+
const data = await api.listTasks({ limit: 100 });
|
|
45
|
+
tasks = data.items || [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!tasks.length) {
|
|
49
|
+
if (args.json) { fmt.out({ summary: { total: 0 }, tasks: [], recommendations: [] }); return; }
|
|
50
|
+
console.log("No tasks.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fetch detail + diff in parallel for all tasks
|
|
55
|
+
if (!args.json) {
|
|
56
|
+
const s = spinner();
|
|
57
|
+
s.start(`Loading ${tasks.length} tasks (details + diffs)...`);
|
|
58
|
+
var results = await enrichTasks(api, tasks, cfg);
|
|
59
|
+
s.stop(`${tasks.length} tasks loaded`);
|
|
60
|
+
} else {
|
|
61
|
+
var results = await enrichTasks(api, tasks, cfg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Cross-ref PR state with GitHub
|
|
65
|
+
for (const r of results) {
|
|
66
|
+
if (r.pr && r.pr.state === "closed") {
|
|
67
|
+
const repo = r._raw?.pullRequest?.repoFullName || cfg.repos[0]?.repoFullName;
|
|
68
|
+
if (repo) {
|
|
69
|
+
const ghPR = github.getPR(repo, r.pr.number);
|
|
70
|
+
if (ghPR) r.pr.state = ghPR.state.toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Categorize
|
|
76
|
+
const triaged: TriageTask[] = results.map(r => {
|
|
77
|
+
let category: TriageTask["category"];
|
|
78
|
+
if (r.status === "backlog") {
|
|
79
|
+
category = "backlog";
|
|
80
|
+
} else if (r.status === "in_progress") {
|
|
81
|
+
category = "in_progress";
|
|
82
|
+
} else if (r.pr?.state === "merged") {
|
|
83
|
+
category = "merged";
|
|
84
|
+
} else if (r.pr && r.pr.state === "open") {
|
|
85
|
+
category = "ready";
|
|
86
|
+
} else if (r.diff && r.diff.files > 0 && !r.pr) {
|
|
87
|
+
category = "needs_pr";
|
|
88
|
+
} else if (r.status === "needs_review" && (!r.diff || r.diff.files === 0)) {
|
|
89
|
+
category = "stuck";
|
|
90
|
+
} else if (r.status === "needs_review" && r.pr) {
|
|
91
|
+
category = "ready";
|
|
92
|
+
} else {
|
|
93
|
+
category = "stuck";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
identifier: r.identifier,
|
|
98
|
+
title: r.title,
|
|
99
|
+
status: r.status,
|
|
100
|
+
labels: r.labels || [],
|
|
101
|
+
category,
|
|
102
|
+
...(r.pr ? { pr: { number: r.pr.number, state: r.pr.state, url: r.pr.url } } : {}),
|
|
103
|
+
...(r.diff ? { diff: r.diff } : {}),
|
|
104
|
+
...(r.jam ? { jam: r.jam } : {}),
|
|
105
|
+
createdAt: r.createdAt,
|
|
106
|
+
updatedAt: r.updatedAt,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Sort: in_progress first, then needs_pr, ready, stuck, backlog, merged
|
|
111
|
+
const order: Record<string, number> = { in_progress: 0, needs_pr: 1, ready: 2, stuck: 3, backlog: 4, merged: 5 };
|
|
112
|
+
triaged.sort((a, b) => (order[a.category] ?? 9) - (order[b.category] ?? 9));
|
|
113
|
+
|
|
114
|
+
// Build summary
|
|
115
|
+
const summary = {
|
|
116
|
+
total: triaged.length,
|
|
117
|
+
merged: triaged.filter(t => t.category === "merged").length,
|
|
118
|
+
ready: triaged.filter(t => t.category === "ready").length,
|
|
119
|
+
needs_pr: triaged.filter(t => t.category === "needs_pr").length,
|
|
120
|
+
stuck: triaged.filter(t => t.category === "stuck").length,
|
|
121
|
+
backlog: triaged.filter(t => t.category === "backlog").length,
|
|
122
|
+
in_progress: triaged.filter(t => t.category === "in_progress").length,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Build recommendations
|
|
126
|
+
const recs: string[] = [];
|
|
127
|
+
const needsPr = triaged.filter(t => t.category === "needs_pr");
|
|
128
|
+
const stuck = triaged.filter(t => t.category === "stuck");
|
|
129
|
+
const ready = triaged.filter(t => t.category === "ready");
|
|
130
|
+
const inProgress = triaged.filter(t => t.category === "in_progress");
|
|
131
|
+
|
|
132
|
+
if (needsPr.length) {
|
|
133
|
+
recs.push(`Create PRs: ${needsPr.map(t => t.identifier).join(", ")} (have diffs, no PR)`);
|
|
134
|
+
}
|
|
135
|
+
if (ready.length) {
|
|
136
|
+
recs.push(`Review + approve: ${ready.map(t => t.identifier).join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
if (stuck.length) {
|
|
139
|
+
// Detect duplicates by similar titles
|
|
140
|
+
const stuckTitles = stuck.map(t => t.title.replace(/^(Implement |PLW-\d+ (BLOCKER|MEDIUM|LOW): )/i, "").slice(0, 40));
|
|
141
|
+
const dupes = stuck.filter((t, i) => {
|
|
142
|
+
const norm = stuckTitles[i];
|
|
143
|
+
return triaged.some(other =>
|
|
144
|
+
other.identifier !== t.identifier &&
|
|
145
|
+
(other.category === "needs_pr" || other.category === "ready" || other.category === "merged") &&
|
|
146
|
+
other.title.replace(/^(Implement |PLW-\d+ (BLOCKER|MEDIUM|LOW): )/i, "").slice(0, 40) === norm
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
if (dupes.length) {
|
|
150
|
+
recs.push(`Stop duplicates: ${dupes.map(t => t.identifier).join(", ")} (no output, duplicates of working tasks)`);
|
|
151
|
+
}
|
|
152
|
+
const realStuck = stuck.filter(t => !dupes.includes(t));
|
|
153
|
+
if (realStuck.length) {
|
|
154
|
+
recs.push(`Retry or stop: ${realStuck.map(t => t.identifier).join(", ")} (no diff produced)`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result: TriageResult = { summary, tasks: triaged, recommendations: recs };
|
|
159
|
+
|
|
160
|
+
if (args.json) { fmt.out(result); return; }
|
|
161
|
+
|
|
162
|
+
// Human output
|
|
163
|
+
const groups: Record<string, TriageTask[]> = {};
|
|
164
|
+
triaged.forEach(t => { (groups[t.category] = groups[t.category] || []).push(t); });
|
|
165
|
+
|
|
166
|
+
const sectionNames: Record<string, string> = {
|
|
167
|
+
in_progress: "IN PROGRESS",
|
|
168
|
+
needs_pr: "HAS CODE, NEEDS PR",
|
|
169
|
+
ready: "READY TO REVIEW",
|
|
170
|
+
stuck: "STUCK (no output)",
|
|
171
|
+
backlog: "BACKLOG",
|
|
172
|
+
merged: "MERGED",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
for (const cat of ["in_progress", "needs_pr", "ready", "stuck", "backlog", "merged"]) {
|
|
176
|
+
const items = groups[cat];
|
|
177
|
+
if (!items?.length) continue;
|
|
178
|
+
|
|
179
|
+
fmt.section(`${sectionNames[cat]} (${items.length})`);
|
|
180
|
+
items.forEach(t => {
|
|
181
|
+
let line = ` ${fmt.pad(t.identifier, 8)}`;
|
|
182
|
+
|
|
183
|
+
if (t.pr) {
|
|
184
|
+
line += ` PR#${fmt.pad(String(t.pr.number), 4)} [${fmt.pad(t.pr.state, 6)}]`;
|
|
185
|
+
} else {
|
|
186
|
+
line += ` `;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (t.diff && t.diff.files > 0) {
|
|
190
|
+
line += ` +${fmt.pad(String(t.diff.additions), 5)} -${fmt.pad(String(t.diff.deletions), 5)} ${fmt.pad(t.diff.files + " files", 8)}`;
|
|
191
|
+
} else {
|
|
192
|
+
line += ` `;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (t.jam) {
|
|
196
|
+
line += ` ${fmt.pad(t.jam.model, 12)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
line += ` ${(t.title || "").slice(0, 45)}`;
|
|
200
|
+
console.log(line);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log();
|
|
205
|
+
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`);
|
|
206
|
+
|
|
207
|
+
if (recs.length) {
|
|
208
|
+
console.log();
|
|
209
|
+
log.step("Recommendations");
|
|
210
|
+
recs.forEach((r, i) => console.log(` ${i + 1}. ${r}`));
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
async function enrichTasks(api: typeof import("../api.js"), tasks: any[], cfg: any) {
|
|
216
|
+
// Fetch full details and diffs in parallel batches
|
|
217
|
+
const enriched = await Promise.all(tasks.map(async (task) => {
|
|
218
|
+
const id = task.identifier || task.id;
|
|
219
|
+
let detail: any = task;
|
|
220
|
+
let diff: any = null;
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
// Only fetch detail if list response (no jams field)
|
|
224
|
+
if (!task.jams) {
|
|
225
|
+
detail = await api.getTask(id);
|
|
226
|
+
}
|
|
227
|
+
} catch {}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
diff = await api.getDiff(id);
|
|
231
|
+
} catch {}
|
|
232
|
+
|
|
233
|
+
const lastJam = (detail.jams || []).at(-1);
|
|
234
|
+
const credits = lastJam?.credits;
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
identifier: detail.identifier || id,
|
|
238
|
+
title: detail.title || task.title || "",
|
|
239
|
+
status: detail.status || task.status,
|
|
240
|
+
labels: detail.labels || task.labels || [],
|
|
241
|
+
createdAt: detail.createdAt || task.createdAt,
|
|
242
|
+
updatedAt: detail.updatedAt || task.updatedAt,
|
|
243
|
+
pr: detail.pullRequest?.number ? {
|
|
244
|
+
number: detail.pullRequest.number,
|
|
245
|
+
state: detail.pullRequest.state || "?",
|
|
246
|
+
url: detail.pullRequest.url,
|
|
247
|
+
} : null,
|
|
248
|
+
diff: diff?.stats ? {
|
|
249
|
+
files: diff.stats.files || 0,
|
|
250
|
+
additions: diff.stats.additions || 0,
|
|
251
|
+
deletions: diff.stats.deletions || 0,
|
|
252
|
+
} : null,
|
|
253
|
+
jam: lastJam ? {
|
|
254
|
+
model: lastJam.model || "?",
|
|
255
|
+
status: lastJam.status || "?",
|
|
256
|
+
credits: {
|
|
257
|
+
llm: typeof credits === "object" ? (credits?.llm ?? 0) : (credits || 0),
|
|
258
|
+
vm: typeof credits === "object" ? (credits?.vm ?? 0) : 0,
|
|
259
|
+
},
|
|
260
|
+
} : null,
|
|
261
|
+
_raw: detail,
|
|
262
|
+
};
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
return enriched;
|
|
266
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -200,6 +200,88 @@ server.registerTool("capy_retry", {
|
|
|
200
200
|
} catch (e) { return err(e); }
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
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.",
|
|
205
|
+
inputSchema: {
|
|
206
|
+
ids: z.array(z.string()).optional().describe("Specific task IDs to triage. Omit for all tasks."),
|
|
207
|
+
},
|
|
208
|
+
annotations: { readOnlyHint: true },
|
|
209
|
+
}, async ({ ids }) => {
|
|
210
|
+
try {
|
|
211
|
+
const github = await import("./github.js");
|
|
212
|
+
const cfg = config.load();
|
|
213
|
+
|
|
214
|
+
let tasks: any[];
|
|
215
|
+
if (ids?.length) {
|
|
216
|
+
tasks = await Promise.all(ids.map(id => api.getTask(id)));
|
|
217
|
+
} else {
|
|
218
|
+
const data = await api.listTasks({ limit: 100 });
|
|
219
|
+
tasks = data.items || [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const enriched = await Promise.all(tasks.map(async (task: any) => {
|
|
223
|
+
const id = task.identifier || task.id;
|
|
224
|
+
let detail: any = task;
|
|
225
|
+
let diff: any = null;
|
|
226
|
+
try { if (!task.jams) detail = await api.getTask(id); } catch {}
|
|
227
|
+
try { diff = await api.getDiff(id); } catch {}
|
|
228
|
+
|
|
229
|
+
if (detail.pullRequest?.number && detail.pullRequest.state === "closed") {
|
|
230
|
+
const repo = detail.pullRequest.repoFullName || cfg.repos[0]?.repoFullName;
|
|
231
|
+
if (repo) {
|
|
232
|
+
const ghPR = github.getPR(repo, detail.pullRequest.number);
|
|
233
|
+
if (ghPR) detail.pullRequest.state = ghPR.state.toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const lastJam = (detail.jams || []).at(-1);
|
|
238
|
+
const credits = lastJam?.credits;
|
|
239
|
+
const pr = detail.pullRequest?.number ? { number: detail.pullRequest.number, state: detail.pullRequest.state || "?", url: detail.pullRequest.url } : null;
|
|
240
|
+
const diffStats = diff?.stats ? { files: diff.stats.files || 0, additions: diff.stats.additions || 0, deletions: diff.stats.deletions || 0 } : null;
|
|
241
|
+
|
|
242
|
+
let category: string;
|
|
243
|
+
if (detail.status === "backlog") category = "backlog";
|
|
244
|
+
else if (detail.status === "in_progress") category = "in_progress";
|
|
245
|
+
else if (pr?.state === "merged") category = "merged";
|
|
246
|
+
else if (pr && pr.state === "open") category = "ready";
|
|
247
|
+
else if (diffStats && diffStats.files > 0 && !pr) category = "needs_pr";
|
|
248
|
+
else if (detail.status === "needs_review" && (!diffStats || diffStats.files === 0)) category = "stuck";
|
|
249
|
+
else category = "stuck";
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
identifier: detail.identifier || id,
|
|
253
|
+
title: detail.title || "",
|
|
254
|
+
status: detail.status,
|
|
255
|
+
labels: detail.labels || [],
|
|
256
|
+
category,
|
|
257
|
+
pr,
|
|
258
|
+
diff: diffStats,
|
|
259
|
+
jam: lastJam ? { model: lastJam.model || "?", status: lastJam.status || "?", credits: { llm: typeof credits === "object" ? (credits?.llm ?? 0) : (credits || 0), vm: typeof credits === "object" ? (credits?.vm ?? 0) : 0 } } : null,
|
|
260
|
+
};
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
const summary = {
|
|
264
|
+
total: enriched.length,
|
|
265
|
+
merged: enriched.filter((t: any) => t.category === "merged").length,
|
|
266
|
+
ready: enriched.filter((t: any) => t.category === "ready").length,
|
|
267
|
+
needs_pr: enriched.filter((t: any) => t.category === "needs_pr").length,
|
|
268
|
+
stuck: enriched.filter((t: any) => t.category === "stuck").length,
|
|
269
|
+
backlog: enriched.filter((t: any) => t.category === "backlog").length,
|
|
270
|
+
in_progress: enriched.filter((t: any) => t.category === "in_progress").length,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const recs: string[] = [];
|
|
274
|
+
const needsPr = enriched.filter((t: any) => t.category === "needs_pr");
|
|
275
|
+
const stuck = enriched.filter((t: any) => t.category === "stuck");
|
|
276
|
+
const ready = enriched.filter((t: any) => t.category === "ready");
|
|
277
|
+
if (needsPr.length) recs.push(`Create PRs: ${needsPr.map((t: any) => t.identifier).join(", ")}`);
|
|
278
|
+
if (ready.length) recs.push(`Review + approve: ${ready.map((t: any) => t.identifier).join(", ")}`);
|
|
279
|
+
if (stuck.length) recs.push(`Retry or stop: ${stuck.map((t: any) => t.identifier).join(", ")} (no diff produced)`);
|
|
280
|
+
|
|
281
|
+
return text({ summary, tasks: enriched, recommendations: recs });
|
|
282
|
+
} catch (e) { return err(e); }
|
|
283
|
+
});
|
|
284
|
+
|
|
203
285
|
// --- Status & monitoring ---
|
|
204
286
|
|
|
205
287
|
server.registerTool("capy_status", {
|