@kody-ade/kody-engine 0.2.1 → 0.2.2

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/bin/kody2.js CHANGED
@@ -1,5 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // package.json
4
+ var package_default = {
5
+ name: "@kody-ade/kody-engine",
6
+ version: "0.2.2",
7
+ description: "kody2 \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
8
+ license: "MIT",
9
+ type: "module",
10
+ bin: {
11
+ kody2: "dist/bin/kody2.js"
12
+ },
13
+ files: [
14
+ "dist",
15
+ "templates",
16
+ "kody.config.schema.json"
17
+ ],
18
+ scripts: {
19
+ kody2: "tsx bin/kody2.ts",
20
+ build: `tsup && node -e "require('fs').cpSync('src/executables', 'dist/executables', { recursive: true })"`,
21
+ test: "vitest run tests/unit tests/int --no-coverage",
22
+ "test:e2e": "vitest run tests/e2e --no-coverage",
23
+ "test:all": "vitest run tests --no-coverage",
24
+ typecheck: "tsc --noEmit",
25
+ lint: "biome check",
26
+ "lint:fix": "biome check --write",
27
+ format: "biome format --write",
28
+ prepublishOnly: "pnpm build"
29
+ },
30
+ dependencies: {
31
+ "@anthropic-ai/claude-agent-sdk": "0.2.92"
32
+ },
33
+ devDependencies: {
34
+ "@biomejs/biome": "^2.4.12",
35
+ "@types/node": "^22.5.4",
36
+ tsup: "^8.5.1",
37
+ tsx: "^4.21.0",
38
+ typescript: "~5.7.0",
39
+ vitest: "^4.1.1"
40
+ },
41
+ engines: {
42
+ node: ">=22"
43
+ },
44
+ repository: {
45
+ type: "git",
46
+ url: "git+https://github.com/aharonyaircohen/kody-engine.git"
47
+ },
48
+ homepage: "https://github.com/aharonyaircohen/kody-engine",
49
+ bugs: "https://github.com/aharonyaircohen/kody-engine/issues"
50
+ };
51
+
3
52
  // src/config.ts
4
53
  import * as fs from "fs";
5
54
  import * as path from "path";
@@ -8,9 +57,7 @@ var LITELLM_DEFAULT_URL = `http://localhost:${LITELLM_DEFAULT_PORT}`;
8
57
  function parseProviderModel(s) {
9
58
  const slash = s.indexOf("/");
10
59
  if (slash <= 0 || slash === s.length - 1) {
11
- throw new Error(
12
- `Invalid model spec '${s}' \u2014 expected 'provider/model' (e.g. 'minimax/MiniMax-M2.7-highspeed')`
13
- );
60
+ throw new Error(`Invalid model spec '${s}' \u2014 expected 'provider/model' (e.g. 'minimax/MiniMax-M2.7-highspeed')`);
14
61
  }
15
62
  return { provider: s.slice(0, slash), model: s.slice(slash + 1) };
16
63
  }
@@ -68,7 +115,8 @@ function parseIssueContext(raw) {
68
115
  const r = raw;
69
116
  const out = {};
70
117
  if (typeof r.commentLimit === "number" && r.commentLimit > 0) out.commentLimit = Math.floor(r.commentLimit);
71
- if (typeof r.commentMaxBytes === "number" && r.commentMaxBytes > 0) out.commentMaxBytes = Math.floor(r.commentMaxBytes);
118
+ if (typeof r.commentMaxBytes === "number" && r.commentMaxBytes > 0)
119
+ out.commentMaxBytes = Math.floor(r.commentMaxBytes);
72
120
  return Object.keys(out).length > 0 ? out : void 0;
73
121
  }
74
122
  function parseTestRequirements(raw) {
@@ -89,12 +137,293 @@ function getAnthropicApiKeyOrDummy() {
89
137
  }
90
138
 
91
139
  // src/executor.ts
92
- import * as fs9 from "fs";
140
+ import * as fs10 from "fs";
93
141
  import * as path8 from "path";
94
142
 
95
- // src/profile.ts
143
+ // src/agent.ts
96
144
  import * as fs2 from "fs";
97
145
  import * as path2 from "path";
146
+ import { query } from "@anthropic-ai/claude-agent-sdk";
147
+
148
+ // src/format.ts
149
+ function renderEvent(msg, opts = {}) {
150
+ if (opts.quiet) {
151
+ if (msg.type === "result") return formatResult(msg);
152
+ return null;
153
+ }
154
+ switch (msg.type) {
155
+ case "system":
156
+ return null;
157
+ case "assistant":
158
+ return formatAssistant(msg, opts);
159
+ case "user":
160
+ return formatUserToolResult(msg, opts);
161
+ case "result":
162
+ return formatResult(msg);
163
+ default:
164
+ return null;
165
+ }
166
+ }
167
+ function formatAssistant(msg, _opts) {
168
+ const content = msg.message?.content ?? [];
169
+ const lines = [];
170
+ for (const block of content) {
171
+ if (block.type === "text") {
172
+ const text = block.text.trim();
173
+ if (text) lines.push(text);
174
+ } else if (block.type === "tool_use") {
175
+ const tu = block;
176
+ lines.push(`\u2192 ${tu.name}${summarizeToolInput(tu.name, tu.input)}`);
177
+ }
178
+ }
179
+ return lines.length > 0 ? lines.join("\n") : null;
180
+ }
181
+ function formatUserToolResult(msg, opts) {
182
+ const content = msg.message?.content ?? [];
183
+ const lines = [];
184
+ for (const block of content) {
185
+ if (block.type === "tool_result") {
186
+ const tr = block;
187
+ const text = stringifyToolContent(tr.content);
188
+ const lineCount = text.split("\n").length;
189
+ const sizeBytes = text.length;
190
+ const flag = tr.is_error ? " ERROR" : "";
191
+ const summary = ` \u21B3${flag} ${lineCount} lines, ${formatBytes(sizeBytes)}`;
192
+ if (opts.verbose) {
193
+ lines.push(`${summary}
194
+ ${truncate(text, 4e3)}`);
195
+ } else {
196
+ lines.push(summary);
197
+ }
198
+ }
199
+ }
200
+ return lines.length > 0 ? lines.join("\n") : null;
201
+ }
202
+ function formatResult(msg) {
203
+ const ok = msg.subtype === "success";
204
+ const tag = ok ? "DONE" : `FAILED (${msg.subtype ?? "unknown"})`;
205
+ const dur = msg.duration_ms ? ` ${(msg.duration_ms / 1e3).toFixed(1)}s` : "";
206
+ const turns = msg.num_turns ? ` ${msg.num_turns} turns` : "";
207
+ const cost = typeof msg.total_cost_usd === "number" ? ` $${msg.total_cost_usd.toFixed(4)}` : "";
208
+ return `
209
+ === ${tag}${dur}${turns}${cost} ===`;
210
+ }
211
+ function summarizeToolInput(toolName, input = {}) {
212
+ if (toolName === "Bash" && typeof input.command === "string") {
213
+ const cmd = input.command.split("\n")[0];
214
+ return `: ${truncate(cmd, 120)}`;
215
+ }
216
+ if ((toolName === "Read" || toolName === "Edit" || toolName === "Write") && typeof input.file_path === "string") {
217
+ return ` ${input.file_path}`;
218
+ }
219
+ if ((toolName === "Glob" || toolName === "Grep") && typeof input.pattern === "string") {
220
+ return `: ${truncate(input.pattern, 80)}`;
221
+ }
222
+ return "";
223
+ }
224
+ function stringifyToolContent(content) {
225
+ if (typeof content === "string") return content;
226
+ if (Array.isArray(content)) {
227
+ return content.map((b) => {
228
+ if (b && typeof b === "object" && "text" in b && typeof b.text === "string") {
229
+ return b.text;
230
+ }
231
+ return JSON.stringify(b);
232
+ }).join("\n");
233
+ }
234
+ return JSON.stringify(content);
235
+ }
236
+ function truncate(s, max) {
237
+ if (s.length <= max) return s;
238
+ return `${s.slice(0, max)}\u2026 (+${s.length - max} chars)`;
239
+ }
240
+ function formatBytes(bytes) {
241
+ if (bytes < 1024) return `${bytes}B`;
242
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
243
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
244
+ }
245
+
246
+ // src/agent.ts
247
+ var DEFAULT_ALLOWED_TOOLS = ["Bash", "Edit", "Read", "Write", "Glob", "Grep"];
248
+ async function runAgent(opts) {
249
+ const ndjsonDir = opts.ndjsonDir ?? path2.join(opts.cwd, ".kody2");
250
+ fs2.mkdirSync(ndjsonDir, { recursive: true });
251
+ const ndjsonPath = path2.join(ndjsonDir, "last-run.jsonl");
252
+ const fullLog = fs2.createWriteStream(ndjsonPath, { flags: "w" });
253
+ const env = {
254
+ ...process.env,
255
+ SKIP_HOOKS: "1",
256
+ HUSKY: "0",
257
+ CI: process.env.CI ?? "1"
258
+ };
259
+ if (opts.litellmUrl) {
260
+ env.ANTHROPIC_BASE_URL = opts.litellmUrl;
261
+ env.ANTHROPIC_API_KEY = getAnthropicApiKeyOrDummy();
262
+ }
263
+ let finalText = "";
264
+ let outcome = "failed";
265
+ let errorMessage;
266
+ try {
267
+ const queryOptions = {
268
+ model: opts.model.model,
269
+ cwd: opts.cwd,
270
+ allowedTools: opts.allowedToolsOverride ?? DEFAULT_ALLOWED_TOOLS,
271
+ permissionMode: opts.permissionModeOverride ?? "acceptEdits",
272
+ env
273
+ };
274
+ if (opts.mcpServers && opts.mcpServers.length > 0) {
275
+ queryOptions.mcpServers = opts.mcpServers;
276
+ }
277
+ const result = query({
278
+ prompt: opts.prompt,
279
+ // biome-ignore lint/suspicious/noExplicitAny: SDK options type is narrow; mcpServers is runtime-passthrough.
280
+ options: queryOptions
281
+ });
282
+ for await (const msg of result) {
283
+ try {
284
+ fullLog.write(`${JSON.stringify(msg)}
285
+ `);
286
+ } catch {
287
+ }
288
+ const line = renderEvent(msg, { verbose: opts.verbose, quiet: opts.quiet });
289
+ if (line) process.stdout.write(`${line}
290
+ `);
291
+ const m = msg;
292
+ if (m.type === "result") {
293
+ if (m.subtype === "success") {
294
+ outcome = "completed";
295
+ finalText = (typeof m.result === "string" ? m.result : "").trim();
296
+ } else {
297
+ outcome = "failed";
298
+ errorMessage = `result subtype: ${m.subtype ?? "unknown"}`;
299
+ }
300
+ }
301
+ }
302
+ } catch (e) {
303
+ outcome = "failed";
304
+ errorMessage = e instanceof Error ? e.message : String(e);
305
+ } finally {
306
+ try {
307
+ fullLog.end();
308
+ } catch {
309
+ }
310
+ }
311
+ return { outcome, finalText, error: errorMessage, ndjsonPath };
312
+ }
313
+
314
+ // src/litellm.ts
315
+ import { execFileSync, spawn } from "child_process";
316
+ import * as fs3 from "fs";
317
+ import * as os from "os";
318
+ import * as path3 from "path";
319
+ async function checkLitellmHealth(url) {
320
+ try {
321
+ const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3e3) });
322
+ return response.ok;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+ function generateLitellmConfigYaml(model) {
328
+ const apiKeyVar = providerApiKeyEnvVar(model.provider);
329
+ return [
330
+ "model_list:",
331
+ ` - model_name: ${model.model}`,
332
+ ` litellm_params:`,
333
+ ` model: ${model.provider}/${model.model}`,
334
+ ` api_key: os.environ/${apiKeyVar}`,
335
+ "",
336
+ "litellm_settings:",
337
+ " drop_params: true",
338
+ ""
339
+ ].join("\n");
340
+ }
341
+ async function startLitellmIfNeeded(model, projectDir, url = LITELLM_DEFAULT_URL) {
342
+ if (!needsLitellmProxy(model)) return null;
343
+ if (await checkLitellmHealth(url)) {
344
+ return { url, kill: () => {
345
+ } };
346
+ }
347
+ let cmd = "litellm";
348
+ try {
349
+ execFileSync("which", ["litellm"], { timeout: 3e3, stdio: "pipe" });
350
+ } catch {
351
+ try {
352
+ execFileSync("python3", ["-c", "import litellm"], { timeout: 1e4, stdio: "pipe" });
353
+ cmd = "python3";
354
+ } catch {
355
+ throw new Error("litellm not installed \u2014 run: pip install 'litellm[proxy]'");
356
+ }
357
+ }
358
+ const configPath = path3.join(os.tmpdir(), `kody2-litellm-${Date.now()}.yaml`);
359
+ fs3.writeFileSync(configPath, generateLitellmConfigYaml(model));
360
+ const portMatch = url.match(/:(\d+)/);
361
+ const port = portMatch ? portMatch[1] : "4000";
362
+ const args = cmd === "litellm" ? ["--config", configPath, "--port", port] : ["-m", "litellm", "--config", configPath, "--port", port];
363
+ const dotenvVars = readDotenvApiKeys(projectDir);
364
+ const logPath = path3.join(os.tmpdir(), `kody2-litellm-${Date.now()}.log`);
365
+ const outFd = fs3.openSync(logPath, "w");
366
+ const child = spawn(cmd, args, {
367
+ stdio: ["ignore", outFd, outFd],
368
+ detached: true,
369
+ env: stripBlockingEnv({ ...process.env, ...dotenvVars })
370
+ });
371
+ fs3.closeSync(outFd);
372
+ for (let i = 0; i < 30; i++) {
373
+ await new Promise((r) => setTimeout(r, 2e3));
374
+ if (await checkLitellmHealth(url)) {
375
+ return {
376
+ url,
377
+ kill: () => {
378
+ try {
379
+ child.kill();
380
+ } catch {
381
+ }
382
+ }
383
+ };
384
+ }
385
+ }
386
+ let logTail = "";
387
+ try {
388
+ logTail = fs3.readFileSync(logPath, "utf-8").slice(-2e3);
389
+ } catch {
390
+ }
391
+ try {
392
+ child.kill();
393
+ } catch {
394
+ }
395
+ throw new Error(`LiteLLM proxy failed to start within 60s. Log tail:
396
+ ${logTail}`);
397
+ }
398
+ function readDotenvApiKeys(projectDir) {
399
+ const dotenvPath = path3.join(projectDir, ".env");
400
+ if (!fs3.existsSync(dotenvPath)) return {};
401
+ const result = {};
402
+ for (const rawLine of fs3.readFileSync(dotenvPath, "utf-8").split("\n")) {
403
+ const line = rawLine.trim();
404
+ if (!line || line.startsWith("#")) continue;
405
+ const match = line.match(/^([A-Z_][A-Z0-9_]*_API_KEY)=(.*)$/);
406
+ if (!match) continue;
407
+ let value = match[2].trim();
408
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
409
+ value = value.slice(1, -1);
410
+ }
411
+ const commentIdx = value.indexOf(" #");
412
+ if (commentIdx !== -1) value = value.slice(0, commentIdx).trim();
413
+ if (value) result[match[1]] = value;
414
+ }
415
+ return result;
416
+ }
417
+ function stripBlockingEnv(env) {
418
+ const out = { ...env };
419
+ delete out.DATABASE_URL;
420
+ delete out.AI_BASE_URL;
421
+ return out;
422
+ }
423
+
424
+ // src/profile.ts
425
+ import * as fs4 from "fs";
426
+ import * as path4 from "path";
98
427
  var VALID_INPUT_TYPES = /* @__PURE__ */ new Set(["int", "string", "bool", "enum"]);
99
428
  var VALID_PERMISSION_MODES = /* @__PURE__ */ new Set(["default", "acceptEdits", "plan", "bypassPermissions"]);
100
429
  var ProfileError = class extends Error {
@@ -107,12 +436,12 @@ var ProfileError = class extends Error {
107
436
  profilePath;
108
437
  };
109
438
  function loadProfile(profilePath) {
110
- if (!fs2.existsSync(profilePath)) {
439
+ if (!fs4.existsSync(profilePath)) {
111
440
  throw new ProfileError(profilePath, "file not found");
112
441
  }
113
442
  let raw;
114
443
  try {
115
- raw = JSON.parse(fs2.readFileSync(profilePath, "utf-8"));
444
+ raw = JSON.parse(fs4.readFileSync(profilePath, "utf-8"));
116
445
  } catch (err) {
117
446
  throw new ProfileError(profilePath, `invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
118
447
  }
@@ -128,7 +457,7 @@ function loadProfile(profilePath) {
128
457
  cliTools: parseCliTools(profilePath, r.cliTools),
129
458
  scripts: parseScripts(profilePath, r.scripts),
130
459
  outputContract: r.outputContract,
131
- dir: path2.dirname(profilePath)
460
+ dir: path4.dirname(profilePath)
132
461
  };
133
462
  return profile;
134
463
  }
@@ -270,637 +599,368 @@ function parseScriptList(p, key, raw) {
270
599
  return out;
271
600
  }
272
601
 
273
- // src/issue.ts
274
- import { execFileSync } from "child_process";
275
- var API_TIMEOUT_MS = 3e4;
276
- function ghToken() {
277
- return process.env.GH_PAT?.trim() || process.env.GH_TOKEN;
602
+ // src/coverage.ts
603
+ import { execFileSync as execFileSync2 } from "child_process";
604
+ function patternToRegex(pattern) {
605
+ let s = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
606
+ s = s.replace(/\*\*\//g, "\xA7S").replace(/\*\*/g, "\xA7A").replace(/\*/g, "[^/]*");
607
+ s = s.replace(/§S/g, "(?:.*/)?").replace(/§A/g, ".*");
608
+ return new RegExp(`^${s}$`);
278
609
  }
279
- function gh(args, options) {
280
- const token = ghToken();
281
- const env = token ? { ...process.env, GH_TOKEN: token } : { ...process.env };
282
- return execFileSync("gh", args, {
283
- encoding: "utf-8",
284
- timeout: API_TIMEOUT_MS,
285
- cwd: options?.cwd,
286
- env,
287
- input: options?.input,
288
- stdio: options?.input ? ["pipe", "pipe", "pipe"] : ["inherit", "pipe", "pipe"]
289
- }).trim();
290
- }
291
- function getIssue(issueNumber, cwd) {
292
- const output = gh(
293
- ["issue", "view", String(issueNumber), "--json", "number,title,body,comments"],
294
- { cwd }
295
- );
296
- const parsed = JSON.parse(output);
297
- if (typeof parsed?.title !== "string") {
298
- throw new Error(`Issue #${issueNumber}: unexpected response shape`);
299
- }
300
- return {
301
- number: parsed.number ?? issueNumber,
302
- title: parsed.title,
303
- body: parsed.body ?? "",
304
- comments: (parsed.comments ?? []).map((c) => ({
305
- body: c.body ?? "",
306
- author: c.author?.login ?? "unknown",
307
- createdAt: c.createdAt ?? ""
308
- }))
309
- };
310
- }
311
- function postIssueComment(issueNumber, body, cwd) {
312
- try {
313
- gh(
314
- ["issue", "comment", String(issueNumber), "--body-file", "-"],
315
- { input: body, cwd }
316
- );
317
- } catch (err) {
318
- process.stderr.write(`[kody2] failed to post comment on #${issueNumber}: ${err instanceof Error ? err.message : String(err)}
319
- `);
320
- }
321
- }
322
- function truncate(s, maxBytes) {
323
- if (s.length <= maxBytes) return s;
324
- return s.slice(0, maxBytes) + `\u2026 (+${s.length - maxBytes} chars)`;
325
- }
326
- function getPr(prNumber, cwd) {
327
- const output = gh(
328
- ["pr", "view", String(prNumber), "--json", "number,title,body,headRefName,baseRefName,state"],
329
- { cwd }
330
- );
331
- const parsed = JSON.parse(output);
332
- if (typeof parsed?.title !== "string") {
333
- throw new Error(`PR #${prNumber}: unexpected response shape`);
334
- }
335
- return {
336
- number: parsed.number ?? prNumber,
337
- title: parsed.title,
338
- body: parsed.body ?? "",
339
- headRefName: String(parsed.headRefName ?? ""),
340
- baseRefName: String(parsed.baseRefName ?? ""),
341
- state: String(parsed.state ?? "")
342
- };
610
+ function renderSiblingPath(file, requireSibling) {
611
+ const lastSlash = file.lastIndexOf("/");
612
+ const dir = lastSlash === -1 ? "" : file.slice(0, lastSlash + 1);
613
+ const base = lastSlash === -1 ? file : file.slice(lastSlash + 1);
614
+ const name = base.replace(/\.[^.]+$/, "");
615
+ const ext = base.match(/\.[^.]+$/)?.[0] ?? "";
616
+ const sibling = requireSibling.replace(/\{name\}/g, name).replace(/\{ext\}/g, ext);
617
+ return dir + sibling;
343
618
  }
344
- function getPrDiff(prNumber, cwd) {
619
+ function safeGit(args, cwd) {
345
620
  try {
346
- return gh(["pr", "diff", String(prNumber)], { cwd });
347
- } catch (err) {
348
- process.stderr.write(`[kody2] failed to fetch diff for PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}
349
- `);
621
+ return execFileSync2("git", args, { encoding: "utf-8", cwd, env: { ...process.env, HUSKY: "0" } }).trim();
622
+ } catch {
350
623
  return "";
351
624
  }
352
625
  }
353
- function getPrReviews(prNumber, cwd) {
354
- try {
355
- const output = gh(
356
- ["pr", "view", String(prNumber), "--json", "reviews"],
357
- { cwd }
358
- );
359
- const parsed = JSON.parse(output);
360
- if (!Array.isArray(parsed?.reviews)) return [];
361
- return parsed.reviews.map((r) => ({
362
- body: r.body ?? "",
363
- state: r.state ?? "",
364
- author: r.author?.login ?? "unknown",
365
- submittedAt: r.submittedAt ?? ""
366
- }));
367
- } catch {
368
- return [];
369
- }
626
+ function getAddedFiles(baseBranch, cwd) {
627
+ const committed = safeGit(["diff", "--name-only", "--diff-filter=A", `origin/${baseBranch}...HEAD`], cwd);
628
+ const untracked = safeGit(["ls-files", "--others", "--exclude-standard"], cwd);
629
+ const set = /* @__PURE__ */ new Set();
630
+ for (const f of committed.split("\n")) if (f) set.add(f);
631
+ for (const f of untracked.split("\n")) if (f) set.add(f);
632
+ return [...set];
370
633
  }
371
- function getPrComments(prNumber, cwd) {
372
- try {
373
- const output = gh(["pr", "view", String(prNumber), "--json", "comments"], { cwd });
374
- const parsed = JSON.parse(output);
375
- if (!Array.isArray(parsed?.comments)) return [];
376
- return parsed.comments.map((c) => ({
377
- body: c.body ?? "",
378
- author: c.author?.login ?? "unknown",
379
- createdAt: c.createdAt ?? ""
380
- })).filter((c) => c.body.trim().length > 0);
381
- } catch {
382
- return [];
634
+ function checkCoverage(addedFiles, requirements) {
635
+ if (requirements.length === 0) return [];
636
+ const addedSet = new Set(addedFiles);
637
+ const misses = [];
638
+ for (const file of addedFiles) {
639
+ if (/\.(test|spec)\./.test(file)) continue;
640
+ for (const req of requirements) {
641
+ const re = patternToRegex(req.pattern);
642
+ if (!re.test(file)) continue;
643
+ const expected = renderSiblingPath(file, req.requireSibling);
644
+ if (!addedSet.has(expected)) {
645
+ misses.push({ file, expectedTest: expected });
646
+ }
647
+ break;
648
+ }
383
649
  }
650
+ return misses;
384
651
  }
385
- var KODY_COMMENT_PREFIXES = ["\u2699\uFE0F kody2", "\u2705 kody2", "\u26A0\uFE0F kody2", "\u2139\uFE0F kody2", "\u2192 kody2"];
386
- function getPrLatestReviewBody(prNumber, cwd) {
387
- const reviews = getPrReviews(prNumber, cwd).filter((r) => r.body.trim().length > 0).map((r) => ({ body: r.body, at: r.submittedAt }));
388
- const comments = getPrComments(prNumber, cwd).filter((c) => !KODY_COMMENT_PREFIXES.some((p) => c.body.startsWith(p))).map((c) => ({ body: c.body, at: c.createdAt }));
389
- const all = [...reviews, ...comments].sort((a, b) => (b.at || "").localeCompare(a.at || ""));
390
- if (all.length > 0) return all[0].body;
391
- const pr = getPr(prNumber, cwd);
392
- return pr.body;
393
- }
394
- function postPrReviewComment(prNumber, body, cwd) {
395
- try {
396
- gh(["pr", "comment", String(prNumber), "--body-file", "-"], { input: body, cwd });
397
- } catch (err) {
398
- process.stderr.write(`[kody2] failed to post review comment on PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}
399
- `);
400
- }
652
+ function formatMissesForFeedback(misses) {
653
+ if (misses.length === 0) return "";
654
+ const lines = ["The following files were added without a sibling test file:"];
655
+ for (const m of misses) lines.push(`- \`${m.file}\` \u2192 expected \`${m.expectedTest}\``);
656
+ lines.push("");
657
+ lines.push(
658
+ "Add the missing test files. Each should cover the new file's public API with at least a happy path and one failure path. Then re-emit DONE / COMMIT_MSG / PR_SUMMARY."
659
+ );
660
+ return lines.join("\n");
401
661
  }
402
662
 
403
- // src/branch.ts
404
- import { execFileSync as execFileSync2 } from "child_process";
405
- var UncommittedChangesError = class extends Error {
406
- constructor(branch) {
407
- super(`Uncommitted changes on branch '${branch}' \u2014 refusing to run to protect work in progress`);
408
- this.branch = branch;
409
- this.name = "UncommittedChangesError";
410
- }
411
- branch;
412
- };
413
- function git(args, cwd) {
414
- return execFileSync2("git", args, {
415
- encoding: "utf-8",
416
- timeout: 3e4,
417
- cwd,
418
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
419
- stdio: ["pipe", "pipe", "pipe"]
420
- }).trim();
421
- }
422
- function deriveBranchName(issueNumber, title) {
423
- const slug = title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 50).replace(/-$/, "");
424
- return slug ? `${issueNumber}-${slug}` : `${issueNumber}`;
425
- }
426
- function getCurrentBranch(cwd) {
427
- return git(["branch", "--show-current"], cwd);
428
- }
429
- function hasUncommittedChanges(cwd) {
430
- return git(["status", "--porcelain", "--untracked-files=no"], cwd).length > 0;
431
- }
432
- function checkoutPrBranch(prNumber, cwd) {
433
- const env = {
434
- ...process.env,
435
- HUSKY: "0",
436
- SKIP_HOOKS: "1",
437
- GH_TOKEN: process.env.GH_PAT?.trim() || process.env.GH_TOKEN || ""
438
- };
439
- execFileSync2("gh", ["pr", "checkout", String(prNumber)], {
440
- cwd,
441
- env,
442
- stdio: ["ignore", "pipe", "pipe"],
443
- timeout: 6e4
444
- });
445
- return getCurrentBranch(cwd);
446
- }
447
- function mergeBase(baseBranch, cwd) {
448
- try {
449
- git(["fetch", "origin", baseBranch], cwd);
450
- } catch {
451
- return "error";
452
- }
453
- try {
454
- git(["merge", `origin/${baseBranch}`, "--no-edit", "--no-ff"], cwd);
455
- return "clean";
456
- } catch {
457
- try {
458
- const unmerged = git(["diff", "--name-only", "--diff-filter=U"], cwd);
459
- if (unmerged.length > 0) return "conflict";
460
- } catch {
461
- }
663
+ // src/prompt.ts
664
+ import * as fs5 from "fs";
665
+ import * as path5 from "path";
666
+ var CONVENTIONS_PER_FILE_MAX_BYTES = 3e4;
667
+ var CONVENTION_FILES = ["CLAUDE.md", "AGENTS.md"];
668
+ function loadProjectConventions(projectDir) {
669
+ const out = [];
670
+ for (const rel of CONVENTION_FILES) {
671
+ const abs = path5.join(projectDir, rel);
672
+ if (!fs5.existsSync(abs)) continue;
673
+ let content;
462
674
  try {
463
- git(["merge", "--abort"], cwd);
675
+ content = fs5.readFileSync(abs, "utf-8");
464
676
  } catch {
677
+ continue;
465
678
  }
466
- return "error";
679
+ const truncated = content.length > CONVENTIONS_PER_FILE_MAX_BYTES;
680
+ if (truncated) content = `${content.slice(0, CONVENTIONS_PER_FILE_MAX_BYTES)}
681
+
682
+ \u2026 (truncated)`;
683
+ out.push({ path: rel, content, truncated });
467
684
  }
685
+ return out;
468
686
  }
469
- function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
470
- const branchName = deriveBranchName(issueNumber, title);
471
- const current = getCurrentBranch(cwd);
472
- if (current === branchName) {
473
- if (hasUncommittedChanges(cwd)) throw new UncommittedChangesError(branchName);
474
- return { branch: branchName, created: false };
475
- }
476
- if (hasUncommittedChanges(cwd)) throw new UncommittedChangesError(current || "(detached)");
477
- try {
478
- git(["fetch", "origin"], cwd);
479
- } catch {
480
- }
481
- try {
482
- git(["rev-parse", "--verify", `origin/${branchName}`], cwd);
483
- git(["checkout", branchName], cwd);
484
- try {
485
- git(["pull", "origin", branchName], cwd);
486
- } catch {
487
- }
488
- return { branch: branchName, created: false };
489
- } catch {
687
+ function parseAgentResult(finalText) {
688
+ const text = (finalText || "").trim();
689
+ if (!text) return { done: false, commitMessage: "", prSummary: "", failureReason: "agent produced no final message" };
690
+ const failedMatch = text.match(/(?:^|\n)\s*FAILED\s*:\s*(.+?)\s*$/s);
691
+ if (failedMatch) {
692
+ return { done: false, commitMessage: "", prSummary: "", failureReason: failedMatch[1].trim() };
490
693
  }
491
- try {
492
- git(["rev-parse", "--verify", branchName], cwd);
493
- git(["checkout", branchName], cwd);
494
- return { branch: branchName, created: false };
495
- } catch {
694
+ if (!/(^|\n)\s*DONE\b/i.test(text)) {
695
+ return { done: false, commitMessage: "", prSummary: "", failureReason: "no DONE or FAILED marker in agent output" };
496
696
  }
497
- try {
498
- git(["checkout", "-b", branchName, `origin/${defaultBranch}`], cwd);
499
- } catch {
500
- git(["checkout", "-b", branchName], cwd);
697
+ const commitMatch = text.match(/^[ \t]*COMMIT_MSG\s*:\s*(.+)$/im);
698
+ const commitMessage = commitMatch ? commitMatch[1].trim() : "";
699
+ const summaryStart = text.search(/(^|\n)[ \t]*PR_SUMMARY\s*:[ \t]*\n/i);
700
+ let prSummary = "";
701
+ if (summaryStart !== -1) {
702
+ const afterMarker = text.slice(summaryStart).replace(/^[\s\S]*?PR_SUMMARY\s*:[ \t]*\n/i, "");
703
+ prSummary = afterMarker.replace(/\n\s*```\s*$/g, "").replace(/```\s*$/g, "").trim();
501
704
  }
502
- return { branch: branchName, created: true };
705
+ return { done: true, commitMessage, prSummary, failureReason: "" };
503
706
  }
504
707
 
505
- // src/gha.ts
506
- import * as fs3 from "fs";
507
- import { execFileSync as execFileSync3 } from "child_process";
508
- function getRunUrl() {
509
- const server = process.env.GITHUB_SERVER_URL;
510
- const repo = process.env.GITHUB_REPOSITORY;
511
- const runId = process.env.GITHUB_RUN_ID;
512
- if (!server || !repo || !runId) return "";
513
- return `${server}/${repo}/actions/runs/${runId}`;
514
- }
515
- function reactToTriggerComment(cwd) {
516
- if (process.env.GITHUB_EVENT_NAME !== "issue_comment") return;
517
- const eventPath = process.env.GITHUB_EVENT_PATH;
518
- if (!eventPath || !fs3.existsSync(eventPath)) return;
519
- let event = null;
520
- try {
521
- event = JSON.parse(fs3.readFileSync(eventPath, "utf-8"));
522
- } catch {
708
+ // src/scripts/checkCoverageWithRetry.ts
709
+ var checkCoverageWithRetry = async (ctx) => {
710
+ const reqs = ctx.data.coverageRules ?? [];
711
+ if (reqs.length === 0) {
712
+ ctx.data.coverageMisses = [];
523
713
  return;
524
714
  }
525
- const commentId = event?.comment?.id;
526
- const repo = process.env.GITHUB_REPOSITORY;
527
- if (!commentId || !repo) return;
528
- const token = process.env.KODY_TOKEN?.trim() || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
529
- try {
530
- execFileSync3(
531
- "gh",
532
- [
533
- "api",
534
- "-X",
535
- "POST",
536
- "-H",
537
- "Accept: application/vnd.github+json",
538
- `/repos/${repo}/issues/comments/${commentId}/reactions`,
539
- "-f",
540
- "content=eyes"
541
- ],
542
- {
543
- cwd,
544
- env: { ...process.env, GH_TOKEN: token ?? process.env.GH_TOKEN ?? "" },
545
- stdio: "pipe",
546
- timeout: 15e3
547
- }
548
- );
549
- } catch {
715
+ if (!ctx.data.agentDone) {
716
+ ctx.data.coverageMisses = [];
717
+ return;
550
718
  }
551
- }
719
+ const misses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
720
+ if (misses.length === 0) {
721
+ ctx.data.coverageMisses = [];
722
+ return;
723
+ }
724
+ const invoker = ctx.data.__invokeAgent;
725
+ const basePrompt = ctx.data.prompt;
726
+ if (!invoker || !basePrompt) {
727
+ ctx.data.coverageMisses = misses;
728
+ return;
729
+ }
730
+ process.stderr.write(`[kody2] coverage check found ${misses.length} missing test(s); retrying agent once
731
+ `);
732
+ const retryPrompt = `${basePrompt}
552
733
 
553
- // src/scripts/runFlow.ts
554
- var runFlow = async (ctx) => {
555
- const issueNumber = ctx.args.issue;
556
- const issue = getIssue(issueNumber, ctx.cwd);
557
- ctx.data.issue = issue;
558
- ctx.data.commentTargetType = "issue";
559
- ctx.data.commentTargetNumber = issueNumber;
734
+ # Coverage failure (retry)
735
+ ${formatMissesForFeedback(misses)}`;
736
+ const retry = await invoker(retryPrompt);
737
+ const retryParsed = parseAgentResult(retry.finalText);
738
+ if (retry.outcome === "completed" && retryParsed.done) {
739
+ ctx.data.agentDone = true;
740
+ ctx.data.commitMessage = retryParsed.commitMessage || ctx.data.commitMessage;
741
+ ctx.data.prSummary = retryParsed.prSummary || ctx.data.prSummary;
742
+ }
743
+ const finalMisses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
744
+ ctx.data.coverageMisses = finalMisses;
745
+ };
746
+
747
+ // src/scripts/commitAndPush.ts
748
+ import { execFileSync as execFileSync4 } from "child_process";
749
+
750
+ // src/commit.ts
751
+ import { execFileSync as execFileSync3 } from "child_process";
752
+ import * as fs6 from "fs";
753
+ import * as path6 from "path";
754
+ var FORBIDDEN_PATH_PREFIXES = [
755
+ ".kody/",
756
+ ".kody-engine/",
757
+ ".kody2/",
758
+ ".kody-lean/",
759
+ // back-compat: stale runtime dir from kody-lean v0.5.x
760
+ "node_modules/",
761
+ "dist/",
762
+ "build/"
763
+ ];
764
+ var FORBIDDEN_PATH_EXACT = /* @__PURE__ */ new Set([".env"]);
765
+ var FORBIDDEN_PATH_SUFFIXES = [".log"];
766
+ var CONVENTIONAL_PREFIXES = [
767
+ "feat:",
768
+ "fix:",
769
+ "chore:",
770
+ "docs:",
771
+ "refactor:",
772
+ "test:",
773
+ "perf:",
774
+ "ci:",
775
+ "style:",
776
+ "build:",
777
+ "revert:"
778
+ ];
779
+ function git(args, cwd) {
560
780
  try {
561
- const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd);
562
- ctx.data.branch = branchInfo.branch;
781
+ return execFileSync3("git", args, {
782
+ encoding: "utf-8",
783
+ timeout: 12e4,
784
+ cwd,
785
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
786
+ stdio: ["pipe", "pipe", "pipe"]
787
+ }).trim();
563
788
  } catch (err) {
564
- if (err instanceof UncommittedChangesError) {
565
- ctx.output.exitCode = 5;
566
- ctx.output.reason = err.message;
567
- ctx.skipAgent = true;
568
- tryPost(issueNumber, `\u26A0\uFE0F kody2 refused to start: ${err.message}`, ctx.cwd);
569
- return;
570
- }
571
- throw err;
789
+ const e = err;
790
+ const stderr = e.stderr?.toString().trim() ?? "";
791
+ const stdout = e.stdout?.toString().trim() ?? "";
792
+ const status = e.status ?? "?";
793
+ const detail = stderr || stdout || e.message || "(no output)";
794
+ throw new Error(`git ${args.join(" ")} (exit ${status}):
795
+ ${detail}`);
572
796
  }
573
- const runUrl = getRunUrl();
574
- const startMsg = runUrl ? `\u2699\uFE0F kody2 started \u2014 branch \`${ctx.data.branch}\`, run ${runUrl}` : `\u2699\uFE0F kody2 started \u2014 branch \`${ctx.data.branch}\``;
575
- tryPost(issueNumber, startMsg, ctx.cwd);
576
- };
577
- function tryPost(issueNumber, body, cwd) {
797
+ }
798
+ function tryGit(args, cwd) {
578
799
  try {
579
- postIssueComment(issueNumber, body, cwd);
800
+ git(args, cwd);
801
+ return true;
580
802
  } catch {
803
+ return false;
581
804
  }
582
805
  }
583
-
584
- // src/scripts/fixFlow.ts
585
- var fixFlow = async (ctx) => {
586
- const prNumber = ctx.args.pr;
587
- const pr = getPr(prNumber, ctx.cwd);
588
- if (pr.state !== "OPEN") {
589
- ctx.output.exitCode = 1;
590
- ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
591
- ctx.skipAgent = true;
592
- return;
806
+ function abortUnfinishedGitOps(cwd) {
807
+ const aborted = [];
808
+ const gitDir = path6.join(cwd ?? process.cwd(), ".git");
809
+ if (!fs6.existsSync(gitDir)) return aborted;
810
+ if (fs6.existsSync(path6.join(gitDir, "MERGE_HEAD"))) {
811
+ if (tryGit(["merge", "--abort"], cwd)) aborted.push("merge");
593
812
  }
594
- ctx.data.pr = pr;
595
- ctx.data.commentTargetType = "pr";
596
- ctx.data.commentTargetNumber = prNumber;
597
- checkoutPrBranch(prNumber, ctx.cwd);
598
- ctx.data.branch = getCurrentBranch(ctx.cwd);
599
- const inlineFeedback = ctx.args.feedback?.trim();
600
- const feedback = inlineFeedback || getPrLatestReviewBody(prNumber, ctx.cwd);
601
- if (!feedback.trim()) {
602
- ctx.output.exitCode = 1;
603
- ctx.output.reason = "no --feedback provided and no review/body text found on PR";
604
- ctx.skipAgent = true;
605
- return;
813
+ if (fs6.existsSync(path6.join(gitDir, "CHERRY_PICK_HEAD"))) {
814
+ if (tryGit(["cherry-pick", "--abort"], cwd)) aborted.push("cherry-pick");
815
+ }
816
+ if (fs6.existsSync(path6.join(gitDir, "REVERT_HEAD"))) {
817
+ if (tryGit(["revert", "--abort"], cwd)) aborted.push("revert");
818
+ }
819
+ if (fs6.existsSync(path6.join(gitDir, "rebase-merge")) || fs6.existsSync(path6.join(gitDir, "rebase-apply"))) {
820
+ if (tryGit(["rebase", "--abort"], cwd)) aborted.push("rebase");
606
821
  }
607
- ctx.data.feedback = feedback;
608
- ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
609
- const runUrl = getRunUrl();
610
- const runSuffix = runUrl ? `, run ${runUrl}` : "";
611
- tryPostPr(
612
- prNumber,
613
- `\u2699\uFE0F kody2 fix started on \`${ctx.data.branch}\`${runSuffix} \u2014 applying feedback (${truncate(feedback.replace(/\n/g, " "), 200)})`,
614
- ctx.cwd
615
- );
616
- };
617
- function tryPostPr(prNumber, body, cwd) {
618
822
  try {
619
- postPrReviewComment(prNumber, body, cwd);
823
+ const unmerged = git(["diff", "--name-only", "--diff-filter=U"], cwd);
824
+ if (unmerged) {
825
+ tryGit(["reset", "--mixed", "HEAD"], cwd);
826
+ aborted.push("unmerged-paths-reset");
827
+ }
620
828
  } catch {
621
829
  }
830
+ return aborted;
622
831
  }
623
-
624
- // src/workflow.ts
625
- import { execFileSync as execFileSync4 } from "child_process";
626
- var GH_TIMEOUT_MS = 3e4;
627
- function ghToken2() {
628
- return process.env.GH_PAT?.trim() || process.env.GH_TOKEN;
832
+ function isForbiddenPath(p) {
833
+ if (FORBIDDEN_PATH_EXACT.has(p)) return true;
834
+ for (const pre of FORBIDDEN_PATH_PREFIXES) if (p.startsWith(pre)) return true;
835
+ for (const suf of FORBIDDEN_PATH_SUFFIXES) if (p.endsWith(suf)) return true;
836
+ return false;
629
837
  }
630
- function gh2(args, cwd) {
631
- const token = ghToken2();
632
- const env = token ? { ...process.env, GH_TOKEN: token } : { ...process.env };
633
- return execFileSync4("gh", args, {
838
+ function listChangedFiles(cwd) {
839
+ const raw = execFileSync3("git", ["status", "--porcelain=v1", "-z"], {
634
840
  encoding: "utf-8",
635
- timeout: GH_TIMEOUT_MS,
636
841
  cwd,
637
- env,
638
- stdio: ["ignore", "pipe", "pipe"]
639
- }).trim();
842
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
843
+ stdio: ["pipe", "pipe", "pipe"]
844
+ });
845
+ if (!raw) return [];
846
+ const entries = raw.split("\0").filter((e) => e.length > 0);
847
+ return entries.map((e) => e.slice(3)).filter(Boolean);
640
848
  }
641
- function getLatestFailedRunForPr(prNumber, cwd) {
642
- let headBranch;
849
+ function normalizeCommitMessage(raw) {
850
+ const trimmed = raw.trim().replace(/^['"]|['"]$/g, "").trim();
851
+ if (!trimmed) return "chore: kody2 update";
852
+ const firstLine2 = trimmed.split("\n")[0];
853
+ for (const prefix of CONVENTIONAL_PREFIXES) {
854
+ if (firstLine2.toLowerCase().startsWith(prefix)) return trimmed;
855
+ }
856
+ return `chore: ${trimmed}`;
857
+ }
858
+ function commitAndPush(branch, agentMessage, cwd) {
859
+ const allChanged = listChangedFiles(cwd);
860
+ const allowedFiles = allChanged.filter((f) => !isForbiddenPath(f));
861
+ const mergeHeadExists = fs6.existsSync(path6.join(cwd ?? process.cwd(), ".git", "MERGE_HEAD"));
862
+ if (allowedFiles.length === 0 && !mergeHeadExists) {
863
+ return { committed: false, pushed: false, sha: "", message: "" };
864
+ }
865
+ for (const f of allowedFiles) {
866
+ try {
867
+ git(["add", "--", f], cwd);
868
+ } catch {
869
+ }
870
+ }
871
+ const message = normalizeCommitMessage(agentMessage);
643
872
  try {
644
- const out = gh2(["pr", "view", String(prNumber), "--json", "headRefName"], cwd);
645
- headBranch = JSON.parse(out).headRefName;
873
+ git(["commit", "--no-gpg-sign", "-m", message], cwd);
874
+ } catch (err) {
875
+ const msg = err instanceof Error ? err.message : String(err);
876
+ if (/nothing to commit/i.test(msg)) {
877
+ return { committed: false, pushed: false, sha: "", message };
878
+ }
879
+ throw err;
880
+ }
881
+ const sha = git(["rev-parse", "HEAD"], cwd).slice(0, 7);
882
+ try {
883
+ git(["push", "-u", "origin", branch], cwd);
646
884
  } catch {
647
- return null;
885
+ git(["push", "--force-with-lease", "-u", "origin", branch], cwd);
648
886
  }
649
- if (!headBranch) return null;
887
+ return { committed: true, pushed: true, sha, message };
888
+ }
889
+ function hasCommitsAhead(branch, defaultBranch, cwd) {
650
890
  try {
651
- const out = gh2(
652
- [
653
- "run",
654
- "list",
655
- "--branch",
656
- headBranch,
657
- "--status",
658
- "failure",
659
- "--limit",
660
- "1",
661
- "--json",
662
- "databaseId,workflowName,headBranch,conclusion,url,createdAt"
663
- ],
664
- cwd
665
- );
666
- const parsed = JSON.parse(out);
667
- if (!Array.isArray(parsed) || parsed.length === 0) return null;
668
- const r = parsed[0];
669
- return {
670
- id: String(r.databaseId ?? ""),
671
- workflowName: r.workflowName ?? "",
672
- headBranch: r.headBranch ?? headBranch,
673
- conclusion: r.conclusion ?? "failure",
674
- url: r.url ?? "",
675
- createdAt: r.createdAt ?? ""
676
- };
677
- } catch {
678
- return null;
679
- }
680
- }
681
- function getFailedRunLogTail(runId, maxBytes, cwd) {
682
- try {
683
- const raw = gh2(["run", "view", String(runId), "--log-failed"], cwd);
684
- if (raw.length <= maxBytes) return raw;
685
- return raw.slice(-maxBytes);
891
+ const out = git(["rev-list", "--count", `origin/${defaultBranch}..${branch}`], cwd);
892
+ return parseInt(out, 10) > 0;
686
893
  } catch {
687
- return "";
688
- }
689
- }
690
-
691
- // src/scripts/fixCiFlow.ts
692
- var LOG_MAX_BYTES = 3e4;
693
- var fixCiFlow = async (ctx) => {
694
- const prNumber = ctx.args.pr;
695
- const pr = getPr(prNumber, ctx.cwd);
696
- if (pr.state !== "OPEN") {
697
- ctx.output.exitCode = 1;
698
- ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
699
- ctx.skipAgent = true;
700
- return;
701
- }
702
- ctx.data.pr = pr;
703
- ctx.data.commentTargetType = "pr";
704
- ctx.data.commentTargetNumber = prNumber;
705
- checkoutPrBranch(prNumber, ctx.cwd);
706
- ctx.data.branch = getCurrentBranch(ctx.cwd);
707
- let runId = ctx.args.runId;
708
- let workflowName = "";
709
- let failedRunUrl = "";
710
- if (!runId) {
711
- const run = getLatestFailedRunForPr(prNumber, ctx.cwd);
712
- if (!run) {
713
- ctx.output.exitCode = 1;
714
- ctx.output.reason = `no failed workflow run found on PR #${prNumber}'s branch`;
715
- ctx.skipAgent = true;
716
- return;
894
+ try {
895
+ const out = git(["rev-list", "--count", `${defaultBranch}..${branch}`], cwd);
896
+ return parseInt(out, 10) > 0;
897
+ } catch {
898
+ return false;
717
899
  }
718
- runId = run.id;
719
- workflowName = run.workflowName;
720
- failedRunUrl = run.url;
721
- }
722
- const logTail = getFailedRunLogTail(runId, LOG_MAX_BYTES, ctx.cwd);
723
- if (!logTail) {
724
- ctx.output.exitCode = 1;
725
- ctx.output.reason = `failed to fetch log tail for run ${runId}`;
726
- ctx.skipAgent = true;
727
- return;
728
- }
729
- ctx.data.failedRunId = runId;
730
- ctx.data.failedWorkflowName = workflowName;
731
- ctx.data.failedRunUrl = failedRunUrl;
732
- ctx.data.failedLogTail = logTail;
733
- ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
734
- const runUrl = getRunUrl();
735
- const runSuffix = runUrl ? `, kody2 run ${runUrl}` : "";
736
- tryPostPr2(prNumber, `\u2699\uFE0F kody2 fix-ci started on \`${ctx.data.branch}\`${runSuffix} \u2014 analyzing workflow run ${runId}`, ctx.cwd);
737
- };
738
- function tryPostPr2(prNumber, body, cwd) {
739
- try {
740
- postPrReviewComment(prNumber, body, cwd);
741
- } catch {
742
900
  }
743
901
  }
744
902
 
745
- // src/scripts/resolveFlow.ts
746
- import { execFileSync as execFileSync5 } from "child_process";
747
- var CONFLICT_DIFF_MAX_BYTES = 4e4;
748
- var resolveFlow = async (ctx) => {
749
- const prNumber = ctx.args.pr;
750
- const pr = getPr(prNumber, ctx.cwd);
751
- if (pr.state !== "OPEN") {
752
- ctx.output.exitCode = 1;
753
- ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
754
- ctx.skipAgent = true;
755
- return;
756
- }
757
- ctx.data.pr = pr;
758
- ctx.data.commentTargetType = "pr";
759
- ctx.data.commentTargetNumber = prNumber;
760
- checkoutPrBranch(prNumber, ctx.cwd);
761
- ctx.data.branch = getCurrentBranch(ctx.cwd);
762
- const baseBranch = pr.baseRefName || ctx.config.git.defaultBranch;
763
- ctx.data.baseBranch = baseBranch;
764
- const mergeStatus = mergeBase(baseBranch, ctx.cwd);
765
- if (mergeStatus === "clean") {
766
- ctx.output.exitCode = 0;
767
- ctx.output.reason = `already up to date with origin/${baseBranch} \u2014 nothing to resolve`;
768
- ctx.skipAgent = true;
769
- tryPostPr3(prNumber, `\u2139\uFE0F kody2 resolve: ${ctx.output.reason}`, ctx.cwd);
770
- return;
771
- }
772
- if (mergeStatus === "error") {
773
- ctx.output.exitCode = 99;
774
- ctx.output.reason = `failed to merge origin/${baseBranch} (non-conflict error); see runner log`;
775
- ctx.skipAgent = true;
776
- tryPostPr3(prNumber, `\u26A0\uFE0F kody2 resolve FAILED: ${ctx.output.reason}`, ctx.cwd);
777
- return;
778
- }
779
- const conflictedFiles = getConflictedFiles(ctx.cwd);
780
- if (conflictedFiles.length === 0) {
781
- ctx.output.exitCode = 99;
782
- ctx.output.reason = "merge reported conflict but no unmerged paths detected";
783
- ctx.skipAgent = true;
903
+ // src/scripts/commitAndPush.ts
904
+ var commitAndPush2 = async (ctx) => {
905
+ const branch = ctx.data.branch;
906
+ if (!branch) {
907
+ ctx.data.commitResult = { committed: false, pushed: false };
784
908
  return;
785
909
  }
786
- ctx.data.conflictedFiles = conflictedFiles;
787
- ctx.data.conflictMarkersPreview = getConflictMarkersPreview(conflictedFiles, ctx.cwd);
788
- const runUrl = getRunUrl();
789
- const runSuffix = runUrl ? `, run ${runUrl}` : "";
790
- tryPostPr3(prNumber, `\u2699\uFE0F kody2 resolve started on \`${ctx.data.branch}\`${runSuffix} \u2014 ${conflictedFiles.length} conflicted file(s)`, ctx.cwd);
791
- };
792
- function getConflictedFiles(cwd) {
793
- try {
794
- const out = execFileSync5("git", ["diff", "--name-only", "--diff-filter=U"], {
795
- encoding: "utf-8",
796
- cwd,
797
- env: { ...process.env, HUSKY: "0" }
798
- }).trim();
799
- return out ? out.split("\n").filter(Boolean) : [];
800
- } catch {
801
- return [];
802
- }
803
- }
804
- function getConflictMarkersPreview(files, cwd, maxBytes = CONFLICT_DIFF_MAX_BYTES) {
805
- const chunks = [];
806
- let total = 0;
807
- for (const f of files) {
910
+ if (ctx.args.mode === "resolve") {
808
911
  try {
809
- const content = execFileSync5("cat", [f], { encoding: "utf-8", cwd }).toString();
810
- const snippet = `### ${f}
811
-
812
- \`\`\`
813
- ${content.slice(0, 6e3)}
814
- \`\`\`
815
- `;
816
- total += snippet.length;
817
- chunks.push(snippet);
818
- if (total >= maxBytes) break;
912
+ execFileSync4("git", ["add", "-A"], { cwd: ctx.cwd, env: { ...process.env, HUSKY: "0" }, stdio: "pipe" });
819
913
  } catch {
820
914
  }
821
- }
822
- return chunks.join("\n");
823
- }
824
- function tryPostPr3(prNumber, body, cwd) {
825
- try {
826
- postPrReviewComment(prNumber, body, cwd);
827
- } catch {
828
- }
829
- }
830
-
831
- // src/prompt.ts
832
- import * as fs4 from "fs";
833
- import * as path3 from "path";
834
- var CONVENTIONS_PER_FILE_MAX_BYTES = 3e4;
835
- var CONVENTION_FILES = ["CLAUDE.md", "AGENTS.md"];
836
- function loadProjectConventions(projectDir) {
837
- const out = [];
838
- for (const rel of CONVENTION_FILES) {
839
- const abs = path3.join(projectDir, rel);
840
- if (!fs4.existsSync(abs)) continue;
841
- let content;
842
- try {
843
- content = fs4.readFileSync(abs, "utf-8");
844
- } catch {
845
- continue;
915
+ } else {
916
+ const aborted = abortUnfinishedGitOps(ctx.cwd);
917
+ if (aborted.length > 0) {
918
+ process.stderr.write(`[kody2] cleaned up unfinished git ops: ${aborted.join(", ")}
919
+ `);
846
920
  }
847
- const truncated = content.length > CONVENTIONS_PER_FILE_MAX_BYTES;
848
- if (truncated) content = content.slice(0, CONVENTIONS_PER_FILE_MAX_BYTES) + "\n\n\u2026 (truncated)";
849
- out.push({ path: rel, content, truncated });
850
- }
851
- return out;
852
- }
853
- function parseAgentResult(finalText) {
854
- const text = (finalText || "").trim();
855
- if (!text) return { done: false, commitMessage: "", prSummary: "", failureReason: "agent produced no final message" };
856
- const failedMatch = text.match(/(?:^|\n)\s*FAILED\s*:\s*(.+?)\s*$/s);
857
- if (failedMatch) {
858
- return { done: false, commitMessage: "", prSummary: "", failureReason: failedMatch[1].trim() };
859
921
  }
860
- if (!/(^|\n)\s*DONE\b/i.test(text)) {
861
- return { done: false, commitMessage: "", prSummary: "", failureReason: "no DONE or FAILED marker in agent output" };
922
+ const fallbackMsg = defaultCommitMessage(ctx.args.mode, ctx.data);
923
+ const message = ctx.data.commitMessage || fallbackMsg;
924
+ try {
925
+ const result = commitAndPush(branch, message, ctx.cwd);
926
+ ctx.data.commitResult = result;
927
+ ctx.data.changedFiles = listChangedFiles(ctx.cwd).filter((f) => !isForbiddenPath(f));
928
+ } catch (err) {
929
+ ctx.data.commitCrash = err instanceof Error ? err.message : String(err);
930
+ ctx.data.commitResult = { committed: false, pushed: false };
862
931
  }
863
- const commitMatch = text.match(/^[ \t]*COMMIT_MSG\s*:\s*(.+)$/im);
864
- const commitMessage = commitMatch ? commitMatch[1].trim() : "";
865
- const summaryStart = text.search(/(^|\n)[ \t]*PR_SUMMARY\s*:[ \t]*\n/i);
866
- let prSummary = "";
867
- if (summaryStart !== -1) {
868
- const afterMarker = text.slice(summaryStart).replace(/^[\s\S]*?PR_SUMMARY\s*:[ \t]*\n/i, "");
869
- prSummary = afterMarker.replace(/\n\s*```\s*$/g, "").replace(/```\s*$/g, "").trim();
932
+ ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
933
+ };
934
+ function defaultCommitMessage(mode, data) {
935
+ switch (mode) {
936
+ case "run":
937
+ return `chore: kody2 changes for #${data.commentTargetNumber}`;
938
+ case "fix":
939
+ return `chore(fix): kody2 fix for PR #${data.commentTargetNumber}`;
940
+ case "fix-ci":
941
+ return `fix(ci): kody2 fix-ci for PR #${data.commentTargetNumber}`;
942
+ case "resolve":
943
+ return `fix: resolve merge conflicts with ${data.baseBranch}`;
944
+ default:
945
+ return `chore: kody2 changes`;
870
946
  }
871
- return { done: true, commitMessage, prSummary, failureReason: "" };
872
947
  }
873
948
 
874
- // src/scripts/loadConventions.ts
875
- var loadConventions = async (ctx) => {
876
- const conventions = loadProjectConventions(ctx.cwd);
877
- ctx.data.conventions = conventions;
878
- if (conventions.length > 0) {
879
- process.stderr.write(`[kody2] loaded conventions: ${conventions.map((c) => c.path).join(", ")}
880
- `);
881
- }
882
- };
883
-
884
- // src/scripts/loadCoverageRules.ts
885
- var loadCoverageRules = async (ctx) => {
886
- ctx.data.coverageRules = ctx.config.testRequirements ?? [];
887
- };
888
-
889
949
  // src/scripts/composePrompt.ts
890
- import * as fs5 from "fs";
891
- import * as path4 from "path";
950
+ import * as fs7 from "fs";
951
+ import * as path7 from "path";
892
952
  var MUSTACHE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
893
953
  var composePrompt = async (ctx, profile) => {
894
954
  const explicit = ctx.data.promptTemplate;
895
955
  const mode = ctx.args.mode;
896
956
  const candidates = [
897
- explicit ? path4.join(profile.dir, explicit) : null,
898
- mode ? path4.join(profile.dir, "prompts", `${mode}.md`) : null,
899
- path4.join(profile.dir, "prompt.md")
957
+ explicit ? path7.join(profile.dir, explicit) : null,
958
+ mode ? path7.join(profile.dir, "prompts", `${mode}.md`) : null,
959
+ path7.join(profile.dir, "prompt.md")
900
960
  ].filter(Boolean);
901
961
  let templatePath = "";
902
962
  for (const c of candidates) {
903
- if (fs5.existsSync(c)) {
963
+ if (fs7.existsSync(c)) {
904
964
  templatePath = c;
905
965
  break;
906
966
  }
@@ -908,18 +968,20 @@ var composePrompt = async (ctx, profile) => {
908
968
  if (!templatePath) {
909
969
  throw new Error(`profile at ${profile.dir}: no prompt template found (tried ${candidates.join(", ")})`);
910
970
  }
911
- const template = fs5.readFileSync(templatePath, "utf-8");
971
+ const template = fs7.readFileSync(templatePath, "utf-8");
912
972
  const tokens = {
913
973
  ...stringifyAll(ctx.args, "args."),
914
974
  ...stringifyAll(ctx.data, ""),
915
- "conventionsBlock": formatConventions(ctx.data.conventions),
916
- "coverageBlock": formatCoverageBlock(ctx.data.coverageRules),
917
- "toolsUsage": formatToolsUsage(profile),
918
- "systemPromptAppend": profile.claudeCode.systemPromptAppend ?? "",
919
- "repoOwner": ctx.config.github.owner,
920
- "repoName": ctx.config.github.repo,
921
- "defaultBranch": ctx.config.git.defaultBranch,
922
- "branch": ctx.data.branch ?? ""
975
+ conventionsBlock: formatConventions(ctx.data.conventions),
976
+ coverageBlock: formatCoverageBlock(
977
+ ctx.data.coverageRules
978
+ ),
979
+ toolsUsage: formatToolsUsage(profile),
980
+ systemPromptAppend: profile.claudeCode.systemPromptAppend ?? "",
981
+ repoOwner: ctx.config.github.owner,
982
+ repoName: ctx.config.github.repo,
983
+ defaultBranch: ctx.config.git.defaultBranch,
984
+ branch: ctx.data.branch ?? ""
923
985
  };
924
986
  ctx.data.prompt = template.replace(MUSTACHE, (_, key) => tokens[key] ?? "");
925
987
  };
@@ -944,10 +1006,7 @@ function stringifyAll(source, prefix) {
944
1006
  }
945
1007
  function formatConventions(conventions) {
946
1008
  if (!conventions || conventions.length === 0) return "";
947
- const lines = [
948
- "# Project conventions (AUTHORITATIVE \u2014 follow these over patterns you infer from code)",
949
- ""
950
- ];
1009
+ const lines = ["# Project conventions (AUTHORITATIVE \u2014 follow these over patterns you infer from code)", ""];
951
1010
  for (const c of conventions) {
952
1011
  lines.push(`## ${c.path}${c.truncated ? " (truncated)" : ""}`);
953
1012
  lines.push("");
@@ -973,452 +1032,188 @@ function formatCoverageBlock(reqs) {
973
1032
  function formatToolsUsage(profile) {
974
1033
  const entries = (profile.cliTools ?? []).filter((t) => t.usage.trim().length > 0);
975
1034
  if (entries.length === 0) return "";
976
- const lines = [
977
- "# Available CLI tools",
978
- ""
979
- ];
1035
+ const lines = ["# Available CLI tools", ""];
980
1036
  for (const t of entries) {
981
1037
  lines.push(`## \`${t.name}\``);
982
1038
  lines.push(t.usage);
983
1039
  if (t.allowedUses.length > 0) {
984
- lines.push(`Allowed sub-commands: ${t.allowedUses.map((u) => "`" + u + "`").join(", ")}`);
1040
+ lines.push(`Allowed sub-commands: ${t.allowedUses.map((u) => `\`${u}\``).join(", ")}`);
985
1041
  }
986
1042
  lines.push("");
987
1043
  }
988
1044
  return lines.join("\n");
989
1045
  }
990
1046
 
991
- // src/scripts/parseAgentResult.ts
992
- var parseAgentResult2 = async (ctx, _profile, agentResult) => {
993
- if (!agentResult) {
994
- ctx.data.agentDone = false;
995
- return;
996
- }
997
- const parsed = parseAgentResult(agentResult.finalText);
998
- ctx.data.agentDone = parsed.done;
999
- ctx.data.commitMessage = parsed.commitMessage;
1000
- ctx.data.prSummary = parsed.prSummary;
1001
- ctx.data.agentFailureReason = parsed.failureReason;
1002
- ctx.data.agentOutcome = agentResult.outcome;
1003
- ctx.data.agentError = agentResult.error;
1004
- };
1005
-
1006
- // src/verify.ts
1007
- import { spawn } from "child_process";
1008
- var TAIL_CHARS = 4e3;
1009
- var COMMAND_TIMEOUT_MS = 10 * 60 * 1e3;
1010
- function runCommand(command, cwd) {
1011
- return new Promise((resolve2) => {
1012
- const start = Date.now();
1013
- const child = spawn(command, {
1014
- cwd,
1015
- shell: true,
1016
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1", CI: process.env.CI ?? "1" },
1017
- stdio: ["ignore", "pipe", "pipe"]
1018
- });
1019
- const buffers = [];
1020
- let totalSize = 0;
1021
- const collect = (chunk) => {
1022
- buffers.push(chunk);
1023
- totalSize += chunk.length;
1024
- while (totalSize > TAIL_CHARS * 4 && buffers.length > 1) {
1025
- totalSize -= buffers[0].length;
1026
- buffers.shift();
1027
- }
1028
- };
1029
- child.stdout?.on("data", collect);
1030
- child.stderr?.on("data", collect);
1031
- const timer = setTimeout(() => {
1032
- child.kill("SIGTERM");
1033
- setTimeout(() => {
1034
- if (!child.killed) child.kill("SIGKILL");
1035
- }, 5e3);
1036
- }, COMMAND_TIMEOUT_MS);
1037
- child.on("exit", (code) => {
1038
- clearTimeout(timer);
1039
- const tail = Buffer.concat(buffers).toString("utf-8").slice(-TAIL_CHARS);
1040
- resolve2({ exitCode: code ?? -1, durationMs: Date.now() - start, tail });
1041
- });
1042
- child.on("error", (err) => {
1043
- clearTimeout(timer);
1044
- resolve2({ exitCode: -1, durationMs: Date.now() - start, tail: err.message });
1045
- });
1046
- });
1047
+ // src/issue.ts
1048
+ import { execFileSync as execFileSync5 } from "child_process";
1049
+ var API_TIMEOUT_MS = 3e4;
1050
+ function ghToken() {
1051
+ return process.env.GH_PAT?.trim() || process.env.GH_TOKEN;
1047
1052
  }
1048
- async function verifyAll(config, cwd) {
1049
- const commands = [];
1050
- if (config.quality.typecheck) commands.push({ name: "typecheck", cmd: config.quality.typecheck });
1051
- if (config.quality.testUnit) commands.push({ name: "test", cmd: config.quality.testUnit });
1052
- if (config.quality.lint) commands.push({ name: "lint", cmd: config.quality.lint });
1053
- const failed = [];
1054
- const details = {};
1055
- for (const { name, cmd } of commands) {
1056
- const result = await runCommand(cmd, cwd);
1057
- details[name] = result;
1058
- if (result.exitCode !== 0) failed.push(name);
1053
+ function gh(args, options) {
1054
+ const token = ghToken();
1055
+ const env = token ? { ...process.env, GH_TOKEN: token } : { ...process.env };
1056
+ return execFileSync5("gh", args, {
1057
+ encoding: "utf-8",
1058
+ timeout: API_TIMEOUT_MS,
1059
+ cwd: options?.cwd,
1060
+ env,
1061
+ input: options?.input,
1062
+ stdio: options?.input ? ["pipe", "pipe", "pipe"] : ["inherit", "pipe", "pipe"]
1063
+ }).trim();
1064
+ }
1065
+ function getIssue(issueNumber, cwd) {
1066
+ const output = gh(["issue", "view", String(issueNumber), "--json", "number,title,body,comments"], { cwd });
1067
+ const parsed = JSON.parse(output);
1068
+ if (typeof parsed?.title !== "string") {
1069
+ throw new Error(`Issue #${issueNumber}: unexpected response shape`);
1059
1070
  }
1060
- return { ok: failed.length === 0, failed, details };
1071
+ return {
1072
+ number: parsed.number ?? issueNumber,
1073
+ title: parsed.title,
1074
+ body: parsed.body ?? "",
1075
+ comments: (parsed.comments ?? []).map((c) => ({
1076
+ body: c.body ?? "",
1077
+ author: c.author?.login ?? "unknown",
1078
+ createdAt: c.createdAt ?? ""
1079
+ }))
1080
+ };
1061
1081
  }
1062
- var ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
1063
- function stripAnsi(s) {
1064
- return s.replace(ANSI_RE, "");
1082
+ function postIssueComment(issueNumber, body, cwd) {
1083
+ try {
1084
+ gh(["issue", "comment", String(issueNumber), "--body-file", "-"], { input: body, cwd });
1085
+ } catch (err) {
1086
+ process.stderr.write(
1087
+ `[kody2] failed to post comment on #${issueNumber}: ${err instanceof Error ? err.message : String(err)}
1088
+ `
1089
+ );
1090
+ }
1065
1091
  }
1066
- function summarizeFailure(result) {
1067
- const lines = [`verify failed: ${result.failed.join(", ")}`];
1068
- for (const name of result.failed) {
1069
- const d = result.details[name];
1070
- if (!d) continue;
1071
- lines.push(`
1072
- --- ${name} (exit ${d.exitCode}, ${(d.durationMs / 1e3).toFixed(1)}s) ---`);
1073
- lines.push(stripAnsi(d.tail));
1092
+ function truncate2(s, maxBytes) {
1093
+ if (s.length <= maxBytes) return s;
1094
+ return `${s.slice(0, maxBytes)}\u2026 (+${s.length - maxBytes} chars)`;
1095
+ }
1096
+ function getPr(prNumber, cwd) {
1097
+ const output = gh(["pr", "view", String(prNumber), "--json", "number,title,body,headRefName,baseRefName,state"], {
1098
+ cwd
1099
+ });
1100
+ const parsed = JSON.parse(output);
1101
+ if (typeof parsed?.title !== "string") {
1102
+ throw new Error(`PR #${prNumber}: unexpected response shape`);
1074
1103
  }
1075
- return lines.join("\n");
1104
+ return {
1105
+ number: parsed.number ?? prNumber,
1106
+ title: parsed.title,
1107
+ body: parsed.body ?? "",
1108
+ headRefName: String(parsed.headRefName ?? ""),
1109
+ baseRefName: String(parsed.baseRefName ?? ""),
1110
+ state: String(parsed.state ?? "")
1111
+ };
1076
1112
  }
1077
-
1078
- // src/scripts/verify.ts
1079
- var verify = async (ctx) => {
1113
+ function getPrDiff(prNumber, cwd) {
1080
1114
  try {
1081
- const result = await verifyAll(ctx.config, ctx.cwd);
1082
- ctx.data.verifyOk = result.ok;
1083
- ctx.data.verifyReason = result.ok ? "" : summarizeFailure(result);
1115
+ return gh(["pr", "diff", String(prNumber)], { cwd });
1084
1116
  } catch (err) {
1085
- ctx.data.verifyOk = false;
1086
- ctx.data.verifyReason = `verify crashed: ${err instanceof Error ? err.message : String(err)}`;
1117
+ process.stderr.write(
1118
+ `[kody2] failed to fetch diff for PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}
1119
+ `
1120
+ );
1121
+ return "";
1087
1122
  }
1088
- };
1089
-
1090
- // src/coverage.ts
1091
- import { execFileSync as execFileSync6 } from "child_process";
1092
- function patternToRegex(pattern) {
1093
- let s = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1094
- s = s.replace(/\*\*\//g, "\xA7S").replace(/\*\*/g, "\xA7A").replace(/\*/g, "[^/]*");
1095
- s = s.replace(/§S/g, "(?:.*/)?").replace(/§A/g, ".*");
1096
- return new RegExp(`^${s}$`);
1097
1123
  }
1098
- function renderSiblingPath(file, requireSibling) {
1099
- const lastSlash = file.lastIndexOf("/");
1100
- const dir = lastSlash === -1 ? "" : file.slice(0, lastSlash + 1);
1101
- const base = lastSlash === -1 ? file : file.slice(lastSlash + 1);
1102
- const name = base.replace(/\.[^.]+$/, "");
1103
- const ext = base.match(/\.[^.]+$/)?.[0] ?? "";
1104
- const sibling = requireSibling.replace(/\{name\}/g, name).replace(/\{ext\}/g, ext);
1105
- return dir + sibling;
1124
+ function getPrReviews(prNumber, cwd) {
1125
+ try {
1126
+ const output = gh(["pr", "view", String(prNumber), "--json", "reviews"], { cwd });
1127
+ const parsed = JSON.parse(output);
1128
+ if (!Array.isArray(parsed?.reviews)) return [];
1129
+ return parsed.reviews.map(
1130
+ (r) => ({
1131
+ body: r.body ?? "",
1132
+ state: r.state ?? "",
1133
+ author: r.author?.login ?? "unknown",
1134
+ submittedAt: r.submittedAt ?? ""
1135
+ })
1136
+ );
1137
+ } catch {
1138
+ return [];
1139
+ }
1106
1140
  }
1107
- function safeGit(args, cwd) {
1141
+ function getPrComments(prNumber, cwd) {
1108
1142
  try {
1109
- return execFileSync6("git", args, { encoding: "utf-8", cwd, env: { ...process.env, HUSKY: "0" } }).trim();
1143
+ const output = gh(["pr", "view", String(prNumber), "--json", "comments"], { cwd });
1144
+ const parsed = JSON.parse(output);
1145
+ if (!Array.isArray(parsed?.comments)) return [];
1146
+ return parsed.comments.map((c) => ({
1147
+ body: c.body ?? "",
1148
+ author: c.author?.login ?? "unknown",
1149
+ createdAt: c.createdAt ?? ""
1150
+ })).filter((c) => c.body.trim().length > 0);
1110
1151
  } catch {
1111
- return "";
1152
+ return [];
1112
1153
  }
1113
1154
  }
1114
- function getAddedFiles(baseBranch, cwd) {
1115
- const committed = safeGit(["diff", "--name-only", "--diff-filter=A", `origin/${baseBranch}...HEAD`], cwd);
1116
- const untracked = safeGit(["ls-files", "--others", "--exclude-standard"], cwd);
1117
- const set = /* @__PURE__ */ new Set();
1118
- for (const f of committed.split("\n")) if (f) set.add(f);
1119
- for (const f of untracked.split("\n")) if (f) set.add(f);
1120
- return [...set];
1155
+ var KODY_COMMENT_PREFIXES = ["\u2699\uFE0F kody2", "\u2705 kody2", "\u26A0\uFE0F kody2", "\u2139\uFE0F kody2", "\u2192 kody2"];
1156
+ function getPrLatestReviewBody(prNumber, cwd) {
1157
+ const reviews = getPrReviews(prNumber, cwd).filter((r) => r.body.trim().length > 0).map((r) => ({ body: r.body, at: r.submittedAt }));
1158
+ const comments = getPrComments(prNumber, cwd).filter((c) => !KODY_COMMENT_PREFIXES.some((p) => c.body.startsWith(p))).map((c) => ({ body: c.body, at: c.createdAt }));
1159
+ const all = [...reviews, ...comments].sort((a, b) => (b.at || "").localeCompare(a.at || ""));
1160
+ if (all.length > 0) return all[0].body;
1161
+ const pr = getPr(prNumber, cwd);
1162
+ return pr.body;
1121
1163
  }
1122
- function checkCoverage(addedFiles, requirements) {
1123
- if (requirements.length === 0) return [];
1124
- const addedSet = new Set(addedFiles);
1125
- const misses = [];
1126
- for (const file of addedFiles) {
1127
- if (/\.(test|spec)\./.test(file)) continue;
1128
- for (const req of requirements) {
1129
- const re = patternToRegex(req.pattern);
1130
- if (!re.test(file)) continue;
1131
- const expected = renderSiblingPath(file, req.requireSibling);
1132
- if (!addedSet.has(expected)) {
1133
- misses.push({ file, expectedTest: expected });
1134
- }
1135
- break;
1136
- }
1164
+ function postPrReviewComment(prNumber, body, cwd) {
1165
+ try {
1166
+ gh(["pr", "comment", String(prNumber), "--body-file", "-"], { input: body, cwd });
1167
+ } catch (err) {
1168
+ process.stderr.write(
1169
+ `[kody2] failed to post review comment on PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}
1170
+ `
1171
+ );
1137
1172
  }
1138
- return misses;
1139
- }
1140
- function formatMissesForFeedback(misses) {
1141
- if (misses.length === 0) return "";
1142
- const lines = ["The following files were added without a sibling test file:"];
1143
- for (const m of misses) lines.push(`- \`${m.file}\` \u2192 expected \`${m.expectedTest}\``);
1144
- lines.push("");
1145
- lines.push("Add the missing test files. Each should cover the new file's public API with at least a happy path and one failure path. Then re-emit DONE / COMMIT_MSG / PR_SUMMARY.");
1146
- return lines.join("\n");
1147
1173
  }
1148
1174
 
1149
- // src/scripts/checkCoverageWithRetry.ts
1150
- var checkCoverageWithRetry = async (ctx) => {
1151
- const reqs = ctx.data.coverageRules ?? [];
1152
- if (reqs.length === 0) {
1153
- ctx.data.coverageMisses = [];
1154
- return;
1175
+ // src/pr.ts
1176
+ var TITLE_MAX = 72;
1177
+ function stripTitlePrefixes(raw) {
1178
+ let s = raw.trim();
1179
+ while (true) {
1180
+ const next = s.replace(/^(\[WIP\]\s*)?#\d+:\s*/, "");
1181
+ if (next === s) break;
1182
+ s = next;
1155
1183
  }
1156
- if (!ctx.data.agentDone) {
1157
- ctx.data.coverageMisses = [];
1158
- return;
1184
+ return s;
1185
+ }
1186
+ function buildPrTitle(issueNumber, issueTitle, draft) {
1187
+ const prefix = draft ? "[WIP] " : "";
1188
+ const clean = stripTitlePrefixes(issueTitle);
1189
+ const base = `${prefix}#${issueNumber}: ${clean}`;
1190
+ if (base.length <= TITLE_MAX) return base;
1191
+ return `${base.slice(0, TITLE_MAX - 1)}\u2026`;
1192
+ }
1193
+ function buildPrBody(opts) {
1194
+ const lines = [];
1195
+ if (opts.draft && opts.failureReason) {
1196
+ const headline = firstLine(opts.failureReason);
1197
+ lines.push(`> \u26A0\uFE0F Draft: ${headline}`);
1198
+ lines.push(`> The failures below may be **pre-existing in the repo** \u2014 verify before treating as PR-blocking.`);
1199
+ lines.push("");
1159
1200
  }
1160
- const misses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
1161
- if (misses.length === 0) {
1162
- ctx.data.coverageMisses = [];
1163
- return;
1201
+ lines.push("## Summary");
1202
+ lines.push("");
1203
+ if (opts.agentSummary?.trim()) {
1204
+ lines.push(opts.agentSummary.trim());
1205
+ } else {
1206
+ lines.push(`Implementation of issue #${opts.issueNumber} \u2014 ${opts.issueTitle}`);
1207
+ lines.push("");
1208
+ lines.push("_(agent did not supply PR_SUMMARY)_");
1164
1209
  }
1165
- const invoker = ctx.data.__invokeAgent;
1166
- const basePrompt = ctx.data.prompt;
1167
- if (!invoker || !basePrompt) {
1168
- ctx.data.coverageMisses = misses;
1169
- return;
1170
- }
1171
- process.stderr.write(`[kody2] coverage check found ${misses.length} missing test(s); retrying agent once
1172
- `);
1173
- const retryPrompt = `${basePrompt}
1174
-
1175
- # Coverage failure (retry)
1176
- ${formatMissesForFeedback(misses)}`;
1177
- const retry = await invoker(retryPrompt);
1178
- const retryParsed = parseAgentResult(retry.finalText);
1179
- if (retry.outcome === "completed" && retryParsed.done) {
1180
- ctx.data.agentDone = true;
1181
- ctx.data.commitMessage = retryParsed.commitMessage || ctx.data.commitMessage;
1182
- ctx.data.prSummary = retryParsed.prSummary || ctx.data.prSummary;
1183
- }
1184
- const finalMisses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
1185
- ctx.data.coverageMisses = finalMisses;
1186
- };
1187
-
1188
- // src/scripts/commitAndPush.ts
1189
- import { execFileSync as execFileSync8 } from "child_process";
1190
-
1191
- // src/commit.ts
1192
- import { execFileSync as execFileSync7 } from "child_process";
1193
- import * as fs6 from "fs";
1194
- import * as path5 from "path";
1195
- var FORBIDDEN_PATH_PREFIXES = [
1196
- ".kody/",
1197
- ".kody-engine/",
1198
- ".kody2/",
1199
- ".kody-lean/",
1200
- // back-compat: stale runtime dir from kody-lean v0.5.x
1201
- "node_modules/",
1202
- "dist/",
1203
- "build/"
1204
- ];
1205
- var FORBIDDEN_PATH_EXACT = /* @__PURE__ */ new Set([".env"]);
1206
- var FORBIDDEN_PATH_SUFFIXES = [".log"];
1207
- var CONVENTIONAL_PREFIXES = [
1208
- "feat:",
1209
- "fix:",
1210
- "chore:",
1211
- "docs:",
1212
- "refactor:",
1213
- "test:",
1214
- "perf:",
1215
- "ci:",
1216
- "style:",
1217
- "build:",
1218
- "revert:"
1219
- ];
1220
- function git2(args, cwd) {
1221
- try {
1222
- return execFileSync7("git", args, {
1223
- encoding: "utf-8",
1224
- timeout: 12e4,
1225
- cwd,
1226
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
1227
- stdio: ["pipe", "pipe", "pipe"]
1228
- }).trim();
1229
- } catch (err) {
1230
- const e = err;
1231
- const stderr = e.stderr?.toString().trim() ?? "";
1232
- const stdout = e.stdout?.toString().trim() ?? "";
1233
- const status = e.status ?? "?";
1234
- const detail = stderr || stdout || e.message || "(no output)";
1235
- throw new Error(`git ${args.join(" ")} (exit ${status}):
1236
- ${detail}`);
1237
- }
1238
- }
1239
- function tryGit(args, cwd) {
1240
- try {
1241
- git2(args, cwd);
1242
- return true;
1243
- } catch {
1244
- return false;
1245
- }
1246
- }
1247
- function abortUnfinishedGitOps(cwd) {
1248
- const aborted = [];
1249
- const gitDir = path5.join(cwd ?? process.cwd(), ".git");
1250
- if (!fs6.existsSync(gitDir)) return aborted;
1251
- if (fs6.existsSync(path5.join(gitDir, "MERGE_HEAD"))) {
1252
- if (tryGit(["merge", "--abort"], cwd)) aborted.push("merge");
1253
- }
1254
- if (fs6.existsSync(path5.join(gitDir, "CHERRY_PICK_HEAD"))) {
1255
- if (tryGit(["cherry-pick", "--abort"], cwd)) aborted.push("cherry-pick");
1256
- }
1257
- if (fs6.existsSync(path5.join(gitDir, "REVERT_HEAD"))) {
1258
- if (tryGit(["revert", "--abort"], cwd)) aborted.push("revert");
1259
- }
1260
- if (fs6.existsSync(path5.join(gitDir, "rebase-merge")) || fs6.existsSync(path5.join(gitDir, "rebase-apply"))) {
1261
- if (tryGit(["rebase", "--abort"], cwd)) aborted.push("rebase");
1262
- }
1263
- try {
1264
- const unmerged = git2(["diff", "--name-only", "--diff-filter=U"], cwd);
1265
- if (unmerged) {
1266
- tryGit(["reset", "--mixed", "HEAD"], cwd);
1267
- aborted.push("unmerged-paths-reset");
1268
- }
1269
- } catch {
1270
- }
1271
- return aborted;
1272
- }
1273
- function isForbiddenPath(p) {
1274
- if (FORBIDDEN_PATH_EXACT.has(p)) return true;
1275
- for (const pre of FORBIDDEN_PATH_PREFIXES) if (p.startsWith(pre)) return true;
1276
- for (const suf of FORBIDDEN_PATH_SUFFIXES) if (p.endsWith(suf)) return true;
1277
- return false;
1278
- }
1279
- function listChangedFiles(cwd) {
1280
- const raw = execFileSync7("git", ["status", "--porcelain=v1", "-z"], {
1281
- encoding: "utf-8",
1282
- cwd,
1283
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
1284
- stdio: ["pipe", "pipe", "pipe"]
1285
- });
1286
- if (!raw) return [];
1287
- const entries = raw.split("\0").filter((e) => e.length > 0);
1288
- return entries.map((e) => e.slice(3)).filter(Boolean);
1289
- }
1290
- function normalizeCommitMessage(raw) {
1291
- const trimmed = raw.trim().replace(/^['"]|['"]$/g, "").trim();
1292
- if (!trimmed) return "chore: kody2 update";
1293
- const firstLine2 = trimmed.split("\n")[0];
1294
- for (const prefix of CONVENTIONAL_PREFIXES) {
1295
- if (firstLine2.toLowerCase().startsWith(prefix)) return trimmed;
1296
- }
1297
- return `chore: ${trimmed}`;
1298
- }
1299
- function commitAndPush(branch, agentMessage, cwd) {
1300
- const allChanged = listChangedFiles(cwd);
1301
- const allowedFiles = allChanged.filter((f) => !isForbiddenPath(f));
1302
- const mergeHeadExists = fs6.existsSync(path5.join(cwd ?? process.cwd(), ".git", "MERGE_HEAD"));
1303
- if (allowedFiles.length === 0 && !mergeHeadExists) {
1304
- return { committed: false, pushed: false, sha: "", message: "" };
1305
- }
1306
- for (const f of allowedFiles) {
1307
- try {
1308
- git2(["add", "--", f], cwd);
1309
- } catch {
1310
- }
1311
- }
1312
- const message = normalizeCommitMessage(agentMessage);
1313
- try {
1314
- git2(["commit", "--no-gpg-sign", "-m", message], cwd);
1315
- } catch (err) {
1316
- const msg = err instanceof Error ? err.message : String(err);
1317
- if (/nothing to commit/i.test(msg)) {
1318
- return { committed: false, pushed: false, sha: "", message };
1319
- }
1320
- throw err;
1321
- }
1322
- const sha = git2(["rev-parse", "HEAD"], cwd).slice(0, 7);
1323
- try {
1324
- git2(["push", "-u", "origin", branch], cwd);
1325
- } catch {
1326
- git2(["push", "--force-with-lease", "-u", "origin", branch], cwd);
1327
- }
1328
- return { committed: true, pushed: true, sha, message };
1329
- }
1330
- function hasCommitsAhead(branch, defaultBranch, cwd) {
1331
- try {
1332
- const out = git2(["rev-list", "--count", `origin/${defaultBranch}..${branch}`], cwd);
1333
- return parseInt(out, 10) > 0;
1334
- } catch {
1335
- try {
1336
- const out = git2(["rev-list", "--count", `${defaultBranch}..${branch}`], cwd);
1337
- return parseInt(out, 10) > 0;
1338
- } catch {
1339
- return false;
1340
- }
1341
- }
1342
- }
1343
-
1344
- // src/scripts/commitAndPush.ts
1345
- var commitAndPush2 = async (ctx) => {
1346
- const branch = ctx.data.branch;
1347
- if (!branch) {
1348
- ctx.data.commitResult = { committed: false, pushed: false };
1349
- return;
1350
- }
1351
- if (ctx.args.mode === "resolve") {
1352
- try {
1353
- execFileSync8("git", ["add", "-A"], { cwd: ctx.cwd, env: { ...process.env, HUSKY: "0" }, stdio: "pipe" });
1354
- } catch {
1355
- }
1356
- } else {
1357
- const aborted = abortUnfinishedGitOps(ctx.cwd);
1358
- if (aborted.length > 0) {
1359
- process.stderr.write(`[kody2] cleaned up unfinished git ops: ${aborted.join(", ")}
1360
- `);
1361
- }
1362
- }
1363
- const fallbackMsg = defaultCommitMessage(ctx.args.mode, ctx.data);
1364
- const message = ctx.data.commitMessage || fallbackMsg;
1365
- try {
1366
- const result = commitAndPush(branch, message, ctx.cwd);
1367
- ctx.data.commitResult = result;
1368
- ctx.data.changedFiles = listChangedFiles(ctx.cwd).filter((f) => !isForbiddenPath(f));
1369
- } catch (err) {
1370
- ctx.data.commitCrash = err instanceof Error ? err.message : String(err);
1371
- ctx.data.commitResult = { committed: false, pushed: false };
1372
- }
1373
- ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
1374
- };
1375
- function defaultCommitMessage(mode, data) {
1376
- switch (mode) {
1377
- case "run":
1378
- return `chore: kody2 changes for #${data.commentTargetNumber}`;
1379
- case "fix":
1380
- return `chore(fix): kody2 fix for PR #${data.commentTargetNumber}`;
1381
- case "fix-ci":
1382
- return `fix(ci): kody2 fix-ci for PR #${data.commentTargetNumber}`;
1383
- case "resolve":
1384
- return `fix: resolve merge conflicts with ${data.baseBranch}`;
1385
- default:
1386
- return `chore: kody2 changes`;
1387
- }
1388
- }
1389
-
1390
- // src/pr.ts
1391
- var TITLE_MAX = 72;
1392
- function buildPrTitle(issueNumber, issueTitle, draft) {
1393
- const prefix = draft ? "[WIP] " : "";
1394
- const base = `${prefix}#${issueNumber}: ${issueTitle}`;
1395
- if (base.length <= TITLE_MAX) return base;
1396
- return base.slice(0, TITLE_MAX - 1) + "\u2026";
1397
- }
1398
- function buildPrBody(opts) {
1399
- const lines = [];
1400
- if (opts.draft && opts.failureReason) {
1401
- const headline = firstLine(opts.failureReason);
1402
- lines.push(`> \u26A0\uFE0F Draft: ${headline}`);
1403
- lines.push(`> The failures below may be **pre-existing in the repo** \u2014 verify before treating as PR-blocking.`);
1404
- lines.push("");
1405
- }
1406
- lines.push("## Summary");
1407
- lines.push("");
1408
- if (opts.agentSummary && opts.agentSummary.trim()) {
1409
- lines.push(opts.agentSummary.trim());
1410
- } else {
1411
- lines.push(`Implementation of issue #${opts.issueNumber} \u2014 ${opts.issueTitle}`);
1412
- lines.push("");
1413
- lines.push("_(agent did not supply PR_SUMMARY)_");
1414
- }
1415
- lines.push("");
1416
- if (opts.changedFiles.length > 0) {
1417
- lines.push("## Changes");
1418
- lines.push("");
1419
- for (const f of opts.changedFiles.slice(0, 50)) lines.push(`- \`${f}\``);
1420
- if (opts.changedFiles.length > 50) lines.push(`- \u2026 and ${opts.changedFiles.length - 50} more`);
1421
- lines.push("");
1210
+ lines.push("");
1211
+ if (opts.changedFiles.length > 0) {
1212
+ lines.push("## Changes");
1213
+ lines.push("");
1214
+ for (const f of opts.changedFiles.slice(0, 50)) lines.push(`- \`${f}\``);
1215
+ if (opts.changedFiles.length > 50) lines.push(`- \u2026 and ${opts.changedFiles.length - 50} more`);
1216
+ lines.push("");
1422
1217
  }
1423
1218
  lines.push(`Closes #${opts.issueNumber}`);
1424
1219
  lines.push("");
@@ -1427,7 +1222,7 @@ function buildPrBody(opts) {
1427
1222
  lines.push("<summary>Verify output (click to expand)</summary>");
1428
1223
  lines.push("");
1429
1224
  lines.push("```");
1430
- lines.push(truncate(opts.failureReason, 6e3));
1225
+ lines.push(truncate2(opts.failureReason, 6e3));
1431
1226
  lines.push("```");
1432
1227
  lines.push("");
1433
1228
  lines.push("</details>");
@@ -1441,7 +1236,7 @@ function firstLine(s) {
1441
1236
  const trimmed = s.trim();
1442
1237
  const nl = trimmed.indexOf("\n");
1443
1238
  const head = nl === -1 ? trimmed : trimmed.slice(0, nl);
1444
- return head.length > 200 ? head.slice(0, 197) + "\u2026" : head;
1239
+ return head.length > 200 ? `${head.slice(0, 197)}\u2026` : head;
1445
1240
  }
1446
1241
  function findExistingPr(branch, cwd) {
1447
1242
  try {
@@ -1461,13 +1256,12 @@ function ensurePr(opts) {
1461
1256
  const existing = findExistingPr(opts.branch, opts.cwd);
1462
1257
  if (existing) {
1463
1258
  try {
1464
- gh(
1465
- ["pr", "edit", String(existing.number), "--body-file", "-"],
1466
- { input: body, cwd: opts.cwd }
1467
- );
1259
+ gh(["pr", "edit", String(existing.number), "--body-file", "-"], { input: body, cwd: opts.cwd });
1468
1260
  } catch (err) {
1469
- process.stderr.write(`[kody2] failed to update PR #${existing.number}: ${err instanceof Error ? err.message : String(err)}
1470
- `);
1261
+ process.stderr.write(
1262
+ `[kody2] failed to update PR #${existing.number}: ${err instanceof Error ? err.message : String(err)}
1263
+ `
1264
+ );
1471
1265
  }
1472
1266
  return { url: existing.url, number: existing.number, draft: opts.draft, action: "updated" };
1473
1267
  }
@@ -1538,9 +1332,354 @@ function computeFailureReason(ctx) {
1538
1332
  if (!agentDone) {
1539
1333
  return ctx.data.agentFailureReason || ctx.data.agentError || ctx.data.commitCrash || "agent did not emit DONE";
1540
1334
  }
1541
- if (ctx.data.verifyOk === false) return ctx.data.verifyReason || "verify failed";
1542
- return "";
1543
- }
1335
+ if (ctx.data.verifyOk === false) return ctx.data.verifyReason || "verify failed";
1336
+ return "";
1337
+ }
1338
+
1339
+ // src/branch.ts
1340
+ import { execFileSync as execFileSync6 } from "child_process";
1341
+ var UncommittedChangesError = class extends Error {
1342
+ constructor(branch) {
1343
+ super(`Uncommitted changes on branch '${branch}' \u2014 refusing to run to protect work in progress`);
1344
+ this.branch = branch;
1345
+ this.name = "UncommittedChangesError";
1346
+ }
1347
+ branch;
1348
+ };
1349
+ function git2(args, cwd) {
1350
+ return execFileSync6("git", args, {
1351
+ encoding: "utf-8",
1352
+ timeout: 3e4,
1353
+ cwd,
1354
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
1355
+ stdio: ["pipe", "pipe", "pipe"]
1356
+ }).trim();
1357
+ }
1358
+ function deriveBranchName(issueNumber, title) {
1359
+ const slug = title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 50).replace(/-$/, "");
1360
+ return slug ? `${issueNumber}-${slug}` : `${issueNumber}`;
1361
+ }
1362
+ function getCurrentBranch(cwd) {
1363
+ return git2(["branch", "--show-current"], cwd);
1364
+ }
1365
+ function hasUncommittedChanges(cwd) {
1366
+ return git2(["status", "--porcelain", "--untracked-files=no"], cwd).length > 0;
1367
+ }
1368
+ function checkoutPrBranch(prNumber, cwd) {
1369
+ const env = {
1370
+ ...process.env,
1371
+ HUSKY: "0",
1372
+ SKIP_HOOKS: "1",
1373
+ GH_TOKEN: process.env.GH_PAT?.trim() || process.env.GH_TOKEN || ""
1374
+ };
1375
+ execFileSync6("gh", ["pr", "checkout", String(prNumber)], {
1376
+ cwd,
1377
+ env,
1378
+ stdio: ["ignore", "pipe", "pipe"],
1379
+ timeout: 6e4
1380
+ });
1381
+ return getCurrentBranch(cwd);
1382
+ }
1383
+ function mergeBase(baseBranch, cwd) {
1384
+ try {
1385
+ git2(["fetch", "origin", baseBranch], cwd);
1386
+ } catch {
1387
+ return "error";
1388
+ }
1389
+ try {
1390
+ git2(["merge", `origin/${baseBranch}`, "--no-edit", "--no-ff"], cwd);
1391
+ return "clean";
1392
+ } catch {
1393
+ try {
1394
+ const unmerged = git2(["diff", "--name-only", "--diff-filter=U"], cwd);
1395
+ if (unmerged.length > 0) return "conflict";
1396
+ } catch {
1397
+ }
1398
+ try {
1399
+ git2(["merge", "--abort"], cwd);
1400
+ } catch {
1401
+ }
1402
+ return "error";
1403
+ }
1404
+ }
1405
+ function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
1406
+ const branchName = deriveBranchName(issueNumber, title);
1407
+ const current = getCurrentBranch(cwd);
1408
+ if (current === branchName) {
1409
+ if (hasUncommittedChanges(cwd)) throw new UncommittedChangesError(branchName);
1410
+ return { branch: branchName, created: false };
1411
+ }
1412
+ if (hasUncommittedChanges(cwd)) throw new UncommittedChangesError(current || "(detached)");
1413
+ try {
1414
+ git2(["fetch", "origin"], cwd);
1415
+ } catch {
1416
+ }
1417
+ try {
1418
+ git2(["rev-parse", "--verify", `origin/${branchName}`], cwd);
1419
+ git2(["checkout", branchName], cwd);
1420
+ try {
1421
+ git2(["pull", "origin", branchName], cwd);
1422
+ } catch {
1423
+ }
1424
+ return { branch: branchName, created: false };
1425
+ } catch {
1426
+ }
1427
+ try {
1428
+ git2(["rev-parse", "--verify", branchName], cwd);
1429
+ git2(["checkout", branchName], cwd);
1430
+ return { branch: branchName, created: false };
1431
+ } catch {
1432
+ }
1433
+ try {
1434
+ git2(["checkout", "-b", branchName, `origin/${defaultBranch}`], cwd);
1435
+ } catch {
1436
+ git2(["checkout", "-b", branchName], cwd);
1437
+ }
1438
+ return { branch: branchName, created: true };
1439
+ }
1440
+
1441
+ // src/gha.ts
1442
+ import { execFileSync as execFileSync7 } from "child_process";
1443
+ import * as fs8 from "fs";
1444
+ function getRunUrl() {
1445
+ const server = process.env.GITHUB_SERVER_URL;
1446
+ const repo = process.env.GITHUB_REPOSITORY;
1447
+ const runId = process.env.GITHUB_RUN_ID;
1448
+ if (!server || !repo || !runId) return "";
1449
+ return `${server}/${repo}/actions/runs/${runId}`;
1450
+ }
1451
+ function reactToTriggerComment(cwd) {
1452
+ if (process.env.GITHUB_EVENT_NAME !== "issue_comment") return;
1453
+ const eventPath = process.env.GITHUB_EVENT_PATH;
1454
+ if (!eventPath || !fs8.existsSync(eventPath)) return;
1455
+ let event = null;
1456
+ try {
1457
+ event = JSON.parse(fs8.readFileSync(eventPath, "utf-8"));
1458
+ } catch {
1459
+ return;
1460
+ }
1461
+ const commentId = event?.comment?.id;
1462
+ const repo = process.env.GITHUB_REPOSITORY;
1463
+ if (!commentId || !repo) return;
1464
+ const token = process.env.KODY_TOKEN?.trim() || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
1465
+ try {
1466
+ execFileSync7(
1467
+ "gh",
1468
+ [
1469
+ "api",
1470
+ "-X",
1471
+ "POST",
1472
+ "-H",
1473
+ "Accept: application/vnd.github+json",
1474
+ `/repos/${repo}/issues/comments/${commentId}/reactions`,
1475
+ "-f",
1476
+ "content=eyes"
1477
+ ],
1478
+ {
1479
+ cwd,
1480
+ env: { ...process.env, GH_TOKEN: token ?? process.env.GH_TOKEN ?? "" },
1481
+ stdio: "pipe",
1482
+ timeout: 15e3
1483
+ }
1484
+ );
1485
+ } catch {
1486
+ }
1487
+ }
1488
+
1489
+ // src/workflow.ts
1490
+ import { execFileSync as execFileSync8 } from "child_process";
1491
+ var GH_TIMEOUT_MS = 3e4;
1492
+ function ghToken2() {
1493
+ return process.env.GH_PAT?.trim() || process.env.GH_TOKEN;
1494
+ }
1495
+ function gh2(args, cwd) {
1496
+ const token = ghToken2();
1497
+ const env = token ? { ...process.env, GH_TOKEN: token } : { ...process.env };
1498
+ return execFileSync8("gh", args, {
1499
+ encoding: "utf-8",
1500
+ timeout: GH_TIMEOUT_MS,
1501
+ cwd,
1502
+ env,
1503
+ stdio: ["ignore", "pipe", "pipe"]
1504
+ }).trim();
1505
+ }
1506
+ function getLatestFailedRunForPr(prNumber, cwd) {
1507
+ let headBranch;
1508
+ try {
1509
+ const out = gh2(["pr", "view", String(prNumber), "--json", "headRefName"], cwd);
1510
+ headBranch = JSON.parse(out).headRefName;
1511
+ } catch {
1512
+ return null;
1513
+ }
1514
+ if (!headBranch) return null;
1515
+ try {
1516
+ const out = gh2(
1517
+ [
1518
+ "run",
1519
+ "list",
1520
+ "--branch",
1521
+ headBranch,
1522
+ "--status",
1523
+ "failure",
1524
+ "--limit",
1525
+ "1",
1526
+ "--json",
1527
+ "databaseId,workflowName,headBranch,conclusion,url,createdAt"
1528
+ ],
1529
+ cwd
1530
+ );
1531
+ const parsed = JSON.parse(out);
1532
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
1533
+ const r = parsed[0];
1534
+ return {
1535
+ id: String(r.databaseId ?? ""),
1536
+ workflowName: r.workflowName ?? "",
1537
+ headBranch: r.headBranch ?? headBranch,
1538
+ conclusion: r.conclusion ?? "failure",
1539
+ url: r.url ?? "",
1540
+ createdAt: r.createdAt ?? ""
1541
+ };
1542
+ } catch {
1543
+ return null;
1544
+ }
1545
+ }
1546
+ function getFailedRunLogTail(runId, maxBytes, cwd) {
1547
+ try {
1548
+ const raw = gh2(["run", "view", String(runId), "--log-failed"], cwd);
1549
+ if (raw.length <= maxBytes) return raw;
1550
+ return raw.slice(-maxBytes);
1551
+ } catch {
1552
+ return "";
1553
+ }
1554
+ }
1555
+
1556
+ // src/scripts/fixCiFlow.ts
1557
+ var LOG_MAX_BYTES = 3e4;
1558
+ var fixCiFlow = async (ctx) => {
1559
+ const prNumber = ctx.args.pr;
1560
+ const pr = getPr(prNumber, ctx.cwd);
1561
+ if (pr.state !== "OPEN") {
1562
+ ctx.output.exitCode = 1;
1563
+ ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
1564
+ ctx.skipAgent = true;
1565
+ return;
1566
+ }
1567
+ ctx.data.pr = pr;
1568
+ ctx.data.commentTargetType = "pr";
1569
+ ctx.data.commentTargetNumber = prNumber;
1570
+ checkoutPrBranch(prNumber, ctx.cwd);
1571
+ ctx.data.branch = getCurrentBranch(ctx.cwd);
1572
+ let runId = ctx.args.runId;
1573
+ let workflowName = "";
1574
+ let failedRunUrl = "";
1575
+ if (!runId) {
1576
+ const run = getLatestFailedRunForPr(prNumber, ctx.cwd);
1577
+ if (!run) {
1578
+ ctx.output.exitCode = 1;
1579
+ ctx.output.reason = `no failed workflow run found on PR #${prNumber}'s branch`;
1580
+ ctx.skipAgent = true;
1581
+ return;
1582
+ }
1583
+ runId = run.id;
1584
+ workflowName = run.workflowName;
1585
+ failedRunUrl = run.url;
1586
+ }
1587
+ const logTail = getFailedRunLogTail(runId, LOG_MAX_BYTES, ctx.cwd);
1588
+ if (!logTail) {
1589
+ ctx.output.exitCode = 1;
1590
+ ctx.output.reason = `failed to fetch log tail for run ${runId}`;
1591
+ ctx.skipAgent = true;
1592
+ return;
1593
+ }
1594
+ ctx.data.failedRunId = runId;
1595
+ ctx.data.failedWorkflowName = workflowName;
1596
+ ctx.data.failedRunUrl = failedRunUrl;
1597
+ ctx.data.failedLogTail = logTail;
1598
+ ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
1599
+ const runUrl = getRunUrl();
1600
+ const runSuffix = runUrl ? `, kody2 run ${runUrl}` : "";
1601
+ tryPostPr(
1602
+ prNumber,
1603
+ `\u2699\uFE0F kody2 fix-ci started on \`${ctx.data.branch}\`${runSuffix} \u2014 analyzing workflow run ${runId}`,
1604
+ ctx.cwd
1605
+ );
1606
+ };
1607
+ function tryPostPr(prNumber, body, cwd) {
1608
+ try {
1609
+ postPrReviewComment(prNumber, body, cwd);
1610
+ } catch {
1611
+ }
1612
+ }
1613
+
1614
+ // src/scripts/fixFlow.ts
1615
+ var fixFlow = async (ctx) => {
1616
+ const prNumber = ctx.args.pr;
1617
+ const pr = getPr(prNumber, ctx.cwd);
1618
+ if (pr.state !== "OPEN") {
1619
+ ctx.output.exitCode = 1;
1620
+ ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
1621
+ ctx.skipAgent = true;
1622
+ return;
1623
+ }
1624
+ ctx.data.pr = pr;
1625
+ ctx.data.commentTargetType = "pr";
1626
+ ctx.data.commentTargetNumber = prNumber;
1627
+ checkoutPrBranch(prNumber, ctx.cwd);
1628
+ ctx.data.branch = getCurrentBranch(ctx.cwd);
1629
+ const inlineFeedback = ctx.args.feedback?.trim();
1630
+ const feedback = inlineFeedback || getPrLatestReviewBody(prNumber, ctx.cwd);
1631
+ if (!feedback.trim()) {
1632
+ ctx.output.exitCode = 1;
1633
+ ctx.output.reason = "no --feedback provided and no review/body text found on PR";
1634
+ ctx.skipAgent = true;
1635
+ return;
1636
+ }
1637
+ ctx.data.feedback = feedback;
1638
+ ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
1639
+ const runUrl = getRunUrl();
1640
+ const runSuffix = runUrl ? `, run ${runUrl}` : "";
1641
+ tryPostPr2(
1642
+ prNumber,
1643
+ `\u2699\uFE0F kody2 fix started on \`${ctx.data.branch}\`${runSuffix} \u2014 applying feedback (${truncate2(feedback.replace(/\n/g, " "), 200)})`,
1644
+ ctx.cwd
1645
+ );
1646
+ };
1647
+ function tryPostPr2(prNumber, body, cwd) {
1648
+ try {
1649
+ postPrReviewComment(prNumber, body, cwd);
1650
+ } catch {
1651
+ }
1652
+ }
1653
+
1654
+ // src/scripts/loadConventions.ts
1655
+ var loadConventions = async (ctx) => {
1656
+ const conventions = loadProjectConventions(ctx.cwd);
1657
+ ctx.data.conventions = conventions;
1658
+ if (conventions.length > 0) {
1659
+ process.stderr.write(`[kody2] loaded conventions: ${conventions.map((c) => c.path).join(", ")}
1660
+ `);
1661
+ }
1662
+ };
1663
+
1664
+ // src/scripts/loadCoverageRules.ts
1665
+ var loadCoverageRules = async (ctx) => {
1666
+ ctx.data.coverageRules = ctx.config.testRequirements ?? [];
1667
+ };
1668
+
1669
+ // src/scripts/parseAgentResult.ts
1670
+ var parseAgentResult2 = async (ctx, _profile, agentResult) => {
1671
+ if (!agentResult) {
1672
+ ctx.data.agentDone = false;
1673
+ return;
1674
+ }
1675
+ const parsed = parseAgentResult(agentResult.finalText);
1676
+ ctx.data.agentDone = parsed.done;
1677
+ ctx.data.commitMessage = parsed.commitMessage;
1678
+ ctx.data.prSummary = parsed.prSummary;
1679
+ ctx.data.agentFailureReason = parsed.failureReason;
1680
+ ctx.data.agentOutcome = agentResult.outcome;
1681
+ ctx.data.agentError = agentResult.error;
1682
+ };
1544
1683
 
1545
1684
  // src/scripts/postIssueComment.ts
1546
1685
  var postIssueComment2 = async (ctx) => {
@@ -1559,13 +1698,13 @@ var postIssueComment2 = async (ctx) => {
1559
1698
  return;
1560
1699
  }
1561
1700
  if (ctx.output.exitCode === 4 && ctx.data.prCrashReason) {
1562
- postWith(targetType, targetNumber, `\u26A0\uFE0F kody2 FAILED: ${truncate(ctx.data.prCrashReason, 1500)}`, ctx.cwd);
1701
+ postWith(targetType, targetNumber, `\u26A0\uFE0F kody2 FAILED: ${truncate2(ctx.data.prCrashReason, 1500)}`, ctx.cwd);
1563
1702
  ctx.output.reason = ctx.data.prCrashReason;
1564
1703
  return;
1565
1704
  }
1566
1705
  const failureReason = computeFailureReason2(ctx);
1567
1706
  const isFailure = failureReason.length > 0;
1568
- const msg = isFailure ? `\u26A0\uFE0F kody2 FAILED: ${truncate(failureReason, 1500)}${prUrl ? ` \u2014 draft PR: ${prUrl}` : ""}` : `\u2705 kody2 PR opened: ${prUrl}`;
1707
+ const msg = isFailure ? `\u26A0\uFE0F kody2 FAILED: ${truncate2(failureReason, 1500)}${prUrl ? ` \u2014 draft PR: ${prUrl}` : ""}` : `\u2705 kody2 PR opened: ${prUrl}`;
1569
1708
  postWith(targetType, targetNumber, msg, ctx.cwd);
1570
1709
  let exitCode = 0;
1571
1710
  const agentDone = Boolean(ctx.data.agentDone);
@@ -1594,195 +1733,266 @@ function postWith(type, n, body, cwd) {
1594
1733
  }
1595
1734
  }
1596
1735
 
1597
- // src/scripts/index.ts
1598
- var preflightScripts = {
1599
- runFlow,
1600
- fixFlow,
1601
- fixCiFlow,
1602
- resolveFlow,
1603
- loadConventions,
1604
- loadCoverageRules,
1605
- composePrompt
1606
- };
1607
- var postflightScripts = {
1608
- parseAgentResult: parseAgentResult2,
1609
- verify,
1610
- checkCoverageWithRetry,
1611
- commitAndPush: commitAndPush2,
1612
- ensurePr: ensurePr2,
1613
- postIssueComment: postIssueComment2
1614
- };
1615
- var allScriptNames = /* @__PURE__ */ new Set([
1616
- ...Object.keys(preflightScripts),
1617
- ...Object.keys(postflightScripts)
1618
- ]);
1619
-
1620
- // src/agent.ts
1621
- import { query } from "@anthropic-ai/claude-agent-sdk";
1622
- import * as fs7 from "fs";
1623
- import * as path6 from "path";
1624
-
1625
- // src/format.ts
1626
- function renderEvent(msg, opts = {}) {
1627
- if (opts.quiet) {
1628
- if (msg.type === "result") return formatResult(msg);
1629
- return null;
1736
+ // src/scripts/resolveFlow.ts
1737
+ import { execFileSync as execFileSync9 } from "child_process";
1738
+ var CONFLICT_DIFF_MAX_BYTES = 4e4;
1739
+ var resolveFlow = async (ctx) => {
1740
+ const prNumber = ctx.args.pr;
1741
+ const pr = getPr(prNumber, ctx.cwd);
1742
+ if (pr.state !== "OPEN") {
1743
+ ctx.output.exitCode = 1;
1744
+ ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
1745
+ ctx.skipAgent = true;
1746
+ return;
1630
1747
  }
1631
- switch (msg.type) {
1632
- case "system":
1633
- return null;
1634
- case "assistant":
1635
- return formatAssistant(msg, opts);
1636
- case "user":
1637
- return formatUserToolResult(msg, opts);
1638
- case "result":
1639
- return formatResult(msg);
1640
- default:
1641
- return null;
1748
+ ctx.data.pr = pr;
1749
+ ctx.data.commentTargetType = "pr";
1750
+ ctx.data.commentTargetNumber = prNumber;
1751
+ checkoutPrBranch(prNumber, ctx.cwd);
1752
+ ctx.data.branch = getCurrentBranch(ctx.cwd);
1753
+ const baseBranch = pr.baseRefName || ctx.config.git.defaultBranch;
1754
+ ctx.data.baseBranch = baseBranch;
1755
+ const mergeStatus = mergeBase(baseBranch, ctx.cwd);
1756
+ if (mergeStatus === "clean") {
1757
+ ctx.output.exitCode = 0;
1758
+ ctx.output.reason = `already up to date with origin/${baseBranch} \u2014 nothing to resolve`;
1759
+ ctx.skipAgent = true;
1760
+ tryPostPr3(prNumber, `\u2139\uFE0F kody2 resolve: ${ctx.output.reason}`, ctx.cwd);
1761
+ return;
1642
1762
  }
1643
- }
1644
- function formatAssistant(msg, opts) {
1645
- const content = msg.message?.content ?? [];
1646
- const lines = [];
1647
- for (const block of content) {
1648
- if (block.type === "text") {
1649
- const text = block.text.trim();
1650
- if (text) lines.push(text);
1651
- } else if (block.type === "tool_use") {
1652
- const tu = block;
1653
- lines.push(`\u2192 ${tu.name}${summarizeToolInput(tu.name, tu.input)}`);
1654
- }
1763
+ if (mergeStatus === "error") {
1764
+ ctx.output.exitCode = 99;
1765
+ ctx.output.reason = `failed to merge origin/${baseBranch} (non-conflict error); see runner log`;
1766
+ ctx.skipAgent = true;
1767
+ tryPostPr3(prNumber, `\u26A0\uFE0F kody2 resolve FAILED: ${ctx.output.reason}`, ctx.cwd);
1768
+ return;
1769
+ }
1770
+ const conflictedFiles = getConflictedFiles(ctx.cwd);
1771
+ if (conflictedFiles.length === 0) {
1772
+ ctx.output.exitCode = 99;
1773
+ ctx.output.reason = "merge reported conflict but no unmerged paths detected";
1774
+ ctx.skipAgent = true;
1775
+ return;
1776
+ }
1777
+ ctx.data.conflictedFiles = conflictedFiles;
1778
+ ctx.data.conflictMarkersPreview = getConflictMarkersPreview(conflictedFiles, ctx.cwd);
1779
+ const runUrl = getRunUrl();
1780
+ const runSuffix = runUrl ? `, run ${runUrl}` : "";
1781
+ tryPostPr3(
1782
+ prNumber,
1783
+ `\u2699\uFE0F kody2 resolve started on \`${ctx.data.branch}\`${runSuffix} \u2014 ${conflictedFiles.length} conflicted file(s)`,
1784
+ ctx.cwd
1785
+ );
1786
+ };
1787
+ function getConflictedFiles(cwd) {
1788
+ try {
1789
+ const out = execFileSync9("git", ["diff", "--name-only", "--diff-filter=U"], {
1790
+ encoding: "utf-8",
1791
+ cwd,
1792
+ env: { ...process.env, HUSKY: "0" }
1793
+ }).trim();
1794
+ return out ? out.split("\n").filter(Boolean) : [];
1795
+ } catch {
1796
+ return [];
1655
1797
  }
1656
- return lines.length > 0 ? lines.join("\n") : null;
1657
1798
  }
1658
- function formatUserToolResult(msg, opts) {
1659
- const content = msg.message?.content ?? [];
1660
- const lines = [];
1661
- for (const block of content) {
1662
- if (block.type === "tool_result") {
1663
- const tr = block;
1664
- const text = stringifyToolContent(tr.content);
1665
- const lineCount = text.split("\n").length;
1666
- const sizeBytes = text.length;
1667
- const flag = tr.is_error ? " ERROR" : "";
1668
- const summary = ` \u21B3${flag} ${lineCount} lines, ${formatBytes(sizeBytes)}`;
1669
- if (opts.verbose) {
1670
- lines.push(`${summary}
1671
- ${truncate2(text, 4e3)}`);
1672
- } else {
1673
- lines.push(summary);
1674
- }
1799
+ function getConflictMarkersPreview(files, cwd, maxBytes = CONFLICT_DIFF_MAX_BYTES) {
1800
+ const chunks = [];
1801
+ let total = 0;
1802
+ for (const f of files) {
1803
+ try {
1804
+ const content = execFileSync9("cat", [f], { encoding: "utf-8", cwd }).toString();
1805
+ const snippet = `### ${f}
1806
+
1807
+ \`\`\`
1808
+ ${content.slice(0, 6e3)}
1809
+ \`\`\`
1810
+ `;
1811
+ total += snippet.length;
1812
+ chunks.push(snippet);
1813
+ if (total >= maxBytes) break;
1814
+ } catch {
1675
1815
  }
1676
1816
  }
1677
- return lines.length > 0 ? lines.join("\n") : null;
1678
- }
1679
- function formatResult(msg) {
1680
- const ok = msg.subtype === "success";
1681
- const tag = ok ? "DONE" : `FAILED (${msg.subtype ?? "unknown"})`;
1682
- const dur = msg.duration_ms ? ` ${(msg.duration_ms / 1e3).toFixed(1)}s` : "";
1683
- const turns = msg.num_turns ? ` ${msg.num_turns} turns` : "";
1684
- const cost = typeof msg.total_cost_usd === "number" ? ` $${msg.total_cost_usd.toFixed(4)}` : "";
1685
- return `
1686
- === ${tag}${dur}${turns}${cost} ===`;
1817
+ return chunks.join("\n");
1687
1818
  }
1688
- function summarizeToolInput(toolName, input = {}) {
1689
- if (toolName === "Bash" && typeof input.command === "string") {
1690
- const cmd = input.command.split("\n")[0];
1691
- return `: ${truncate2(cmd, 120)}`;
1819
+ function tryPostPr3(prNumber, body, cwd) {
1820
+ try {
1821
+ postPrReviewComment(prNumber, body, cwd);
1822
+ } catch {
1692
1823
  }
1693
- if ((toolName === "Read" || toolName === "Edit" || toolName === "Write") && typeof input.file_path === "string") {
1694
- return ` ${input.file_path}`;
1824
+ }
1825
+
1826
+ // src/scripts/runFlow.ts
1827
+ var runFlow = async (ctx) => {
1828
+ const issueNumber = ctx.args.issue;
1829
+ const issue = getIssue(issueNumber, ctx.cwd);
1830
+ ctx.data.issue = issue;
1831
+ ctx.data.commentTargetType = "issue";
1832
+ ctx.data.commentTargetNumber = issueNumber;
1833
+ try {
1834
+ const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd);
1835
+ ctx.data.branch = branchInfo.branch;
1836
+ } catch (err) {
1837
+ if (err instanceof UncommittedChangesError) {
1838
+ ctx.output.exitCode = 5;
1839
+ ctx.output.reason = err.message;
1840
+ ctx.skipAgent = true;
1841
+ tryPost(issueNumber, `\u26A0\uFE0F kody2 refused to start: ${err.message}`, ctx.cwd);
1842
+ return;
1843
+ }
1844
+ throw err;
1695
1845
  }
1696
- if ((toolName === "Glob" || toolName === "Grep") && typeof input.pattern === "string") {
1697
- return `: ${truncate2(input.pattern, 80)}`;
1846
+ const runUrl = getRunUrl();
1847
+ const startMsg = runUrl ? `\u2699\uFE0F kody2 started \u2014 branch \`${ctx.data.branch}\`, run ${runUrl}` : `\u2699\uFE0F kody2 started \u2014 branch \`${ctx.data.branch}\``;
1848
+ tryPost(issueNumber, startMsg, ctx.cwd);
1849
+ };
1850
+ function tryPost(issueNumber, body, cwd) {
1851
+ try {
1852
+ postIssueComment(issueNumber, body, cwd);
1853
+ } catch {
1698
1854
  }
1699
- return "";
1700
1855
  }
1701
- function stringifyToolContent(content) {
1702
- if (typeof content === "string") return content;
1703
- if (Array.isArray(content)) {
1704
- return content.map((b) => {
1705
- if (b && typeof b === "object" && "text" in b && typeof b.text === "string") {
1706
- return b.text;
1856
+
1857
+ // src/verify.ts
1858
+ import { spawn as spawn2 } from "child_process";
1859
+ var TAIL_CHARS = 4e3;
1860
+ var COMMAND_TIMEOUT_MS = 10 * 60 * 1e3;
1861
+ function runCommand(command, cwd) {
1862
+ return new Promise((resolve2) => {
1863
+ const start = Date.now();
1864
+ const child = spawn2(command, {
1865
+ cwd,
1866
+ shell: true,
1867
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1", CI: process.env.CI ?? "1" },
1868
+ stdio: ["ignore", "pipe", "pipe"]
1869
+ });
1870
+ const buffers = [];
1871
+ let totalSize = 0;
1872
+ const collect = (chunk) => {
1873
+ buffers.push(chunk);
1874
+ totalSize += chunk.length;
1875
+ while (totalSize > TAIL_CHARS * 4 && buffers.length > 1) {
1876
+ totalSize -= buffers[0].length;
1877
+ buffers.shift();
1707
1878
  }
1708
- return JSON.stringify(b);
1709
- }).join("\n");
1879
+ };
1880
+ child.stdout?.on("data", collect);
1881
+ child.stderr?.on("data", collect);
1882
+ const timer = setTimeout(() => {
1883
+ child.kill("SIGTERM");
1884
+ setTimeout(() => {
1885
+ if (!child.killed) child.kill("SIGKILL");
1886
+ }, 5e3);
1887
+ }, COMMAND_TIMEOUT_MS);
1888
+ child.on("exit", (code) => {
1889
+ clearTimeout(timer);
1890
+ const tail = Buffer.concat(buffers).toString("utf-8").slice(-TAIL_CHARS);
1891
+ resolve2({ exitCode: code ?? -1, durationMs: Date.now() - start, tail });
1892
+ });
1893
+ child.on("error", (err) => {
1894
+ clearTimeout(timer);
1895
+ resolve2({ exitCode: -1, durationMs: Date.now() - start, tail: err.message });
1896
+ });
1897
+ });
1898
+ }
1899
+ async function verifyAll(config, cwd) {
1900
+ const commands = [];
1901
+ if (config.quality.typecheck) commands.push({ name: "typecheck", cmd: config.quality.typecheck });
1902
+ if (config.quality.testUnit) commands.push({ name: "test", cmd: config.quality.testUnit });
1903
+ if (config.quality.lint) commands.push({ name: "lint", cmd: config.quality.lint });
1904
+ const failed = [];
1905
+ const details = {};
1906
+ for (const { name, cmd } of commands) {
1907
+ const result = await runCommand(cmd, cwd);
1908
+ details[name] = result;
1909
+ if (result.exitCode !== 0) failed.push(name);
1710
1910
  }
1711
- return JSON.stringify(content);
1911
+ return { ok: failed.length === 0, failed, details };
1712
1912
  }
1713
- function truncate2(s, max) {
1714
- if (s.length <= max) return s;
1715
- return s.slice(0, max) + `\u2026 (+${s.length - max} chars)`;
1913
+ var ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
1914
+ function stripAnsi(s) {
1915
+ return s.replace(ANSI_RE, "");
1716
1916
  }
1717
- function formatBytes(bytes) {
1718
- if (bytes < 1024) return `${bytes}B`;
1719
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
1720
- return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
1917
+ function summarizeFailure(result) {
1918
+ const lines = [`verify failed: ${result.failed.join(", ")}`];
1919
+ for (const name of result.failed) {
1920
+ const d = result.details[name];
1921
+ if (!d) continue;
1922
+ lines.push(`
1923
+ --- ${name} (exit ${d.exitCode}, ${(d.durationMs / 1e3).toFixed(1)}s) ---`);
1924
+ lines.push(stripAnsi(d.tail));
1925
+ }
1926
+ return lines.join("\n");
1721
1927
  }
1722
1928
 
1723
- // src/agent.ts
1724
- var DEFAULT_ALLOWED_TOOLS = ["Bash", "Edit", "Read", "Write", "Glob", "Grep"];
1725
- async function runAgent(opts) {
1726
- const ndjsonDir = opts.ndjsonDir ?? path6.join(opts.cwd, ".kody2");
1727
- fs7.mkdirSync(ndjsonDir, { recursive: true });
1728
- const ndjsonPath = path6.join(ndjsonDir, "last-run.jsonl");
1729
- const fullLog = fs7.createWriteStream(ndjsonPath, { flags: "w" });
1730
- const env = {
1731
- ...process.env,
1732
- SKIP_HOOKS: "1",
1733
- HUSKY: "0",
1734
- CI: process.env.CI ?? "1"
1735
- };
1736
- if (opts.litellmUrl) {
1737
- env.ANTHROPIC_BASE_URL = opts.litellmUrl;
1738
- env.ANTHROPIC_API_KEY = getAnthropicApiKeyOrDummy();
1929
+ // src/scripts/verify.ts
1930
+ var verify = async (ctx) => {
1931
+ try {
1932
+ const result = await verifyAll(ctx.config, ctx.cwd);
1933
+ ctx.data.verifyOk = result.ok;
1934
+ ctx.data.verifyReason = result.ok ? "" : summarizeFailure(result);
1935
+ } catch (err) {
1936
+ ctx.data.verifyOk = false;
1937
+ ctx.data.verifyReason = `verify crashed: ${err instanceof Error ? err.message : String(err)}`;
1739
1938
  }
1740
- let finalText = "";
1741
- let outcome = "failed";
1742
- let errorMessage;
1939
+ };
1940
+
1941
+ // src/scripts/writeRunSummary.ts
1942
+ import * as fs9 from "fs";
1943
+ var writeRunSummary = async (ctx) => {
1944
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
1945
+ if (!summaryPath) return;
1946
+ const mode = ctx.args.mode;
1947
+ const issue = ctx.args.issue;
1948
+ const pr = ctx.args.pr;
1949
+ const target = issue ? `issue #${issue}` : pr ? `PR #${pr}` : "(unknown)";
1950
+ const prUrl = ctx.output.prUrl;
1951
+ const exitCode = ctx.output.exitCode ?? 0;
1952
+ const reason = ctx.output.reason;
1953
+ const status = exitCode === 0 ? "\u2705 success" : exitCode === 3 ? "\u23ED\uFE0F no-op" : "\u26A0\uFE0F failed";
1954
+ const lines = [];
1955
+ lines.push(`## kody2 run \u2014 ${status}`);
1956
+ lines.push("");
1957
+ lines.push(`- **Mode:** \`${mode ?? "?"}\``);
1958
+ lines.push(`- **Target:** ${target}`);
1959
+ if (prUrl) lines.push(`- **PR:** ${prUrl}`);
1960
+ lines.push(`- **Exit code:** ${exitCode}`);
1961
+ if (reason) lines.push(`- **Reason:** ${reason}`);
1962
+ lines.push("");
1743
1963
  try {
1744
- const result = query({
1745
- prompt: opts.prompt,
1746
- options: {
1747
- model: opts.model.model,
1748
- cwd: opts.cwd,
1749
- allowedTools: opts.allowedToolsOverride ?? DEFAULT_ALLOWED_TOOLS,
1750
- permissionMode: opts.permissionModeOverride ?? "acceptEdits",
1751
- env
1752
- }
1753
- });
1754
- for await (const msg of result) {
1755
- try {
1756
- fullLog.write(JSON.stringify(msg) + "\n");
1757
- } catch {
1758
- }
1759
- const line = renderEvent(msg, { verbose: opts.verbose, quiet: opts.quiet });
1760
- if (line) process.stdout.write(line + "\n");
1761
- const m = msg;
1762
- if (m.type === "result") {
1763
- if (m.subtype === "success") {
1764
- outcome = "completed";
1765
- finalText = (typeof m.result === "string" ? m.result : "").trim();
1766
- } else {
1767
- outcome = "failed";
1768
- errorMessage = `result subtype: ${m.subtype ?? "unknown"}`;
1769
- }
1770
- }
1771
- }
1772
- } catch (e) {
1773
- outcome = "failed";
1774
- errorMessage = e instanceof Error ? e.message : String(e);
1775
- } finally {
1776
- try {
1777
- fullLog.end();
1778
- } catch {
1779
- }
1964
+ fs9.appendFileSync(summaryPath, `${lines.join("\n")}
1965
+ `);
1966
+ } catch {
1780
1967
  }
1781
- return { outcome, finalText, error: errorMessage, ndjsonPath };
1782
- }
1968
+ };
1969
+
1970
+ // src/scripts/index.ts
1971
+ var preflightScripts = {
1972
+ runFlow,
1973
+ fixFlow,
1974
+ fixCiFlow,
1975
+ resolveFlow,
1976
+ loadConventions,
1977
+ loadCoverageRules,
1978
+ composePrompt
1979
+ };
1980
+ var postflightScripts = {
1981
+ parseAgentResult: parseAgentResult2,
1982
+ verify,
1983
+ checkCoverageWithRetry,
1984
+ commitAndPush: commitAndPush2,
1985
+ ensurePr: ensurePr2,
1986
+ postIssueComment: postIssueComment2,
1987
+ writeRunSummary
1988
+ };
1989
+ var allScriptNames = /* @__PURE__ */ new Set([
1990
+ ...Object.keys(preflightScripts),
1991
+ ...Object.keys(postflightScripts)
1992
+ ]);
1783
1993
 
1784
1994
  // src/tools.ts
1785
- import { execFileSync as execFileSync9 } from "child_process";
1995
+ import { execFileSync as execFileSync10 } from "child_process";
1786
1996
  function verifyCliTools(tools, cwd) {
1787
1997
  const out = [];
1788
1998
  for (const t of tools) out.push(verifyOne(t, cwd));
@@ -1815,121 +2025,13 @@ function verifyOne(tool, cwd) {
1815
2025
  }
1816
2026
  function runShell(cmd, cwd, timeoutMs = 3e4) {
1817
2027
  try {
1818
- execFileSync9("sh", ["-c", cmd], { cwd, stdio: "pipe", timeout: timeoutMs });
2028
+ execFileSync10("sh", ["-c", cmd], { cwd, stdio: "pipe", timeout: timeoutMs });
1819
2029
  return true;
1820
2030
  } catch {
1821
2031
  return false;
1822
2032
  }
1823
2033
  }
1824
2034
 
1825
- // src/litellm.ts
1826
- import * as fs8 from "fs";
1827
- import * as os from "os";
1828
- import * as path7 from "path";
1829
- import { execFileSync as execFileSync10, spawn as spawn2 } from "child_process";
1830
- async function checkLitellmHealth(url) {
1831
- try {
1832
- const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(3e3) });
1833
- return response.ok;
1834
- } catch {
1835
- return false;
1836
- }
1837
- }
1838
- function generateLitellmConfigYaml(model) {
1839
- const apiKeyVar = providerApiKeyEnvVar(model.provider);
1840
- return [
1841
- "model_list:",
1842
- ` - model_name: ${model.model}`,
1843
- ` litellm_params:`,
1844
- ` model: ${model.provider}/${model.model}`,
1845
- ` api_key: os.environ/${apiKeyVar}`,
1846
- "",
1847
- "litellm_settings:",
1848
- " drop_params: true",
1849
- ""
1850
- ].join("\n");
1851
- }
1852
- async function startLitellmIfNeeded(model, projectDir, url = LITELLM_DEFAULT_URL) {
1853
- if (!needsLitellmProxy(model)) return null;
1854
- if (await checkLitellmHealth(url)) {
1855
- return { url, kill: () => {
1856
- } };
1857
- }
1858
- let cmd = "litellm";
1859
- let args;
1860
- try {
1861
- execFileSync10("which", ["litellm"], { timeout: 3e3, stdio: "pipe" });
1862
- } catch {
1863
- try {
1864
- execFileSync10("python3", ["-c", "import litellm"], { timeout: 1e4, stdio: "pipe" });
1865
- cmd = "python3";
1866
- } catch {
1867
- throw new Error("litellm not installed \u2014 run: pip install 'litellm[proxy]'");
1868
- }
1869
- }
1870
- const configPath = path7.join(os.tmpdir(), `kody2-litellm-${Date.now()}.yaml`);
1871
- fs8.writeFileSync(configPath, generateLitellmConfigYaml(model));
1872
- const portMatch = url.match(/:(\d+)/);
1873
- const port = portMatch ? portMatch[1] : "4000";
1874
- args = cmd === "litellm" ? ["--config", configPath, "--port", port] : ["-m", "litellm", "--config", configPath, "--port", port];
1875
- const dotenvVars = readDotenvApiKeys(projectDir);
1876
- const logPath = path7.join(os.tmpdir(), `kody2-litellm-${Date.now()}.log`);
1877
- const outFd = fs8.openSync(logPath, "w");
1878
- const child = spawn2(cmd, args, {
1879
- stdio: ["ignore", outFd, outFd],
1880
- detached: true,
1881
- env: stripBlockingEnv({ ...process.env, ...dotenvVars })
1882
- });
1883
- fs8.closeSync(outFd);
1884
- for (let i = 0; i < 30; i++) {
1885
- await new Promise((r) => setTimeout(r, 2e3));
1886
- if (await checkLitellmHealth(url)) {
1887
- return { url, kill: () => {
1888
- try {
1889
- child.kill();
1890
- } catch {
1891
- }
1892
- } };
1893
- }
1894
- }
1895
- let logTail = "";
1896
- try {
1897
- logTail = fs8.readFileSync(logPath, "utf-8").slice(-2e3);
1898
- } catch {
1899
- }
1900
- try {
1901
- child.kill();
1902
- } catch {
1903
- }
1904
- throw new Error(`LiteLLM proxy failed to start within 60s. Log tail:
1905
- ${logTail}`);
1906
- }
1907
- function readDotenvApiKeys(projectDir) {
1908
- const dotenvPath = path7.join(projectDir, ".env");
1909
- if (!fs8.existsSync(dotenvPath)) return {};
1910
- const result = {};
1911
- for (const rawLine of fs8.readFileSync(dotenvPath, "utf-8").split("\n")) {
1912
- const line = rawLine.trim();
1913
- if (!line || line.startsWith("#")) continue;
1914
- const match = line.match(/^([A-Z_][A-Z0-9_]*_API_KEY)=(.*)$/);
1915
- if (!match) continue;
1916
- let value = match[2].trim();
1917
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1918
- value = value.slice(1, -1);
1919
- }
1920
- const commentIdx = value.indexOf(" #");
1921
- if (commentIdx !== -1) value = value.slice(0, commentIdx).trim();
1922
- if (value) result[match[1]] = value;
1923
- }
1924
- return result;
1925
- }
1926
- function stripBlockingEnv(env) {
1927
- const out = { ...env };
1928
- delete out.DATABASE_URL;
1929
- delete out.AI_BASE_URL;
1930
- return out;
1931
- }
1932
-
1933
2035
  // src/executor.ts
1934
2036
  async function runExecutable(profileName, input) {
1935
2037
  const profilePath = resolveProfilePath(profileName);
@@ -1960,7 +2062,10 @@ async function runExecutable(profileName, input) {
1960
2062
  try {
1961
2063
  litellm = await startLitellmIfNeeded(model, input.cwd);
1962
2064
  } catch (err) {
1963
- return finish({ exitCode: 99, reason: `litellm startup failed: ${err instanceof Error ? err.message : String(err)}` });
2065
+ return finish({
2066
+ exitCode: 99,
2067
+ reason: `litellm startup failed: ${err instanceof Error ? err.message : String(err)}`
2068
+ });
1964
2069
  }
1965
2070
  const ctx = {
1966
2071
  args,
@@ -1981,7 +2086,8 @@ async function runExecutable(profileName, input) {
1981
2086
  quiet: input.quiet,
1982
2087
  ndjsonDir,
1983
2088
  allowedToolsOverride: profile.claudeCode.tools,
1984
- permissionModeOverride: profile.claudeCode.permissionMode
2089
+ permissionModeOverride: profile.claudeCode.permissionMode,
2090
+ mcpServers: profile.claudeCode.mcpServers
1985
2091
  });
1986
2092
  ctx.data.__invokeAgent = invokeAgent;
1987
2093
  try {
@@ -2006,9 +2112,21 @@ async function runExecutable(profileName, input) {
2006
2112
  if (!shouldRun(entry, ctx)) continue;
2007
2113
  const fn = postflightScripts[entry.script];
2008
2114
  if (!fn) return finish({ exitCode: 99, reason: `postflight script not registered: ${entry.script}` });
2009
- await fn(ctx, profile, agentResult);
2115
+ try {
2116
+ await fn(ctx, profile, agentResult);
2117
+ } catch (err) {
2118
+ const msg = err instanceof Error ? err.message : String(err);
2119
+ process.stderr.write(`[kody2] postflight script "${entry.script}" crashed: ${msg}
2120
+ `);
2121
+ if (!ctx.output.reason) ctx.output.reason = `postflight ${entry.script} crashed: ${msg}`;
2122
+ if (ctx.output.exitCode === 0) ctx.output.exitCode = 99;
2123
+ }
2010
2124
  }
2011
- return finish(ctx.output);
2125
+ return finish({
2126
+ exitCode: ctx.output.exitCode ?? 0,
2127
+ prUrl: ctx.output.prUrl,
2128
+ reason: ctx.output.reason
2129
+ });
2012
2130
  } finally {
2013
2131
  try {
2014
2132
  litellm?.kill();
@@ -2027,7 +2145,7 @@ function resolveProfilePath(profileName) {
2027
2145
  // fallback
2028
2146
  ];
2029
2147
  for (const c of candidates) {
2030
- if (fs9.existsSync(c)) return c;
2148
+ if (fs10.existsSync(c)) return c;
2031
2149
  }
2032
2150
  return candidates[0];
2033
2151
  }
@@ -2105,12 +2223,12 @@ function finish(out) {
2105
2223
  }
2106
2224
 
2107
2225
  // src/kody2-cli.ts
2108
- import * as fs11 from "fs";
2109
- import * as path9 from "path";
2110
2226
  import { execFileSync as execFileSync11 } from "child_process";
2227
+ import * as fs12 from "fs";
2228
+ import * as path9 from "path";
2111
2229
 
2112
2230
  // src/dispatch.ts
2113
- import * as fs10 from "fs";
2231
+ import * as fs11 from "fs";
2114
2232
  function autoDispatch(explicit) {
2115
2233
  if (explicit?.mode && explicit.target) {
2116
2234
  return {
@@ -2120,10 +2238,10 @@ function autoDispatch(explicit) {
2120
2238
  }
2121
2239
  const eventName = process.env.GITHUB_EVENT_NAME;
2122
2240
  const eventPath = process.env.GITHUB_EVENT_PATH;
2123
- if (!eventName || !eventPath || !fs10.existsSync(eventPath)) return null;
2241
+ if (!eventName || !eventPath || !fs11.existsSync(eventPath)) return null;
2124
2242
  let event = {};
2125
2243
  try {
2126
- event = JSON.parse(fs10.readFileSync(eventPath, "utf-8"));
2244
+ event = JSON.parse(fs11.readFileSync(eventPath, "utf-8"));
2127
2245
  } catch {
2128
2246
  return null;
2129
2247
  }
@@ -2243,9 +2361,9 @@ function resolveAuthToken(env = process.env) {
2243
2361
  return token;
2244
2362
  }
2245
2363
  function detectPackageManager(cwd) {
2246
- if (fs11.existsSync(path9.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2247
- if (fs11.existsSync(path9.join(cwd, "yarn.lock"))) return "yarn";
2248
- if (fs11.existsSync(path9.join(cwd, "bun.lockb"))) return "bun";
2364
+ if (fs12.existsSync(path9.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
2365
+ if (fs12.existsSync(path9.join(cwd, "yarn.lock"))) return "yarn";
2366
+ if (fs12.existsSync(path9.join(cwd, "bun.lockb"))) return "bun";
2249
2367
  return "npm";
2250
2368
  }
2251
2369
  function shellOut(cmd, args, cwd, stream = true) {
@@ -2325,13 +2443,13 @@ function postFailureTail(issueNumber, cwd, reason) {
2325
2443
  const logPath = path9.join(cwd, ".kody2", "last-run.jsonl");
2326
2444
  let tail = "";
2327
2445
  try {
2328
- if (fs11.existsSync(logPath)) {
2329
- const content = fs11.readFileSync(logPath, "utf-8");
2446
+ if (fs12.existsSync(logPath)) {
2447
+ const content = fs12.readFileSync(logPath, "utf-8");
2330
2448
  tail = content.slice(-3e3);
2331
2449
  }
2332
2450
  } catch {
2333
2451
  }
2334
- const body = tail ? `\u26A0\uFE0F kody2 preflight failed: ${truncate(reason, 500)}
2452
+ const body = tail ? `\u26A0\uFE0F kody2 preflight failed: ${truncate2(reason, 500)}
2335
2453
 
2336
2454
  <details><summary>Last-run log tail</summary>
2337
2455
 
@@ -2339,7 +2457,7 @@ function postFailureTail(issueNumber, cwd, reason) {
2339
2457
  ${tail}
2340
2458
  \`\`\`
2341
2459
 
2342
- </details>` : `\u26A0\uFE0F kody2 preflight failed: ${truncate(reason, 1500)}`;
2460
+ </details>` : `\u26A0\uFE0F kody2 preflight failed: ${truncate2(reason, 1500)}`;
2343
2461
  try {
2344
2462
  postIssueComment(issueNumber, body, cwd);
2345
2463
  } catch {
@@ -2359,7 +2477,8 @@ async function runCi(argv) {
2359
2477
  if (args.errors.length > 0 && !args.errors.includes("__HELP__")) {
2360
2478
  for (const e of args.errors) process.stderr.write(`error: ${e}
2361
2479
  `);
2362
- process.stderr.write("\n" + CI_HELP);
2480
+ process.stderr.write(`
2481
+ ${CI_HELP}`);
2363
2482
  return 64;
2364
2483
  }
2365
2484
  const cwd = args.cwd ? path9.resolve(args.cwd) : process.cwd();
@@ -2430,12 +2549,74 @@ async function runCi(argv) {
2430
2549
  const msg = err instanceof Error ? err.message : String(err);
2431
2550
  process.stderr.write(`[kody2] run crashed: ${msg}
2432
2551
  `);
2433
- if (err instanceof Error && err.stack) process.stderr.write(err.stack + "\n");
2552
+ if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}
2553
+ `);
2434
2554
  postFailureTail(issueNumber, cwd, `run crashed: ${msg}`);
2435
2555
  return 99;
2436
2556
  }
2437
2557
  }
2438
2558
 
2559
+ // src/registry.ts
2560
+ import * as fs13 from "fs";
2561
+ import * as path10 from "path";
2562
+ function getExecutablesRoot() {
2563
+ const here = path10.dirname(new URL(import.meta.url).pathname);
2564
+ const candidates = [
2565
+ path10.join(here, "executables"),
2566
+ // dev: src/
2567
+ path10.join(here, "..", "executables"),
2568
+ // built: dist/bin → dist/executables
2569
+ path10.join(here, "..", "src", "executables")
2570
+ // fallback
2571
+ ];
2572
+ for (const c of candidates) {
2573
+ if (fs13.existsSync(c) && fs13.statSync(c).isDirectory()) return c;
2574
+ }
2575
+ return candidates[0];
2576
+ }
2577
+ function listExecutables(root = getExecutablesRoot()) {
2578
+ if (!fs13.existsSync(root)) return [];
2579
+ const entries = fs13.readdirSync(root, { withFileTypes: true });
2580
+ const out = [];
2581
+ for (const ent of entries) {
2582
+ if (!ent.isDirectory()) continue;
2583
+ const profilePath = path10.join(root, ent.name, "profile.json");
2584
+ if (fs13.existsSync(profilePath) && fs13.statSync(profilePath).isFile()) {
2585
+ out.push({ name: ent.name, profilePath });
2586
+ }
2587
+ }
2588
+ return out.sort((a, b) => a.name.localeCompare(b.name));
2589
+ }
2590
+ function hasExecutable(name, root = getExecutablesRoot()) {
2591
+ if (!isSafeName(name)) return false;
2592
+ const profilePath = path10.join(root, name, "profile.json");
2593
+ return fs13.existsSync(profilePath) && fs13.statSync(profilePath).isFile();
2594
+ }
2595
+ function isSafeName(name) {
2596
+ return /^[a-z][a-z0-9-]*$/.test(name) && !name.includes("..");
2597
+ }
2598
+ function parseGenericFlags(argv) {
2599
+ const args = {};
2600
+ const positional = [];
2601
+ for (let i = 0; i < argv.length; i++) {
2602
+ const arg = argv[i];
2603
+ if (!arg.startsWith("--")) {
2604
+ positional.push(arg);
2605
+ continue;
2606
+ }
2607
+ const key = arg.slice(2);
2608
+ const next = argv[i + 1];
2609
+ if (next !== void 0 && !next.startsWith("--")) {
2610
+ args[key] = next;
2611
+ i++;
2612
+ } else {
2613
+ args[key] = true;
2614
+ }
2615
+ }
2616
+ if (positional.length > 0) args._ = positional;
2617
+ return args;
2618
+ }
2619
+
2439
2620
  // src/entry.ts
2440
2621
  var HELP_TEXT = `kody2 \u2014 single-session autonomous engineer
2441
2622
 
@@ -2452,10 +2633,10 @@ All commands dispatch to the Build executable with a specific mode. The
2452
2633
  executable is defined by \`src/executables/build/profile.json\`.
2453
2634
 
2454
2635
  Exit codes:
2455
- 0 success (PR opened, verify passed)
2636
+ 0 success (PR opened, verify passed \u2014 or resolve produced a merge commit)
2456
2637
  1 agent reported FAILED (draft PR opened)
2457
- 2 verify failed (draft PR opened)
2458
- 3 no commits to ship
2638
+ 2 verify failed (draft PR opened) \u2014 skipped in resolve mode
2639
+ 3 no commits to ship (also the resolve clean-merge short-circuit)
2459
2640
  4 PR creation failed
2460
2641
  5 uncommitted changes on target branch
2461
2642
  64 invalid CLI args
@@ -2475,7 +2656,18 @@ function parseArgs(argv) {
2475
2656
  parseCommandArgs(cmd, argv.slice(1), result);
2476
2657
  return result;
2477
2658
  }
2478
- result.errors.push(`unknown command: ${cmd}`);
2659
+ if (hasExecutable(cmd)) {
2660
+ result.command = "__executable__";
2661
+ result.executableName = cmd;
2662
+ result.cliArgs = parseGenericFlags(argv.slice(1));
2663
+ if (typeof result.cliArgs.cwd === "string") result.cwd = result.cliArgs.cwd;
2664
+ if (result.cliArgs.verbose === true) result.verbose = true;
2665
+ if (result.cliArgs.quiet === true) result.quiet = true;
2666
+ return result;
2667
+ }
2668
+ const discovered = listExecutables().map((e) => e.name).filter((n) => n !== "build");
2669
+ const available = ["run", "fix", "fix-ci", "resolve", "ci", "help", "version", ...discovered];
2670
+ result.errors.push(`unknown command: ${cmd} (available: ${available.join(", ")})`);
2479
2671
  return result;
2480
2672
  }
2481
2673
  function parseCommandArgs(cmd, rest, result) {
@@ -2510,7 +2702,8 @@ async function main(argv = process.argv.slice(2)) {
2510
2702
  if (args.errors.length > 0) {
2511
2703
  for (const e of args.errors) process.stderr.write(`error: ${e}
2512
2704
  `);
2513
- process.stderr.write("\n" + HELP_TEXT);
2705
+ process.stderr.write(`
2706
+ ${HELP_TEXT}`);
2514
2707
  return 64;
2515
2708
  }
2516
2709
  if (args.command === "help") {
@@ -2518,7 +2711,8 @@ async function main(argv = process.argv.slice(2)) {
2518
2711
  return 0;
2519
2712
  }
2520
2713
  if (args.command === "version") {
2521
- process.stdout.write("kody2 0.2.1\n");
2714
+ process.stdout.write(`kody2 ${package_default.version}
2715
+ `);
2522
2716
  return 0;
2523
2717
  }
2524
2718
  if (args.command === "ci") {
@@ -2528,7 +2722,8 @@ async function main(argv = process.argv.slice(2)) {
2528
2722
  const msg = err instanceof Error ? err.message : String(err);
2529
2723
  process.stderr.write(`[kody2] fatal: ${msg}
2530
2724
  `);
2531
- if (err instanceof Error && err.stack) process.stderr.write(err.stack + "\n");
2725
+ if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}
2726
+ `);
2532
2727
  return 99;
2533
2728
  }
2534
2729
  }
@@ -2544,6 +2739,27 @@ async function main(argv = process.argv.slice(2)) {
2544
2739
  `);
2545
2740
  return 99;
2546
2741
  }
2742
+ if (args.command === "__executable__") {
2743
+ try {
2744
+ const result = await runExecutable(args.executableName, {
2745
+ cliArgs: args.cliArgs ?? {},
2746
+ cwd,
2747
+ config,
2748
+ verbose: args.verbose,
2749
+ quiet: args.quiet
2750
+ });
2751
+ return result.exitCode;
2752
+ } catch (err) {
2753
+ const msg = err instanceof Error ? err.message : String(err);
2754
+ process.stderr.write(`[kody2] ${args.executableName} crashed: ${msg}
2755
+ `);
2756
+ if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}
2757
+ `);
2758
+ process.stdout.write(`PR_URL=FAILED: ${args.executableName} crashed: ${msg}
2759
+ `);
2760
+ return 99;
2761
+ }
2762
+ }
2547
2763
  const cliArgs = { mode: args.command };
2548
2764
  if (args.issueNumber !== void 0) cliArgs.issue = args.issueNumber;
2549
2765
  if (args.prNumber !== void 0) cliArgs.pr = args.prNumber;
@@ -2562,7 +2778,8 @@ async function main(argv = process.argv.slice(2)) {
2562
2778
  const msg = err instanceof Error ? err.message : String(err);
2563
2779
  process.stderr.write(`[kody2] wrapper crashed: ${msg}
2564
2780
  `);
2565
- if (err instanceof Error && err.stack) process.stderr.write(err.stack + "\n");
2781
+ if (err instanceof Error && err.stack) process.stderr.write(`${err.stack}
2782
+ `);
2566
2783
  process.stdout.write(`PR_URL=FAILED: wrapper crashed: ${msg}
2567
2784
  `);
2568
2785
  return 99;