capyai 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/capy.js ADDED
@@ -0,0 +1,1619 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ import { createRequire } from "node:module";
4
+ var __create = Object.create;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __defProp = Object.defineProperty;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __toESM = (mod, isNodeMode, target) => {
10
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
11
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
12
+ for (let key of __getOwnPropNames(mod))
13
+ if (!__hasOwnProp.call(to, key))
14
+ __defProp(to, key, {
15
+ get: () => mod[key],
16
+ enumerable: true
17
+ });
18
+ return to;
19
+ };
20
+ var __export = (target, all) => {
21
+ for (var name in all)
22
+ __defProp(target, name, {
23
+ get: all[name],
24
+ enumerable: true,
25
+ configurable: true,
26
+ set: (newValue) => all[name] = () => newValue
27
+ });
28
+ };
29
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
30
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
31
+
32
+ // src/config.ts
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ function load() {
36
+ const envPath = process.env.CAPY_ENV_FILE || path.join(CONFIG_DIR, ".env");
37
+ try {
38
+ fs.readFileSync(envPath, "utf8").split(`
39
+ `).forEach((line) => {
40
+ const t = line.trim();
41
+ if (!t || t.startsWith("#"))
42
+ return;
43
+ const eq = t.indexOf("=");
44
+ if (eq === -1)
45
+ return;
46
+ const k = t.slice(0, eq).trim();
47
+ const v = t.slice(eq + 1).trim();
48
+ if (!process.env[k])
49
+ process.env[k] = v;
50
+ });
51
+ } catch {}
52
+ let cfg;
53
+ try {
54
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
55
+ } catch {
56
+ cfg = {};
57
+ }
58
+ const merged = { ...DEFAULTS, ...cfg };
59
+ merged.quality = { ...DEFAULTS.quality, ...cfg.quality || {} };
60
+ if (process.env.CAPY_API_KEY)
61
+ merged.apiKey = process.env.CAPY_API_KEY;
62
+ if (process.env.CAPY_PROJECT_ID)
63
+ merged.projectId = process.env.CAPY_PROJECT_ID;
64
+ if (process.env.CAPY_SERVER)
65
+ merged.server = process.env.CAPY_SERVER;
66
+ return merged;
67
+ }
68
+ function save(cfg) {
69
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
70
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + `
71
+ `);
72
+ }
73
+ function get(key) {
74
+ const cfg = load();
75
+ if (key.includes(".")) {
76
+ const parts = key.split(".");
77
+ let val = cfg;
78
+ for (const p of parts) {
79
+ val = val?.[p];
80
+ }
81
+ return val;
82
+ }
83
+ return cfg[key];
84
+ }
85
+ function set(key, value) {
86
+ const cfg = load();
87
+ if (key.includes(".")) {
88
+ const parts = key.split(".");
89
+ let obj = cfg;
90
+ for (let i = 0;i < parts.length - 1; i++) {
91
+ if (!obj[parts[i]])
92
+ obj[parts[i]] = {};
93
+ obj = obj[parts[i]];
94
+ }
95
+ let parsed = value;
96
+ if (value === "true")
97
+ parsed = true;
98
+ else if (value === "false")
99
+ parsed = false;
100
+ else if (/^\d+$/.test(value))
101
+ parsed = parseInt(value);
102
+ obj[parts[parts.length - 1]] = parsed;
103
+ } else {
104
+ cfg[key] = value;
105
+ }
106
+ save(cfg);
107
+ }
108
+ var CONFIG_DIR, CONFIG_PATH, WATCH_DIR, DEFAULTS;
109
+ var init_config = __esm(() => {
110
+ CONFIG_DIR = path.join(process.env.HOME || "/root", ".capy");
111
+ CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
112
+ WATCH_DIR = path.join(CONFIG_DIR, "watches");
113
+ DEFAULTS = {
114
+ apiKey: "",
115
+ projectId: "",
116
+ server: "https://capy.ai/api/v1",
117
+ repos: [],
118
+ defaultModel: "gpt-5.4",
119
+ quality: {
120
+ minReviewScore: 4,
121
+ requireCI: true,
122
+ requireTests: true,
123
+ requireLinearLink: true,
124
+ reviewProvider: "greptile"
125
+ },
126
+ watchInterval: 3,
127
+ notifyCommand: ""
128
+ };
129
+ });
130
+
131
+ // src/api.ts
132
+ async function request(method, path2, body) {
133
+ const cfg = load();
134
+ if (!cfg.apiKey) {
135
+ console.error("capy: API key not configured. Run: capy init");
136
+ process.exit(1);
137
+ }
138
+ const url = `${cfg.server}${path2}`;
139
+ const headers = {
140
+ Authorization: `Bearer ${cfg.apiKey}`,
141
+ Accept: "application/json"
142
+ };
143
+ const init = { method, headers };
144
+ if (body) {
145
+ headers["Content-Type"] = "application/json";
146
+ init.body = JSON.stringify(body);
147
+ }
148
+ let res;
149
+ try {
150
+ res = await fetch(url, init);
151
+ } catch (e) {
152
+ console.error(`capy: request failed — ${e.message}`);
153
+ process.exit(1);
154
+ }
155
+ const text = await res.text();
156
+ try {
157
+ const data = JSON.parse(text);
158
+ if (data.error) {
159
+ console.error(`capy: API error — ${data.error.message || data.error.code}`);
160
+ process.exit(1);
161
+ }
162
+ return data;
163
+ } catch {
164
+ console.error("capy: bad API response:", text.slice(0, 200));
165
+ process.exit(1);
166
+ }
167
+ }
168
+ async function createThread(prompt, model, repos) {
169
+ const cfg = load();
170
+ return request("POST", "/threads", {
171
+ projectId: cfg.projectId,
172
+ prompt,
173
+ model: model || cfg.defaultModel,
174
+ repos: repos || cfg.repos
175
+ });
176
+ }
177
+ async function listThreads(opts = {}) {
178
+ const cfg = load();
179
+ const p = new URLSearchParams({ projectId: cfg.projectId, limit: String(opts.limit || 10) });
180
+ if (opts.status)
181
+ p.set("status", opts.status);
182
+ return request("GET", `/threads?${p}`);
183
+ }
184
+ async function getThread(id) {
185
+ return request("GET", `/threads/${id}`);
186
+ }
187
+ async function messageThread(id, msg) {
188
+ return request("POST", `/threads/${id}/message`, { message: msg });
189
+ }
190
+ async function stopThread(id) {
191
+ return request("POST", `/threads/${id}/stop`);
192
+ }
193
+ async function getThreadMessages(id, opts = {}) {
194
+ const p = new URLSearchParams({ limit: String(opts.limit || 50) });
195
+ return request("GET", `/threads/${id}/messages?${p}`);
196
+ }
197
+ async function createTask(prompt, model, opts = {}) {
198
+ const cfg = load();
199
+ return request("POST", "/tasks", {
200
+ projectId: cfg.projectId,
201
+ prompt,
202
+ title: (opts.title || prompt).slice(0, 80),
203
+ repos: cfg.repos,
204
+ model: model || cfg.defaultModel,
205
+ start: opts.start !== false,
206
+ ...opts.labels ? { labels: opts.labels } : {}
207
+ });
208
+ }
209
+ async function listTasks(opts = {}) {
210
+ const cfg = load();
211
+ const p = new URLSearchParams({ projectId: cfg.projectId, limit: String(opts.limit || 30) });
212
+ if (opts.status)
213
+ p.set("status", opts.status);
214
+ return request("GET", `/tasks?${p}`);
215
+ }
216
+ async function getTask(id) {
217
+ return request("GET", `/tasks/${id}`);
218
+ }
219
+ async function startTask(id, model) {
220
+ return request("POST", `/tasks/${id}/start`, { model: model || load().defaultModel });
221
+ }
222
+ async function stopTask(id, reason) {
223
+ return request("POST", `/tasks/${id}/stop`, reason ? { reason } : {});
224
+ }
225
+ async function messageTask(id, msg) {
226
+ return request("POST", `/tasks/${id}/message`, { message: msg });
227
+ }
228
+ async function createPR(id, opts = {}) {
229
+ return request("POST", `/tasks/${id}/pr`, opts);
230
+ }
231
+ async function getDiff(id, mode = "run") {
232
+ return request("GET", `/tasks/${id}/diff?mode=${mode}`);
233
+ }
234
+ async function listModels() {
235
+ return request("GET", "/models");
236
+ }
237
+ var init_api = __esm(() => {
238
+ init_config();
239
+ });
240
+
241
+ // src/github.ts
242
+ import { execFileSync } from "node:child_process";
243
+ function gh(args, opts = {}) {
244
+ try {
245
+ return JSON.parse(execFileSync("gh", args, {
246
+ encoding: "utf8",
247
+ timeout: opts.timeout || 15000,
248
+ maxBuffer: 5 * 1024 * 1024
249
+ }));
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ function getPR(repo, number) {
255
+ return gh([
256
+ "pr",
257
+ "view",
258
+ String(number),
259
+ "--repo",
260
+ repo,
261
+ "--json",
262
+ "state,mergeable,mergedAt,closedAt,headRefName,baseRefName,title,body,url,number,additions,deletions,changedFiles,reviewDecision,statusCheckRollup,reviews,comments"
263
+ ]);
264
+ }
265
+ function getPRReviewComments(repo, number) {
266
+ try {
267
+ const out = execFileSync("gh", ["api", `repos/${repo}/pulls/${number}/comments`, "--paginate"], {
268
+ encoding: "utf8",
269
+ timeout: 15000,
270
+ maxBuffer: 5 * 1024 * 1024
271
+ });
272
+ return JSON.parse(out);
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+ function getPRIssueComments(repo, number) {
278
+ try {
279
+ const out = execFileSync("gh", ["api", `repos/${repo}/issues/${number}/comments`, "--paginate"], {
280
+ encoding: "utf8",
281
+ timeout: 15000,
282
+ maxBuffer: 5 * 1024 * 1024
283
+ });
284
+ return JSON.parse(out);
285
+ } catch {
286
+ return [];
287
+ }
288
+ }
289
+ function getCIStatus(repo, number, prData) {
290
+ const pr = prData || getPR(repo, number);
291
+ if (!pr)
292
+ return null;
293
+ const checks = pr.statusCheckRollup || [];
294
+ const total = checks.length;
295
+ const passing = checks.filter((c) => c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL" || c.status === "COMPLETED").length;
296
+ const failing = checks.filter((c) => c.conclusion === "FAILURE" || c.conclusion === "ERROR" || c.conclusion === "TIMED_OUT");
297
+ const pending = checks.filter((c) => c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING");
298
+ return {
299
+ total,
300
+ passing,
301
+ failing: failing.map((c) => ({ name: c.name || c.context || "", conclusion: c.conclusion })),
302
+ pending: pending.map((c) => ({ name: c.name || c.context || "", status: c.status })),
303
+ allGreen: total > 0 && failing.length === 0 && pending.length === 0,
304
+ noChecks: total === 0
305
+ };
306
+ }
307
+ function parseGreptileReview(comments) {
308
+ const greptile = comments.find((c) => (c.user?.login || "").toLowerCase().includes("greptile") || (c.body || "").includes("Confidence Score"));
309
+ if (!greptile)
310
+ return null;
311
+ const body = greptile.body || "";
312
+ const scoreMatch = body.match(/(?:Confidence\s*Score|confidence)[:\s]*(\d(?:\.\d)?)\s*\/\s*5/i);
313
+ const score = scoreMatch ? parseFloat(scoreMatch[1]) : null;
314
+ const logicCount = (body.match(/\bLogic\b/gi) || []).length;
315
+ const syntaxCount = (body.match(/\bSyntax\b/gi) || []).length;
316
+ const styleCount = (body.match(/\bStyle\b/gi) || []).length;
317
+ return {
318
+ score,
319
+ issueCount: logicCount + syntaxCount + styleCount,
320
+ logic: logicCount,
321
+ syntax: syntaxCount,
322
+ style: styleCount,
323
+ body: body.slice(0, 2000),
324
+ url: greptile.html_url
325
+ };
326
+ }
327
+ function diffHasTests(files) {
328
+ if (!files)
329
+ return false;
330
+ return files.some((f) => {
331
+ const p = (f.path || f.filename || "").toLowerCase();
332
+ return p.includes("test") || p.includes("spec") || p.includes("__tests__") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.go") || p.endsWith(".spec.ts") || p.endsWith(".spec.js");
333
+ });
334
+ }
335
+ function getUnresolvedThreads(repo, number) {
336
+ try {
337
+ const query = `query { repository(owner:"${repo.split("/")[0]}", name:"${repo.split("/")[1]}") { pullRequest(number:${number}) { reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:1) { nodes { body author { login } } } } } } } }`;
338
+ const out = execFileSync("gh", ["api", "graphql", "-f", `query=${query}`], {
339
+ encoding: "utf8",
340
+ timeout: 15000
341
+ });
342
+ const data = JSON.parse(out);
343
+ const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes || [];
344
+ return threads.filter((t) => !t.isResolved && !t.isOutdated).map((t) => ({
345
+ body: t.comments?.nodes?.[0]?.body?.slice(0, 200) || "",
346
+ author: t.comments?.nodes?.[0]?.author?.login || "unknown"
347
+ }));
348
+ } catch {
349
+ return [];
350
+ }
351
+ }
352
+ var init_github = () => {};
353
+
354
+ // src/greptile.ts
355
+ async function mcp(method, params) {
356
+ const cfg = load();
357
+ const apiKey = cfg.greptileApiKey || process.env.GREPTILE_API_KEY || "";
358
+ if (!apiKey) {
359
+ console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
360
+ process.exit(1);
361
+ }
362
+ const body = {
363
+ jsonrpc: "2.0",
364
+ id: Date.now(),
365
+ method: "tools/call",
366
+ params: {
367
+ name: method,
368
+ arguments: params
369
+ }
370
+ };
371
+ let res;
372
+ try {
373
+ res = await fetch(MCP_URL, {
374
+ method: "POST",
375
+ headers: {
376
+ Authorization: `Bearer ${apiKey}`,
377
+ "Content-Type": "application/json",
378
+ Accept: "application/json"
379
+ },
380
+ body: JSON.stringify(body),
381
+ signal: AbortSignal.timeout(30000)
382
+ });
383
+ } catch (e) {
384
+ console.error(`greptile: request failed — ${e.message}`);
385
+ return null;
386
+ }
387
+ const text = await res.text();
388
+ try {
389
+ const data = JSON.parse(text);
390
+ if (data.error) {
391
+ console.error(`greptile: ${data.error.message || JSON.stringify(data.error)}`);
392
+ return null;
393
+ }
394
+ if (data.result?.content) {
395
+ const textPart = data.result.content.find((c) => c.type === "text");
396
+ if (textPart) {
397
+ try {
398
+ return JSON.parse(textPart.text);
399
+ } catch {
400
+ return textPart.text;
401
+ }
402
+ }
403
+ }
404
+ return data.result;
405
+ } catch {
406
+ console.error("greptile: bad response:", text.slice(0, 300));
407
+ return null;
408
+ }
409
+ }
410
+ async function triggerReview(repo, prNumber, defaultBranch) {
411
+ return mcp("trigger_code_review", {
412
+ name: repo,
413
+ remote: "github",
414
+ defaultBranch: defaultBranch || "main",
415
+ prNumber
416
+ });
417
+ }
418
+ async function getReview(reviewId) {
419
+ return mcp("get_code_review", { codeReviewId: reviewId });
420
+ }
421
+ async function listComments(repo, prNumber, opts = {}) {
422
+ const params = {
423
+ name: repo,
424
+ remote: "github",
425
+ defaultBranch: opts.defaultBranch || "main",
426
+ prNumber
427
+ };
428
+ if (opts.greptileOnly)
429
+ params.greptileGenerated = true;
430
+ if (opts.unaddressedOnly)
431
+ params.addressed = false;
432
+ return mcp("list_merge_request_comments", params);
433
+ }
434
+ async function waitForReview(reviewId, timeoutMs = 120000) {
435
+ const start = Date.now();
436
+ while (Date.now() - start < timeoutMs) {
437
+ const review = await getReview(reviewId);
438
+ if (!review)
439
+ return null;
440
+ if (review.status === "COMPLETED")
441
+ return review;
442
+ if (review.status === "FAILED")
443
+ return review;
444
+ await new Promise((r) => setTimeout(r, 5000));
445
+ }
446
+ return null;
447
+ }
448
+ async function freshReview(repo, prNumber, defaultBranch) {
449
+ const trigger = await triggerReview(repo, prNumber, defaultBranch);
450
+ if (!trigger)
451
+ return null;
452
+ const reviewId = trigger.codeReviewId || trigger.id;
453
+ if (!reviewId)
454
+ return trigger;
455
+ console.error(`greptile: review triggered (${reviewId}), waiting...`);
456
+ return waitForReview(reviewId);
457
+ }
458
+ async function getUnaddressedIssues(repo, prNumber, defaultBranch) {
459
+ const comments = await listComments(repo, prNumber, {
460
+ defaultBranch,
461
+ greptileOnly: true,
462
+ unaddressedOnly: true
463
+ });
464
+ if (!comments || !Array.isArray(comments))
465
+ return [];
466
+ return comments.map((c) => ({
467
+ body: (c.body || "").slice(0, 200),
468
+ file: c.path || c.file || "?",
469
+ line: c.line || c.position || "?",
470
+ hasSuggestion: !!c.hasSuggestion,
471
+ suggestedCode: c.suggestedCode || null
472
+ }));
473
+ }
474
+ var MCP_URL = "https://api.greptile.com/mcp";
475
+ var init_greptile = __esm(() => {
476
+ init_config();
477
+ });
478
+
479
+ // src/quality.ts
480
+ function getGreptileStatusCheck(pr) {
481
+ if (!pr?.statusCheckRollup)
482
+ return null;
483
+ const c = pr.statusCheckRollup.find((c2) => (c2.name || c2.context || "").toLowerCase().includes("greptile"));
484
+ if (!c)
485
+ return null;
486
+ if (c.conclusion === "SUCCESS" || c.status === "COMPLETED")
487
+ return "success";
488
+ if (c.conclusion === "FAILURE" || c.conclusion === "ERROR")
489
+ return "failure";
490
+ if (c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING")
491
+ return "pending";
492
+ return null;
493
+ }
494
+ async function check(task) {
495
+ const cfg = load();
496
+ const thresholds = cfg.quality;
497
+ const gates = [];
498
+ const reviewProvider = cfg.quality.reviewProvider || "greptile";
499
+ const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
500
+ const useGreptile = (reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey;
501
+ const useCapy = reviewProvider === "capy" || reviewProvider === "both";
502
+ const hasPR = !!(task.pullRequest && task.pullRequest.number);
503
+ gates.push({ name: "pr_exists", pass: hasPR, detail: hasPR ? `PR#${task.pullRequest.number}` : "No PR created" });
504
+ if (!hasPR) {
505
+ return {
506
+ pass: false,
507
+ passed: 0,
508
+ total: 1,
509
+ gates,
510
+ summary: "No PR. Create one first: capy pr " + (task.identifier || task.id)
511
+ };
512
+ }
513
+ const repo = task.pullRequest.repoFullName || cfg.repos[0] && cfg.repos[0].repoFullName;
514
+ if (!repo) {
515
+ return { pass: false, passed: 0, total: 1, gates, summary: "No repo configured. Run: capy init" };
516
+ }
517
+ const prNum = task.pullRequest.number;
518
+ const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
519
+ const pr = getPR(repo, prNum);
520
+ if (pr) {
521
+ const merged = pr.state === "MERGED";
522
+ const open = pr.state === "OPEN";
523
+ gates.push({
524
+ name: "pr_open",
525
+ pass: merged || open,
526
+ detail: `${pr.state}${pr.reviewDecision ? ` (${pr.reviewDecision})` : ""}`
527
+ });
528
+ }
529
+ const ci = getCIStatus(repo, prNum, pr);
530
+ if (ci) {
531
+ const nonGreptile = (f) => !(f.name || "").toLowerCase().includes("greptile");
532
+ const failures = ci.failing.filter(nonGreptile);
533
+ const pending = ci.pending.filter(nonGreptile);
534
+ const ciGreen = failures.length === 0 && pending.length === 0;
535
+ const greptileCheck = !ci.failing.every(nonGreptile) || !ci.pending.every(nonGreptile);
536
+ gates.push({
537
+ name: "ci",
538
+ pass: ciGreen || ci.noChecks,
539
+ detail: ci.noChecks ? "No CI configured" : ciGreen ? `${ci.total - (greptileCheck ? 1 : 0)} passing` : `${failures.length} failing: ${failures.map((f) => f.name).join(", ")}`,
540
+ failing: failures,
541
+ pending
542
+ });
543
+ }
544
+ if (useGreptile) {
545
+ const status = getGreptileStatusCheck(pr);
546
+ if (status === "pending") {
547
+ gates.push({ name: "greptile", pass: false, detail: "Review still processing" });
548
+ } else {
549
+ const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
550
+ gates.push({
551
+ name: "greptile",
552
+ pass: unaddressed.length === 0,
553
+ detail: unaddressed.length === 0 ? "All issues addressed" : `${unaddressed.length} unaddressed: ${unaddressed.slice(0, 3).map((u) => `${u.file}:${u.line}`).join(", ")}`,
554
+ issues: unaddressed
555
+ });
556
+ if (status === "failure") {
557
+ gates.push({ name: "greptile_check", pass: false, detail: "Status check failing" });
558
+ } else if (status === "success") {
559
+ gates.push({ name: "greptile_check", pass: true, detail: "Status check passing" });
560
+ }
561
+ }
562
+ }
563
+ if (useCapy) {
564
+ const unresolved = getUnresolvedThreads(repo, prNum);
565
+ gates.push({
566
+ name: "threads",
567
+ pass: unresolved.length === 0,
568
+ detail: unresolved.length === 0 ? "No unresolved threads" : `${unresolved.length} unresolved`,
569
+ threads: unresolved
570
+ });
571
+ }
572
+ if (thresholds.requireTests) {
573
+ let diffFiles = null;
574
+ try {
575
+ diffFiles = (await getDiff(task.identifier || task.id)).files || null;
576
+ } catch {}
577
+ const hasTests = diffFiles ? diffHasTests(diffFiles) : false;
578
+ gates.push({ name: "tests", pass: hasTests, detail: hasTests ? "Tests in diff" : "No test files in diff" });
579
+ }
580
+ const passed = gates.filter((g) => g.pass).length;
581
+ const total = gates.length;
582
+ const allPass = gates.every((g) => g.pass);
583
+ const failing = gates.filter((g) => !g.pass);
584
+ let summary;
585
+ if (allPass) {
586
+ summary = `${passed}/${total} gates passing. Ready to merge.`;
587
+ } else {
588
+ summary = `${passed}/${total} gates passing:
589
+ ` + failing.map((g) => ` - ${g.name}: ${g.detail}`).join(`
590
+ `);
591
+ }
592
+ return { pass: allPass, passed, total, gates, summary };
593
+ }
594
+ var init_quality = __esm(() => {
595
+ init_github();
596
+ init_config();
597
+ init_greptile();
598
+ init_api();
599
+ });
600
+
601
+ // src/watch.ts
602
+ import fs2 from "node:fs";
603
+ import path2 from "node:path";
604
+ import { execSync } from "node:child_process";
605
+ function getCrontab() {
606
+ try {
607
+ return execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
608
+ } catch {
609
+ return "";
610
+ }
611
+ }
612
+ function setCrontab(content) {
613
+ execSync(`echo ${JSON.stringify(content)} | crontab -`, { encoding: "utf8" });
614
+ }
615
+ function add(id, type, intervalMin) {
616
+ const watchDir = WATCH_DIR;
617
+ fs2.mkdirSync(watchDir, { recursive: true });
618
+ const thisDir = path2.dirname(new URL(import.meta.url).pathname);
619
+ const binPath = path2.resolve(thisDir, "..", "bin", "capy.ts");
620
+ const runtime = typeof Bun !== "undefined" ? "bun" : "node";
621
+ const tag = `# capy-watch:${id}`;
622
+ const cronLine = `*/${intervalMin} * * * * ${runtime} ${binPath} _poll ${id} ${type} ${tag}`;
623
+ let crontab = getCrontab();
624
+ if (crontab.includes(`capy-watch:${id}`))
625
+ return false;
626
+ crontab = crontab.trimEnd() + `
627
+ ` + cronLine + `
628
+ `;
629
+ setCrontab(crontab);
630
+ fs2.writeFileSync(path2.join(watchDir, `${id}.json`), JSON.stringify({
631
+ id,
632
+ type,
633
+ intervalMin,
634
+ created: new Date().toISOString()
635
+ }));
636
+ return true;
637
+ }
638
+ function remove(id) {
639
+ let crontab = getCrontab();
640
+ const lines = crontab.split(`
641
+ `).filter((l) => !l.includes(`capy-watch:${id}`));
642
+ setCrontab(lines.join(`
643
+ `) + `
644
+ `);
645
+ try {
646
+ fs2.unlinkSync(path2.join(WATCH_DIR, `${id}.json`));
647
+ } catch {}
648
+ }
649
+ function list() {
650
+ try {
651
+ return fs2.readdirSync(WATCH_DIR).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(fs2.readFileSync(path2.join(WATCH_DIR, f), "utf8")));
652
+ } catch {
653
+ return [];
654
+ }
655
+ }
656
+ function notify(text) {
657
+ const cfg = load();
658
+ const cmd = cfg.notifyCommand || "openclaw system event --text {text} --mode now";
659
+ try {
660
+ execSync(cmd.replace("{text}", JSON.stringify(text)), {
661
+ encoding: "utf8",
662
+ timeout: 15000
663
+ });
664
+ return true;
665
+ } catch {
666
+ return false;
667
+ }
668
+ }
669
+ var init_watch = __esm(() => {
670
+ init_config();
671
+ });
672
+
673
+ // src/format.ts
674
+ function pad(s, n) {
675
+ return (String(s) + " ".repeat(n)).slice(0, n);
676
+ }
677
+ function out(data) {
678
+ if (IS_JSON) {
679
+ console.log(JSON.stringify(data, null, 2));
680
+ } else if (typeof data === "string") {
681
+ console.log(data);
682
+ } else if (data !== null && data !== undefined) {
683
+ console.log(JSON.stringify(data, null, 2));
684
+ }
685
+ }
686
+ function table(headers, rows) {
687
+ if (IS_JSON) {
688
+ console.log(JSON.stringify(rows, null, 2));
689
+ return;
690
+ }
691
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || "").length)));
692
+ console.log(headers.map((h, i) => pad(h, widths[i] + 2)).join(""));
693
+ console.log("-".repeat(widths.reduce((a, b) => a + b + 2, 0)));
694
+ rows.forEach((r) => {
695
+ console.log(r.map((c, i) => pad(String(c || ""), widths[i] + 2)).join(""));
696
+ });
697
+ }
698
+ function credits(c) {
699
+ if (!c)
700
+ return "0";
701
+ if (typeof c === "number")
702
+ return String(c);
703
+ return `llm=${c.llm || 0} vm=${c.vm || 0}`;
704
+ }
705
+ function section(title) {
706
+ if (!IS_JSON) {
707
+ console.log(`
708
+ ${title}`);
709
+ console.log("-".repeat(80));
710
+ }
711
+ }
712
+ var IS_JSON;
713
+ var init_format = __esm(() => {
714
+ IS_JSON = process.argv.includes("--json");
715
+ });
716
+
717
+ // src/cli.ts
718
+ var exports_cli = {};
719
+ __export(exports_cli, {
720
+ run: () => run
721
+ });
722
+ function parseModel(argv) {
723
+ const f = argv.find((a) => a.startsWith("--model="));
724
+ if (f)
725
+ return f.split("=")[1];
726
+ if (argv.includes("--opus"))
727
+ return "claude-opus-4-6";
728
+ if (argv.includes("--sonnet"))
729
+ return "claude-sonnet-4-6";
730
+ if (argv.includes("--mini"))
731
+ return "gpt-5.4-mini";
732
+ if (argv.includes("--fast"))
733
+ return "gpt-5.4-fast";
734
+ if (argv.includes("--kimi"))
735
+ return "kimi-k2.5";
736
+ if (argv.includes("--glm"))
737
+ return "glm-5";
738
+ if (argv.includes("--gemini"))
739
+ return "gemini-3.1-pro";
740
+ if (argv.includes("--grok"))
741
+ return "grok-4.1-fast";
742
+ if (argv.includes("--qwen"))
743
+ return "qwen-3-coder";
744
+ return null;
745
+ }
746
+ function strip(argv) {
747
+ return argv.filter((a) => !a.startsWith("--"));
748
+ }
749
+ function getMode(argv) {
750
+ const f = argv.find((a) => a.startsWith("--mode="));
751
+ return f ? f.split("=")[1] : "run";
752
+ }
753
+ function getInterval(argv) {
754
+ const f = argv.find((a) => a.startsWith("--interval="));
755
+ return f ? Math.max(1, Math.min(parseInt(f.split("=")[1]), 30)) : load().watchInterval;
756
+ }
757
+ async function run(cmd, argv) {
758
+ const handler = commands[cmd];
759
+ if (!handler) {
760
+ console.error(`capy: unknown command "${cmd}". Run: capy help`);
761
+ process.exit(1);
762
+ }
763
+ await handler(argv);
764
+ }
765
+ var commands;
766
+ var init_cli = __esm(() => {
767
+ init_api();
768
+ init_config();
769
+ init_github();
770
+ init_quality();
771
+ init_watch();
772
+ init_format();
773
+ init_greptile();
774
+ commands = {};
775
+ commands.init = async function(argv) {
776
+ const cfg = load();
777
+ const readline = await import("node:readline");
778
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
779
+ const ask = (q, def) => new Promise((r) => rl.question(`${q} [${def}]: `, (a) => r(a.trim() || def)));
780
+ cfg.apiKey = await ask("Capy API key", cfg.apiKey || "capy_...");
781
+ cfg.projectId = await ask("Project ID", cfg.projectId || "");
782
+ const repoStr = await ask("Repos (owner/repo:branch, comma-sep)", cfg.repos.map((r) => `${r.repoFullName}:${r.branch}`).join(",") || "owner/repo:main");
783
+ cfg.repos = repoStr.split(",").map((s) => {
784
+ const [repo, branch] = s.trim().split(":");
785
+ return { repoFullName: repo, branch: branch || "main" };
786
+ });
787
+ cfg.defaultModel = await ask("Default model", cfg.defaultModel);
788
+ cfg.quality.minReviewScore = parseInt(await ask("Min review score (1-5)", String(cfg.quality.minReviewScore)));
789
+ rl.close();
790
+ save(cfg);
791
+ console.log(`
792
+ Config saved to ${CONFIG_PATH}`);
793
+ };
794
+ commands.config = function(argv) {
795
+ const args = strip(argv);
796
+ if (args.length === 0) {
797
+ out(load());
798
+ return;
799
+ }
800
+ if (args.length === 1) {
801
+ const val = get(args[0]);
802
+ if (val === undefined) {
803
+ console.error(`capy: unknown config key "${args[0]}"`);
804
+ process.exit(1);
805
+ }
806
+ if (IS_JSON || typeof val === "object") {
807
+ out(IS_JSON ? { [args[0]]: val } : val);
808
+ } else {
809
+ console.log(String(val));
810
+ }
811
+ return;
812
+ }
813
+ set(args[0], args.slice(1).join(" "));
814
+ console.log(`Set ${args[0]} = ${get(args[0])}`);
815
+ };
816
+ commands.captain = commands.plan = async function(argv) {
817
+ const prompt = strip(argv).join(" ");
818
+ if (!prompt) {
819
+ console.error("Usage: capy captain <prompt>");
820
+ process.exit(1);
821
+ }
822
+ const model = parseModel(argv) || load().defaultModel;
823
+ const data = await createThread(prompt, model);
824
+ if (IS_JSON) {
825
+ out(data);
826
+ return;
827
+ }
828
+ console.log(`Captain started: https://app.capy.ai/threads/${data.id}`);
829
+ console.log(`Thread: ${data.id} Model: ${model}`);
830
+ };
831
+ commands.threads = async function(argv) {
832
+ const sub = strip(argv)[0] || "list";
833
+ if (sub === "list") {
834
+ const data = await listThreads();
835
+ if (IS_JSON) {
836
+ out(data.items || []);
837
+ return;
838
+ }
839
+ if (!data.items?.length) {
840
+ console.log("No threads.");
841
+ return;
842
+ }
843
+ table(["ID", "STATUS", "TITLE"], data.items.map((t) => [
844
+ t.id.slice(0, 16),
845
+ t.status,
846
+ (t.title || "(untitled)").slice(0, 40)
847
+ ]));
848
+ return;
849
+ }
850
+ if (sub === "get") {
851
+ const id = strip(argv)[1];
852
+ if (!id) {
853
+ console.error("Usage: capy threads get <id>");
854
+ process.exit(1);
855
+ }
856
+ const data = await getThread(id);
857
+ if (IS_JSON) {
858
+ out(data);
859
+ return;
860
+ }
861
+ console.log(`Thread: ${data.id}`);
862
+ console.log(`Title: ${data.title || "(untitled)"}`);
863
+ console.log(`Status: ${data.status}`);
864
+ if (data.tasks?.length) {
865
+ console.log(`
866
+ Tasks (${data.tasks.length}):`);
867
+ data.tasks.forEach((t) => console.log(` ${t.identifier} ${t.title} [${t.status}]`));
868
+ }
869
+ if (data.pullRequests?.length) {
870
+ console.log(`
871
+ PRs:`);
872
+ data.pullRequests.forEach((p) => console.log(` PR#${p.number} ${p.url} [${p.state}]`));
873
+ }
874
+ return;
875
+ }
876
+ if (sub === "msg" || sub === "message") {
877
+ const id = strip(argv)[1], msg = strip(argv).slice(2).join(" ");
878
+ if (!id || !msg) {
879
+ console.error("Usage: capy threads msg <id> <text>");
880
+ process.exit(1);
881
+ }
882
+ await messageThread(id, msg);
883
+ console.log("Message sent.");
884
+ return;
885
+ }
886
+ if (sub === "stop") {
887
+ const id = strip(argv)[1];
888
+ if (!id) {
889
+ console.error("Usage: capy threads stop <id>");
890
+ process.exit(1);
891
+ }
892
+ await stopThread(id);
893
+ console.log(`Stopped thread ${id}.`);
894
+ return;
895
+ }
896
+ if (sub === "messages" || sub === "msgs") {
897
+ const id = strip(argv)[1];
898
+ if (!id) {
899
+ console.error("Usage: capy threads messages <id>");
900
+ process.exit(1);
901
+ }
902
+ const data = await getThreadMessages(id);
903
+ if (IS_JSON) {
904
+ out(data.items || []);
905
+ return;
906
+ }
907
+ (data.items || []).forEach((m) => {
908
+ console.log(`[${m.source}] ${m.content.slice(0, 200)}`);
909
+ console.log();
910
+ });
911
+ return;
912
+ }
913
+ console.error("Usage: capy threads [list|get|msg|stop|messages]");
914
+ process.exit(1);
915
+ };
916
+ commands.build = commands.run = async function(argv) {
917
+ const prompt = strip(argv).join(" ");
918
+ if (!prompt) {
919
+ console.error("Usage: capy build <prompt>");
920
+ process.exit(1);
921
+ }
922
+ const model = parseModel(argv) || load().defaultModel;
923
+ const data = await createTask(prompt, model);
924
+ if (IS_JSON) {
925
+ out(data);
926
+ return;
927
+ }
928
+ console.log(`Build started: https://app.capy.ai/tasks/${data.id}`);
929
+ console.log(`ID: ${data.identifier} Model: ${model}`);
930
+ };
931
+ commands.list = commands.ls = async function(argv) {
932
+ const status = strip(argv)[0];
933
+ const data = await listTasks({ status });
934
+ if (IS_JSON) {
935
+ out(data.items || []);
936
+ return;
937
+ }
938
+ if (!data.items?.length) {
939
+ console.log("No tasks.");
940
+ return;
941
+ }
942
+ table(["ID", "STATUS", "TITLE", "PR"], data.items.map((t) => [
943
+ t.identifier,
944
+ t.status,
945
+ (t.title || "").slice(0, 45),
946
+ t.pullRequest ? `PR#${t.pullRequest.number} [${t.pullRequest.state}]` : "—"
947
+ ]));
948
+ };
949
+ commands.get = commands.show = async function(argv) {
950
+ const id = strip(argv)[0];
951
+ if (!id) {
952
+ console.error("Usage: capy get <id>");
953
+ process.exit(1);
954
+ }
955
+ const data = await getTask(id);
956
+ if (IS_JSON) {
957
+ out(data);
958
+ return;
959
+ }
960
+ console.log(`Task: ${data.identifier} — ${data.title}`);
961
+ console.log(`Status: ${data.status}`);
962
+ console.log(`Created: ${data.createdAt}`);
963
+ if (data.pullRequest) {
964
+ console.log(`PR: ${data.pullRequest.url || `#${data.pullRequest.number}`} [${data.pullRequest.state}]`);
965
+ }
966
+ if (data.jams?.length) {
967
+ console.log(`
968
+ Jams (${data.jams.length}):`);
969
+ data.jams.forEach((j, i) => {
970
+ console.log(` ${i + 1}. model=${j.model || "?"} status=${j.status || "?"} credits=${credits(j.credits)}`);
971
+ });
972
+ }
973
+ };
974
+ commands.start = async function(argv) {
975
+ const id = strip(argv)[0];
976
+ if (!id) {
977
+ console.error("Usage: capy start <id>");
978
+ process.exit(1);
979
+ }
980
+ const model = parseModel(argv) || load().defaultModel;
981
+ const data = await startTask(id, model);
982
+ if (IS_JSON) {
983
+ out(data);
984
+ return;
985
+ }
986
+ console.log(`Started ${data.identifier || id} → ${data.status}`);
987
+ };
988
+ commands.stop = commands.kill = async function(argv) {
989
+ const id = strip(argv)[0], reason = strip(argv).slice(1).join(" ");
990
+ if (!id) {
991
+ console.error("Usage: capy stop <id>");
992
+ process.exit(1);
993
+ }
994
+ const data = await stopTask(id, reason);
995
+ if (IS_JSON) {
996
+ out(data);
997
+ return;
998
+ }
999
+ console.log(`Stopped ${data.identifier || id} → ${data.status}`);
1000
+ };
1001
+ commands.msg = commands.message = async function(argv) {
1002
+ const id = strip(argv)[0], msg = strip(argv).slice(1).join(" ");
1003
+ if (!id || !msg) {
1004
+ console.error("Usage: capy msg <id> <text>");
1005
+ process.exit(1);
1006
+ }
1007
+ await messageTask(id, msg);
1008
+ if (IS_JSON) {
1009
+ out({ id, message: msg, status: "sent" });
1010
+ return;
1011
+ }
1012
+ console.log("Message sent.");
1013
+ };
1014
+ commands.diff = async function(argv) {
1015
+ const id = strip(argv)[0];
1016
+ if (!id) {
1017
+ console.error("Usage: capy diff <id>");
1018
+ process.exit(1);
1019
+ }
1020
+ const data = await getDiff(id, getMode(argv));
1021
+ if (IS_JSON) {
1022
+ out(data);
1023
+ return;
1024
+ }
1025
+ console.log(`Diff (${data.source || "unknown"}): +${data.stats?.additions || 0} -${data.stats?.deletions || 0} in ${data.stats?.files || 0} files
1026
+ `);
1027
+ if (data.files) {
1028
+ data.files.forEach((f) => {
1029
+ console.log(`--- ${f.path} (${f.state}) +${f.additions} -${f.deletions}`);
1030
+ if (f.patch)
1031
+ console.log(f.patch);
1032
+ console.log();
1033
+ });
1034
+ }
1035
+ };
1036
+ commands.pr = async function(argv) {
1037
+ const id = strip(argv)[0], title = strip(argv).slice(1).join(" ");
1038
+ if (!id) {
1039
+ console.error("Usage: capy pr <id> [title]");
1040
+ process.exit(1);
1041
+ }
1042
+ const body = title ? { title } : {};
1043
+ const data = await createPR(id, body);
1044
+ if (IS_JSON) {
1045
+ out(data);
1046
+ return;
1047
+ }
1048
+ console.log(`PR: ${data.url}`);
1049
+ console.log(`#${data.number} ${data.title} (${data.headRef} → ${data.baseRef})`);
1050
+ };
1051
+ commands.models = async function() {
1052
+ const data = await listModels();
1053
+ if (IS_JSON) {
1054
+ out(data.models || []);
1055
+ return;
1056
+ }
1057
+ if (data.models) {
1058
+ table(["MODEL", "PROVIDER", "CAPTAIN"], data.models.map((m) => [
1059
+ m.id,
1060
+ m.provider || "?",
1061
+ m.captainEligible ? "yes" : "no"
1062
+ ]));
1063
+ }
1064
+ };
1065
+ commands.tools = commands.commands = function(argv) {
1066
+ const all = {
1067
+ captain: { args: "<prompt>", desc: "Start Captain thread" },
1068
+ build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
1069
+ threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
1070
+ status: { args: "", desc: "Dashboard" },
1071
+ list: { args: "[status]", desc: "List tasks" },
1072
+ get: { args: "<id>", desc: "Task details" },
1073
+ start: { args: "<id>", desc: "Start task" },
1074
+ stop: { args: "<id> [reason]", desc: "Stop task" },
1075
+ msg: { args: "<id> <text>", desc: "Message task" },
1076
+ diff: { args: "<id>", desc: "View diff" },
1077
+ pr: { args: "<id> [title]", desc: "Create PR" },
1078
+ review: { args: "<id>", desc: "Quality gates check" },
1079
+ "re-review": { args: "<id>", desc: "Trigger Greptile re-review" },
1080
+ approve: { args: "<id>", desc: "Approve if gates pass" },
1081
+ retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
1082
+ watch: { args: "<id>", desc: "Poll + notify on completion" },
1083
+ unwatch: { args: "<id>", desc: "Stop watching" },
1084
+ watches: { args: "", desc: "List watches" },
1085
+ models: { args: "", desc: "List models" },
1086
+ tools: { args: "", desc: "This list" },
1087
+ config: { args: "[key] [value]", desc: "Get/set config" },
1088
+ init: { args: "", desc: "Interactive setup" }
1089
+ };
1090
+ if (IS_JSON) {
1091
+ out(all);
1092
+ return;
1093
+ }
1094
+ const cfg = load();
1095
+ console.log(`Available commands:
1096
+ `);
1097
+ for (const [name, t] of Object.entries(all)) {
1098
+ console.log(` ${pad(name, 14)} ${pad(t.args, 24)} ${t.desc}`);
1099
+ }
1100
+ console.log(`
1101
+ Config: ${CONFIG_PATH}`);
1102
+ console.log(`Review provider: ${cfg.quality?.reviewProvider || "greptile"}`);
1103
+ console.log(`Default model: ${cfg.defaultModel}`);
1104
+ console.log(`Repos: ${(cfg.repos || []).map((r) => r.repoFullName).join(", ") || "none"}`);
1105
+ const envVars = [
1106
+ ["CAPY_API_KEY", "API key (overrides config)"],
1107
+ ["CAPY_PROJECT_ID", "Project ID (overrides config)"],
1108
+ ["CAPY_SERVER", "API server URL"],
1109
+ ["CAPY_ENV_FILE", "Path to .env file"],
1110
+ ["GREPTILE_API_KEY", "Greptile API key"]
1111
+ ];
1112
+ console.log(`
1113
+ Environment variables:`);
1114
+ envVars.forEach(([k, v]) => console.log(` ${pad(k, 20)} ${v}`));
1115
+ };
1116
+ commands.status = commands.dashboard = async function(argv) {
1117
+ const cfg = load();
1118
+ const threads = await listThreads({ limit: 10 });
1119
+ const tasks = await listTasks({ limit: 30 });
1120
+ if (IS_JSON) {
1121
+ out({
1122
+ threads: threads.items || [],
1123
+ tasks: tasks.items || [],
1124
+ watches: list()
1125
+ });
1126
+ return;
1127
+ }
1128
+ const active = (threads.items || []).filter((t) => t.status === "active");
1129
+ if (active.length) {
1130
+ section("ACTIVE THREADS");
1131
+ active.forEach((t) => console.log(` ${t.id.slice(0, 14)} ${(t.title || "(untitled)").slice(0, 50)} [active]`));
1132
+ }
1133
+ const allTasks = tasks.items || [];
1134
+ const buckets = {};
1135
+ allTasks.forEach((t) => {
1136
+ (buckets[t.status] = buckets[t.status] || []).push(t);
1137
+ });
1138
+ if (buckets.in_progress?.length) {
1139
+ section("IN PROGRESS");
1140
+ buckets.in_progress.forEach((t) => {
1141
+ const j = (t.jams || []).at(-1);
1142
+ const stuck = j && j.status === "idle" && (!j.credits || typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0);
1143
+ console.log(` ${pad(t.identifier, 10)} ${pad((t.title || "").slice(0, 48), 50)}${stuck ? " !! STUCK" : ""}`);
1144
+ });
1145
+ }
1146
+ if (buckets.needs_review?.length) {
1147
+ section("NEEDS REVIEW");
1148
+ buckets.needs_review.forEach((t) => {
1149
+ let prInfo = "no PR";
1150
+ if (t.pullRequest?.number) {
1151
+ const repo = t.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
1152
+ const pr = getPR(repo, t.pullRequest.number);
1153
+ const state = pr ? pr.state : t.pullRequest.state || "?";
1154
+ const ci = getCIStatus(repo, t.pullRequest.number, pr);
1155
+ const ciStr = ci ? ci.allGreen ? "CI pass" : ci.noChecks ? "no CI" : "CI FAIL" : "?";
1156
+ prInfo = `PR#${t.pullRequest.number} [${state}] ${ciStr}`;
1157
+ }
1158
+ console.log(` ${pad(t.identifier, 10)} ${pad((t.title || "").slice(0, 42), 44)} ${prInfo}`);
1159
+ });
1160
+ }
1161
+ if (buckets.backlog?.length) {
1162
+ section(`BACKLOG (${buckets.backlog.length})`);
1163
+ buckets.backlog.forEach((t) => console.log(` ${pad(t.identifier, 10)} ${(t.title || "").slice(0, 60)}`));
1164
+ }
1165
+ const watches = list();
1166
+ if (watches.length) {
1167
+ section(`ACTIVE WATCHES (${watches.length})`);
1168
+ watches.forEach((w) => console.log(` ${pad(w.id.slice(0, 18), 20)} type=${w.type} every ${w.intervalMin}min`));
1169
+ }
1170
+ const stuckCount = (buckets.in_progress || []).filter((t) => {
1171
+ const j = (t.jams || []).at(-1);
1172
+ return j && j.status === "idle" && (!j.credits || typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0);
1173
+ }).length;
1174
+ console.log(`
1175
+ Summary: ${allTasks.length} tasks, ${(buckets.in_progress || []).length} active, ${(buckets.needs_review || []).length} review, ${stuckCount} stuck`);
1176
+ };
1177
+ commands.review = async function(argv) {
1178
+ const id = strip(argv)[0];
1179
+ if (!id) {
1180
+ console.error("Usage: capy review <id>");
1181
+ process.exit(1);
1182
+ }
1183
+ const task = await getTask(id);
1184
+ const cfg = load();
1185
+ const reviewProvider = cfg.quality?.reviewProvider || "greptile";
1186
+ if (!task.pullRequest?.number) {
1187
+ if (IS_JSON) {
1188
+ out({ error: "no_pr", task: task.identifier });
1189
+ return;
1190
+ }
1191
+ console.log(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
1192
+ return;
1193
+ }
1194
+ const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
1195
+ const prNum = task.pullRequest.number;
1196
+ const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
1197
+ let diffStats = null;
1198
+ try {
1199
+ const diff = await getDiff(id);
1200
+ diffStats = diff.stats || null;
1201
+ } catch {}
1202
+ const q = await check(task);
1203
+ let unaddressed = [];
1204
+ const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
1205
+ if ((reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey) {
1206
+ unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
1207
+ }
1208
+ if (IS_JSON) {
1209
+ out({
1210
+ task: task.identifier,
1211
+ quality: q,
1212
+ unaddressed,
1213
+ reviewProvider,
1214
+ diff: diffStats ? { files: diffStats.files || 0, additions: diffStats.additions || 0, deletions: diffStats.deletions || 0 } : null
1215
+ });
1216
+ return;
1217
+ }
1218
+ const prOpen = q.gates.find((g) => g.name === "pr_open");
1219
+ console.log(`Review: ${task.identifier} — ${task.title}`);
1220
+ console.log(`PR: #${prNum} [${prOpen?.detail || task.pullRequest?.state || "?"}]`);
1221
+ if (diffStats)
1222
+ console.log(`Diff: +${diffStats.additions || 0} -${diffStats.deletions || 0} in ${diffStats.files || 0} files`);
1223
+ console.log(`Review: ${reviewProvider}`);
1224
+ console.log();
1225
+ q.gates.forEach((g) => {
1226
+ const icon = g.pass ? "✓" : "✗";
1227
+ console.log(` ${icon} ${g.name}: ${g.detail}`);
1228
+ if (g.name === "ci" && g.failing?.length) {
1229
+ g.failing.forEach((f) => console.log(` ✗ ${f.name} (${f.conclusion || f.status})`));
1230
+ }
1231
+ if (g.name === "ci" && g.pending?.length) {
1232
+ g.pending.forEach((f) => console.log(` ... ${f.name} (${f.status})`));
1233
+ }
1234
+ });
1235
+ if (unaddressed.length > 0) {
1236
+ console.log(`
1237
+ Unaddressed Greptile issues (${unaddressed.length}):`);
1238
+ unaddressed.forEach((u) => {
1239
+ console.log(` ${u.file}:${u.line} ${u.body}`);
1240
+ if (u.hasSuggestion)
1241
+ console.log(` ^ has suggested fix`);
1242
+ });
1243
+ }
1244
+ console.log(`
1245
+ ${q.summary}`);
1246
+ const greptileGate = q.gates.find((g) => g.name === "greptile");
1247
+ if (greptileGate && !greptileGate.pass) {
1248
+ if (greptileGate.detail.includes("processing")) {
1249
+ console.log(`
1250
+ Greptile is still processing. Wait a minute, then: capy review ${task.identifier}`);
1251
+ } else {
1252
+ console.log(`
1253
+ Fix the unaddressed issues, push, and Greptile will auto-re-review.`);
1254
+ console.log(`Then: capy review ${task.identifier}`);
1255
+ }
1256
+ }
1257
+ };
1258
+ commands["re-review"] = commands.rereview = async function(argv) {
1259
+ const id = strip(argv)[0];
1260
+ if (!id) {
1261
+ console.error("Usage: capy re-review <id>");
1262
+ process.exit(1);
1263
+ }
1264
+ const cfg = load();
1265
+ const reviewProvider = cfg.quality?.reviewProvider || "greptile";
1266
+ if (reviewProvider !== "greptile" && reviewProvider !== "both") {
1267
+ console.error(`capy: re-review requires Greptile provider (current: ${reviewProvider})`);
1268
+ process.exit(1);
1269
+ }
1270
+ if (!cfg.greptileApiKey && !process.env.GREPTILE_API_KEY) {
1271
+ console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
1272
+ process.exit(1);
1273
+ }
1274
+ const task = await getTask(id);
1275
+ if (!task.pullRequest?.number) {
1276
+ console.error(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
1277
+ process.exit(1);
1278
+ }
1279
+ const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
1280
+ const prNum = task.pullRequest.number;
1281
+ const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
1282
+ console.log(`Triggering fresh Greptile review for PR#${prNum}...`);
1283
+ console.log(`(Note: Greptile auto-reviews on every push via triggerOnUpdates. This is a manual override.)`);
1284
+ const result = await freshReview(repo, prNum, defaultBranch);
1285
+ if (IS_JSON) {
1286
+ out(result);
1287
+ return;
1288
+ }
1289
+ if (result) {
1290
+ if (result.status === "COMPLETED") {
1291
+ console.log("Review completed.");
1292
+ } else if (result.status === "FAILED") {
1293
+ console.log("Review failed. Check the PR state.");
1294
+ } else {
1295
+ console.log(`Review status: ${result.status || "unknown"}`);
1296
+ }
1297
+ } else {
1298
+ console.log("Review triggered. Check back shortly or run: capy review " + task.identifier);
1299
+ }
1300
+ const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
1301
+ if (unaddressed.length > 0) {
1302
+ console.log(`
1303
+ Unaddressed issues: ${unaddressed.length}`);
1304
+ unaddressed.forEach((u) => console.log(` ${u.file}:${u.line} ${u.body}`));
1305
+ } else {
1306
+ console.log(`
1307
+ All issues addressed.`);
1308
+ }
1309
+ };
1310
+ commands.approve = async function(argv) {
1311
+ const id = strip(argv)[0];
1312
+ if (!id) {
1313
+ console.error("Usage: capy approve <id>");
1314
+ process.exit(1);
1315
+ }
1316
+ const force = argv.includes("--force");
1317
+ const task = await getTask(id);
1318
+ const cfg = load();
1319
+ const q = await check(task);
1320
+ if (IS_JSON) {
1321
+ out({ task: task.identifier, quality: q, approved: q.pass || force });
1322
+ return;
1323
+ }
1324
+ console.log(`${task.identifier} — ${task.title}
1325
+ `);
1326
+ q.gates.forEach((g) => {
1327
+ const icon = g.pass ? "✓" : "✗";
1328
+ console.log(` ${icon} ${g.name}: ${g.detail}`);
1329
+ });
1330
+ console.log(`
1331
+ ${q.summary}`);
1332
+ if (!q.pass && !force) {
1333
+ console.log(`
1334
+ Blocked. Fix the failing gates or use --force to override.`);
1335
+ process.exit(1);
1336
+ }
1337
+ if (q.pass || force) {
1338
+ console.log(`
1339
+ ✓ Approved.${force && !q.pass ? " (forced)" : ""}`);
1340
+ const approveCmd = cfg.approveCommand;
1341
+ if (approveCmd) {
1342
+ try {
1343
+ const { execSync: execSync2 } = await import("node:child_process");
1344
+ const expanded = approveCmd.replace("{task}", task.identifier || task.id).replace("{title}", task.title || "").replace("{pr}", String(task.pullRequest?.number || ""));
1345
+ execSync2(expanded, { encoding: "utf8", timeout: 15000, stdio: "pipe" });
1346
+ console.log("Post-approve hook ran.");
1347
+ } catch {}
1348
+ }
1349
+ }
1350
+ };
1351
+ commands.retry = async function(argv) {
1352
+ const id = strip(argv)[0];
1353
+ if (!id) {
1354
+ console.error('Usage: capy retry <id> [--fix "what to fix"]');
1355
+ process.exit(1);
1356
+ }
1357
+ const fixFlag = argv.find((a) => a.startsWith("--fix="));
1358
+ const fixArg = fixFlag ? fixFlag.split("=").slice(1).join("=") : null;
1359
+ const task = await getTask(id);
1360
+ const cfg = load();
1361
+ let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]
1362
+ `;
1363
+ try {
1364
+ const diff = await getDiff(id);
1365
+ if (diff.stats?.files && diff.stats.files > 0) {
1366
+ context += `
1367
+ Previous diff: +${diff.stats.additions} -${diff.stats.deletions} in ${diff.stats.files} files
1368
+ `;
1369
+ context += `Files changed: ${(diff.files || []).map((f) => f.path).join(", ")}
1370
+ `;
1371
+ } else {
1372
+ context += `
1373
+ Previous diff: empty (agent produced no changes)
1374
+ `;
1375
+ }
1376
+ } catch {
1377
+ context += `
1378
+ Previous diff: unavailable
1379
+ `;
1380
+ }
1381
+ if (task.pullRequest?.number) {
1382
+ const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
1383
+ const prNum = task.pullRequest.number;
1384
+ const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
1385
+ const reviewComments = getPRReviewComments(repo, prNum);
1386
+ const ci = getCIStatus(repo, prNum);
1387
+ const reviewProvider = cfg.quality?.reviewProvider || "greptile";
1388
+ const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
1389
+ if (reviewProvider === "greptile" && hasGreptileKey) {
1390
+ const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
1391
+ if (unaddressed.length > 0) {
1392
+ context += `
1393
+ Unaddressed Greptile issues (${unaddressed.length}):
1394
+ `;
1395
+ unaddressed.forEach((u) => {
1396
+ context += ` ${u.file}:${u.line}: ${u.body}
1397
+ `;
1398
+ if (u.suggestedCode)
1399
+ context += ` Suggested fix: ${u.suggestedCode.slice(0, 200)}
1400
+ `;
1401
+ });
1402
+ } else {
1403
+ context += `
1404
+ Greptile: all issues addressed
1405
+ `;
1406
+ }
1407
+ } else {
1408
+ const issueComments = getPRIssueComments(repo, prNum);
1409
+ const greptileReview = parseGreptileReview(issueComments);
1410
+ if (greptileReview) {
1411
+ context += `
1412
+ Greptile review: ${greptileReview.score}/5 (stale — may not reflect latest)
1413
+ `;
1414
+ }
1415
+ }
1416
+ if (ci && !ci.allGreen) {
1417
+ context += `
1418
+ CI failures: ${ci.failing.map((f) => f.name).join(", ")}
1419
+ `;
1420
+ }
1421
+ if (reviewComments.length) {
1422
+ context += `
1423
+ Review comments (${reviewComments.length}):
1424
+ `;
1425
+ reviewComments.slice(0, 5).forEach((c) => {
1426
+ context += ` ${c.path}:${c.line || "?"}: ${(c.body || "").slice(0, 150)}
1427
+ `;
1428
+ });
1429
+ }
1430
+ }
1431
+ const originalPrompt = task.prompt || task.title;
1432
+ let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.
1433
+
1434
+ `;
1435
+ retryPrompt += `Original task: ${originalPrompt}
1436
+
1437
+ `;
1438
+ retryPrompt += `--- CONTEXT FROM PREVIOUS ATTEMPT ---
1439
+ ${context}
1440
+ `;
1441
+ if (fixArg) {
1442
+ retryPrompt += `--- SPECIFIC FIX REQUESTED ---
1443
+ ${fixArg}
1444
+
1445
+ `;
1446
+ }
1447
+ retryPrompt += `--- INSTRUCTIONS ---
1448
+ `;
1449
+ retryPrompt += `Fix the issues from the previous attempt. Do not repeat the same mistakes.
1450
+ `;
1451
+ retryPrompt += `Include tests. Run tests before completing. Verify CI will pass.
1452
+ `;
1453
+ if (IS_JSON) {
1454
+ out({ originalTask: task.identifier, retryPrompt, context });
1455
+ return;
1456
+ }
1457
+ if (task.status === "in_progress") {
1458
+ await stopTask(id, "Retrying with fixes");
1459
+ console.log(`Stopped ${task.identifier}.`);
1460
+ }
1461
+ const model = parseModel(argv) || cfg.defaultModel;
1462
+ const data = await createThread(retryPrompt, model);
1463
+ console.log(`Retry started: https://app.capy.ai/threads/${data.id}`);
1464
+ console.log(`Thread: ${data.id} Model: ${model}`);
1465
+ console.log(`
1466
+ Context included: ${context.split(`
1467
+ `).length} lines from previous attempt.`);
1468
+ };
1469
+ commands.watch = function(argv) {
1470
+ const id = strip(argv)[0];
1471
+ if (!id) {
1472
+ console.error("Usage: capy watch <id> [--interval=3]");
1473
+ process.exit(1);
1474
+ }
1475
+ const interval = getInterval(argv);
1476
+ const type = id.length > 20 || id.length > 10 && !id.match(/^[A-Z]+-\d+$/) ? "thread" : "task";
1477
+ const added = add(id, type, interval);
1478
+ if (IS_JSON) {
1479
+ out({ id, type, interval, added });
1480
+ return;
1481
+ }
1482
+ if (added) {
1483
+ console.log(`Watching ${id} (${type}) every ${interval}min. Will notify when done.`);
1484
+ } else {
1485
+ console.log(`Already watching ${id}.`);
1486
+ }
1487
+ };
1488
+ commands.unwatch = function(argv) {
1489
+ const id = strip(argv)[0];
1490
+ if (!id) {
1491
+ console.error("Usage: capy unwatch <id>");
1492
+ process.exit(1);
1493
+ }
1494
+ remove(id);
1495
+ if (IS_JSON) {
1496
+ out({ id, status: "removed" });
1497
+ return;
1498
+ }
1499
+ console.log(`Stopped watching ${id}.`);
1500
+ };
1501
+ commands.watches = function() {
1502
+ const w = list();
1503
+ if (IS_JSON) {
1504
+ out(w);
1505
+ return;
1506
+ }
1507
+ if (!w.length) {
1508
+ console.log("No active watches.");
1509
+ return;
1510
+ }
1511
+ w.forEach((e) => console.log(`${pad(e.id.slice(0, 20), 22)} type=${e.type} every ${e.intervalMin}min since ${e.created}`));
1512
+ };
1513
+ commands._poll = async function(argv) {
1514
+ const id = argv[0], type = argv[1] || "task";
1515
+ if (!id)
1516
+ process.exit(1);
1517
+ if (type === "thread") {
1518
+ const data2 = await getThread(id);
1519
+ if (data2.status === "idle" || data2.status === "archived") {
1520
+ const taskLines = (data2.tasks || []).map((t) => ` ${t.identifier}: ${t.title} [${t.status}]`).join(`
1521
+ `);
1522
+ const prLines = (data2.pullRequests || []).map((p) => ` PR#${p.number}: ${p.url} [${p.state}]`).join(`
1523
+ `);
1524
+ let msg = `[Capy] Captain thread finished.
1525
+ Title: ${data2.title || "(untitled)"}
1526
+ Status: ${data2.status}`;
1527
+ if (taskLines)
1528
+ msg += `
1529
+
1530
+ Tasks:
1531
+ ${taskLines}`;
1532
+ if (prLines)
1533
+ msg += `
1534
+
1535
+ PRs:
1536
+ ${prLines}`;
1537
+ msg += `
1538
+
1539
+ Run: capy review <task-id> for each task, then capy approve <task-id> if quality passes.`;
1540
+ notify(msg);
1541
+ remove(id);
1542
+ }
1543
+ return;
1544
+ }
1545
+ const data = await getTask(id);
1546
+ if (data.status === "needs_review" || data.status === "archived") {
1547
+ let msg = `[Capy] Task ${data.identifier} ready.
1548
+ Title: ${data.title}
1549
+ Status: ${data.status}`;
1550
+ if (data.pullRequest)
1551
+ msg += `
1552
+ PR: ${data.pullRequest.url || "#" + data.pullRequest.number}`;
1553
+ msg += `
1554
+
1555
+ Run: capy review ${data.identifier}, then capy approve ${data.identifier} if quality passes.`;
1556
+ notify(msg);
1557
+ remove(id);
1558
+ }
1559
+ };
1560
+ });
1561
+
1562
+ // bin/capy.ts
1563
+ import { createRequire as createRequire2 } from "module";
1564
+ var require2 = createRequire2(import.meta.url);
1565
+ var { version } = require2("../package.json");
1566
+ var cmd = process.argv[2];
1567
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
1568
+ console.log(`capy \u2014 agent orchestrator with quality gates
1569
+
1570
+ Usage: capy <command> [args] [flags]
1571
+
1572
+ Agents:
1573
+ captain <prompt> Start Captain thread
1574
+ build <prompt> Start Build agent
1575
+ threads [list|get|msg|stop|messages]
1576
+
1577
+ Tasks:
1578
+ status Dashboard
1579
+ list [status] List tasks
1580
+ get <id> Task details
1581
+ start/stop/msg <id> Control tasks
1582
+ diff <id> View diff
1583
+ pr <id> [title] Create PR
1584
+
1585
+ Quality:
1586
+ review <id> Gate check
1587
+ re-review <id> Trigger Greptile re-review
1588
+ approve <id> [--force] Approve if gates pass
1589
+ retry <id> [--fix="..."] Retry with context
1590
+
1591
+ Monitoring:
1592
+ watch/unwatch <id> Auto-poll + notify
1593
+ watches List watches
1594
+
1595
+ Config:
1596
+ init Interactive setup
1597
+ config [key] [value] Get/set config
1598
+ models List models
1599
+ tools All commands + env vars
1600
+
1601
+ Flags:
1602
+ --json --model=<id> --opus --sonnet --fast
1603
+
1604
+ v${version}
1605
+ `);
1606
+ process.exit(0);
1607
+ }
1608
+ try {
1609
+ const { run: run2 } = await Promise.resolve().then(() => (init_cli(), exports_cli));
1610
+ await run2(cmd, process.argv.slice(3));
1611
+ } catch (e) {
1612
+ const err = e;
1613
+ if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_MODULE_NOT_FOUND") {
1614
+ console.error("capy: broken install. Reinstall: npm i -g capy-cli");
1615
+ process.exit(1);
1616
+ }
1617
+ console.error(err.message);
1618
+ process.exit(1);
1619
+ }