@kody-ade/kody-engine 0.4.81 → 0.4.83

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/kody.js CHANGED
@@ -460,16 +460,16 @@ var init_issue = __esm({
460
460
  });
461
461
 
462
462
  // src/prompt.ts
463
- import * as fs14 from "fs";
464
- import * as path12 from "path";
463
+ import * as fs16 from "fs";
464
+ import * as path14 from "path";
465
465
  function loadProjectConventions(projectDir) {
466
466
  const out = [];
467
467
  for (const rel of CONVENTION_FILES) {
468
- const abs = path12.join(projectDir, rel);
469
- if (!fs14.existsSync(abs)) continue;
468
+ const abs = path14.join(projectDir, rel);
469
+ if (!fs16.existsSync(abs)) continue;
470
470
  let content;
471
471
  try {
472
- content = fs14.readFileSync(abs, "utf-8");
472
+ content = fs16.readFileSync(abs, "utf-8");
473
473
  } catch {
474
474
  continue;
475
475
  }
@@ -617,28 +617,28 @@ var loadMemoryContext_exports = {};
617
617
  __export(loadMemoryContext_exports, {
618
618
  loadMemoryContext: () => loadMemoryContext
619
619
  });
620
- import * as fs28 from "fs";
621
- import * as path26 from "path";
620
+ import * as fs30 from "fs";
621
+ import * as path28 from "path";
622
622
  function collectPages(memoryAbs) {
623
623
  const out = [];
624
624
  walkMd(memoryAbs, (file) => {
625
625
  let stat;
626
626
  try {
627
- stat = fs28.statSync(file);
627
+ stat = fs30.statSync(file);
628
628
  } catch {
629
629
  return;
630
630
  }
631
631
  let raw;
632
632
  try {
633
- raw = fs28.readFileSync(file, "utf-8");
633
+ raw = fs30.readFileSync(file, "utf-8");
634
634
  } catch {
635
635
  return;
636
636
  }
637
637
  const fm = raw.match(/^---\s*\n([\s\S]*?)\n---/);
638
- const title = fm?.[1]?.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? path26.basename(file, ".md");
638
+ const title = fm?.[1]?.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? path28.basename(file, ".md");
639
639
  const updated = fm?.[1]?.match(/^updated:\s*([0-9T:.+\-Z]+)/m)?.[1]?.trim() ?? "";
640
640
  out.push({
641
- relPath: path26.relative(memoryAbs, file),
641
+ relPath: path28.relative(memoryAbs, file),
642
642
  title,
643
643
  updated,
644
644
  content: raw.length > PER_PAGE_MAX_BYTES ? raw.slice(0, PER_PAGE_MAX_BYTES) + TRUNCATED_SUFFIX : raw,
@@ -706,16 +706,16 @@ function walkMd(root, visit) {
706
706
  const dir = stack.pop();
707
707
  let names;
708
708
  try {
709
- names = fs28.readdirSync(dir);
709
+ names = fs30.readdirSync(dir);
710
710
  } catch {
711
711
  continue;
712
712
  }
713
713
  for (const name of names) {
714
714
  if (name.startsWith(".")) continue;
715
- const full = path26.join(dir, name);
715
+ const full = path28.join(dir, name);
716
716
  let stat;
717
717
  try {
718
- stat = fs28.statSync(full);
718
+ stat = fs30.statSync(full);
719
719
  } catch {
720
720
  continue;
721
721
  }
@@ -738,8 +738,8 @@ var init_loadMemoryContext = __esm({
738
738
  TRUNCATED_SUFFIX = "\n\n\u2026 (truncated)";
739
739
  loadMemoryContext = async (ctx) => {
740
740
  if (typeof ctx.data.memoryContext === "string") return;
741
- const memoryAbs = path26.join(ctx.cwd, MEMORY_DIR_RELATIVE);
742
- if (!fs28.existsSync(memoryAbs)) {
741
+ const memoryAbs = path28.join(ctx.cwd, MEMORY_DIR_RELATIVE);
742
+ if (!fs30.existsSync(memoryAbs)) {
743
743
  ctx.data.memoryContext = "";
744
744
  return;
745
745
  }
@@ -868,7 +868,7 @@ var init_loadPriorArt = __esm({
868
868
  // package.json
869
869
  var package_default = {
870
870
  name: "@kody-ade/kody-engine",
871
- version: "0.4.81",
871
+ version: "0.4.83",
872
872
  description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
873
873
  license: "MIT",
874
874
  type: "module",
@@ -3702,529 +3702,982 @@ var advanceFlow = async (ctx, profile) => {
3702
3702
  }
3703
3703
  };
3704
3704
 
3705
- // src/scripts/buildSyntheticPlugin.ts
3705
+ // src/scripts/brainServe.ts
3706
+ import { createServer } from "http";
3707
+ import * as fs14 from "fs";
3708
+ import * as path12 from "path";
3709
+
3710
+ // src/scripts/brainTurnLog.ts
3706
3711
  import * as fs13 from "fs";
3707
- import * as os3 from "os";
3708
3712
  import * as path11 from "path";
3709
- function getPluginsCatalogRoot() {
3710
- const here = path11.dirname(new URL(import.meta.url).pathname);
3711
- const candidates = [
3712
- path11.join(here, "..", "plugins"),
3713
- // dev: src/scripts → src/plugins
3714
- path11.join(here, "..", "..", "plugins"),
3715
- // built: dist/scripts → dist/plugins
3716
- path11.join(here, "..", "..", "src", "plugins")
3717
- // fallback
3718
- ];
3719
- for (const c of candidates) {
3720
- if (fs13.existsSync(c) && fs13.statSync(c).isDirectory()) return c;
3713
+ var live = /* @__PURE__ */ new Map();
3714
+ function eventsPath(dir, chatId) {
3715
+ return path11.join(dir, ".kody", "brain-events", `${chatId}.jsonl`);
3716
+ }
3717
+ function lastPersistedSeq(dir, chatId) {
3718
+ const p = eventsPath(dir, chatId);
3719
+ if (!fs13.existsSync(p)) return 0;
3720
+ const lines = fs13.readFileSync(p, "utf-8").split("\n").filter(Boolean);
3721
+ if (lines.length === 0) return 0;
3722
+ try {
3723
+ return JSON.parse(lines[lines.length - 1]).seq || 0;
3724
+ } catch {
3725
+ return 0;
3721
3726
  }
3722
- return candidates[0];
3723
3727
  }
3724
- var buildSyntheticPlugin = async (ctx, profile) => {
3725
- const cc = profile.claudeCode;
3726
- const needsSynthetic = cc.skills.length > 0 || cc.commands.length > 0 || cc.hooks.length > 0 || cc.subagents.length > 0;
3727
- if (!needsSynthetic) return;
3728
- const catalog = getPluginsCatalogRoot();
3729
- const runId = `${profile.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3730
- const root = path11.join(os3.tmpdir(), `kody-synth-${runId}`);
3731
- fs13.mkdirSync(path11.join(root, ".claude-plugin"), { recursive: true });
3732
- const resolvePart = (bucket, entry) => {
3733
- const local = path11.join(profile.dir, bucket, entry);
3734
- if (fs13.existsSync(local)) return local;
3735
- const central = path11.join(catalog, bucket, entry);
3736
- if (fs13.existsSync(central)) return central;
3737
- throw new Error(
3738
- `buildSyntheticPlugin: ${bucket} entry '${entry}' not found in executable dir (${profile.dir}/${bucket}/) or catalog (${catalog}/${bucket}/)`
3739
- );
3740
- };
3741
- if (cc.skills.length > 0) {
3742
- const dst = path11.join(root, "skills");
3743
- fs13.mkdirSync(dst, { recursive: true });
3744
- for (const name of cc.skills) {
3745
- copyDir(resolvePart("skills", name), path11.join(dst, name));
3728
+ function readSince(dir, chatId, since) {
3729
+ const p = eventsPath(dir, chatId);
3730
+ if (!fs13.existsSync(p)) return [];
3731
+ const out = [];
3732
+ for (const line of fs13.readFileSync(p, "utf-8").split("\n")) {
3733
+ if (!line) continue;
3734
+ try {
3735
+ const rec = JSON.parse(line);
3736
+ if (rec.seq > since) out.push(rec);
3737
+ } catch {
3746
3738
  }
3747
3739
  }
3748
- if (cc.commands.length > 0) {
3749
- const dst = path11.join(root, "commands");
3750
- fs13.mkdirSync(dst, { recursive: true });
3751
- for (const name of cc.commands) {
3752
- fs13.copyFileSync(resolvePart("commands", `${name}.md`), path11.join(dst, `${name}.md`));
3740
+ return out;
3741
+ }
3742
+ function isTerminal(event) {
3743
+ return event.type === "done" || event.type === "error";
3744
+ }
3745
+ function beginTurn(dir, chatId) {
3746
+ const existing = live.get(chatId);
3747
+ const seqFloor = existing ? existing.seq : lastPersistedSeq(dir, chatId);
3748
+ const turn = (existing?.turn ?? 0) + 1;
3749
+ const state = {
3750
+ seq: seqFloor,
3751
+ turn,
3752
+ status: "running",
3753
+ terminal: null,
3754
+ subscribers: /* @__PURE__ */ new Set()
3755
+ };
3756
+ live.set(chatId, state);
3757
+ const p = eventsPath(dir, chatId);
3758
+ fs13.mkdirSync(path11.dirname(p), { recursive: true });
3759
+ return (event) => {
3760
+ state.seq += 1;
3761
+ const rec = { seq: state.seq, turn, ts: Date.now(), event };
3762
+ try {
3763
+ fs13.appendFileSync(p, JSON.stringify(rec) + "\n");
3764
+ } catch (err) {
3765
+ process.stderr.write(
3766
+ `[brain-turn-log] append failed for ${chatId}: ${err instanceof Error ? err.message : String(err)}
3767
+ `
3768
+ );
3753
3769
  }
3754
- }
3755
- if (cc.subagents.length > 0) {
3756
- const dst = path11.join(root, "agents");
3757
- fs13.mkdirSync(dst, { recursive: true });
3758
- for (const name of cc.subagents) {
3759
- fs13.copyFileSync(resolvePart("agents", `${name}.md`), path11.join(dst, `${name}.md`));
3770
+ for (const fn of state.subscribers) {
3771
+ try {
3772
+ fn(rec);
3773
+ } catch {
3774
+ }
3760
3775
  }
3761
- }
3762
- if (cc.hooks.length > 0) {
3763
- const dst = path11.join(root, "hooks");
3764
- fs13.mkdirSync(dst, { recursive: true });
3765
- const merged = { hooks: {} };
3766
- for (const name of cc.hooks) {
3767
- const src = resolvePart("hooks", `${name}.json`);
3768
- const parsed = JSON.parse(fs13.readFileSync(src, "utf-8"));
3769
- for (const [event, entries] of Object.entries(parsed.hooks ?? {})) {
3770
- if (!Array.isArray(entries)) continue;
3771
- if (!merged.hooks[event]) merged.hooks[event] = [];
3772
- merged.hooks[event].push(...entries);
3776
+ if (isTerminal(event)) {
3777
+ state.status = "ended";
3778
+ state.terminal = rec;
3779
+ const subs = [...state.subscribers];
3780
+ state.subscribers.clear();
3781
+ for (const fn of subs) {
3782
+ try {
3783
+ fn(null);
3784
+ } catch {
3785
+ }
3773
3786
  }
3774
3787
  }
3775
- fs13.writeFileSync(path11.join(dst, "hooks.json"), `${JSON.stringify(merged, null, 2)}
3776
- `);
3777
- }
3778
- const manifest = {
3779
- name: `kody-synth-${profile.name}`,
3780
- version: "1.0.0",
3781
- description: `Synthetic plugin assembled by Kody for profile '${profile.name}' at runtime.`
3782
3788
  };
3783
- if (cc.skills.length > 0) manifest.skills = ["./skills/"];
3784
- if (cc.commands.length > 0) manifest.commands = ["./commands/"];
3785
- if (cc.subagents.length > 0) manifest.agents = cc.subagents.map((n) => `./agents/${n}.md`);
3786
- fs13.writeFileSync(path11.join(root, ".claude-plugin", "plugin.json"), `${JSON.stringify(manifest, null, 2)}
3787
- `);
3788
- ctx.data.syntheticPluginPath = root;
3789
- };
3790
- function copyDir(src, dst) {
3791
- fs13.mkdirSync(dst, { recursive: true });
3792
- for (const ent of fs13.readdirSync(src, { withFileTypes: true })) {
3793
- const s = path11.join(src, ent.name);
3794
- const d = path11.join(dst, ent.name);
3795
- if (ent.isDirectory()) copyDir(s, d);
3796
- else if (ent.isFile()) fs13.copyFileSync(s, d);
3797
- }
3798
- }
3799
-
3800
- // src/coverage.ts
3801
- import { execFileSync as execFileSync8 } from "child_process";
3802
- function patternToRegex(pattern) {
3803
- let s = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3804
- s = s.replace(/\*\*\//g, "\xA7S").replace(/\*\*/g, "\xA7A").replace(/\*/g, "[^/]*");
3805
- s = s.replace(/§S/g, "(?:.*/)?").replace(/§A/g, ".*");
3806
- return new RegExp(`^${s}$`);
3807
- }
3808
- function renderSiblingPath(file, requireSibling) {
3809
- const lastSlash = file.lastIndexOf("/");
3810
- const dir = lastSlash === -1 ? "" : file.slice(0, lastSlash + 1);
3811
- const base = lastSlash === -1 ? file : file.slice(lastSlash + 1);
3812
- const name = base.replace(/\.[^.]+$/, "");
3813
- const ext = base.match(/\.[^.]+$/)?.[0] ?? "";
3814
- const sibling = requireSibling.replace(/\{name\}/g, name).replace(/\{ext\}/g, ext);
3815
- return dir + sibling;
3816
3789
  }
3817
- function safeGit(args, cwd) {
3790
+ function endTurnIfUnterminated(dir, chatId, errMessage) {
3791
+ const state = live.get(chatId);
3792
+ if (!state || state.status === "ended") return;
3793
+ state.seq += 1;
3794
+ const rec = {
3795
+ seq: state.seq,
3796
+ turn: state.turn,
3797
+ ts: Date.now(),
3798
+ event: { type: "error", error: errMessage || "turn ended unexpectedly", chatId }
3799
+ };
3818
3800
  try {
3819
- return execFileSync8("git", args, { encoding: "utf-8", cwd, env: { ...process.env, HUSKY: "0" } }).trim();
3801
+ fs13.appendFileSync(eventsPath(dir, chatId), JSON.stringify(rec) + "\n");
3820
3802
  } catch {
3821
- return "";
3822
3803
  }
3823
- }
3824
- function getAddedFiles(baseBranch, cwd) {
3825
- const committed = safeGit(["diff", "--name-only", "--diff-filter=A", `origin/${baseBranch}...HEAD`], cwd);
3826
- const untracked = safeGit(["ls-files", "--others", "--exclude-standard"], cwd);
3827
- const set = /* @__PURE__ */ new Set();
3828
- for (const f of committed.split("\n")) if (f) set.add(f);
3829
- for (const f of untracked.split("\n")) if (f) set.add(f);
3830
- return [...set];
3831
- }
3832
- function checkCoverage(addedFiles, requirements) {
3833
- if (requirements.length === 0) return [];
3834
- const addedSet = new Set(addedFiles);
3835
- const misses = [];
3836
- for (const file of addedFiles) {
3837
- if (/\.(test|spec)\./.test(file)) continue;
3838
- for (const req of requirements) {
3839
- const re = patternToRegex(req.pattern);
3840
- if (!re.test(file)) continue;
3841
- const expected = renderSiblingPath(file, req.requireSibling);
3842
- if (!addedSet.has(expected)) {
3843
- misses.push({ file, expectedTest: expected });
3844
- }
3845
- break;
3804
+ state.status = "ended";
3805
+ state.terminal = rec;
3806
+ const subs = [...state.subscribers];
3807
+ state.subscribers.clear();
3808
+ for (const fn of subs) {
3809
+ try {
3810
+ fn(rec);
3811
+ fn(null);
3812
+ } catch {
3846
3813
  }
3847
3814
  }
3848
- return misses;
3849
- }
3850
- function formatMissesForFeedback(misses) {
3851
- if (misses.length === 0) return "";
3852
- const lines = ["The following files were added without a sibling test file:"];
3853
- for (const m of misses) lines.push(`- \`${m.file}\` \u2192 expected \`${m.expectedTest}\``);
3854
- lines.push("");
3855
- lines.push(
3856
- "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."
3857
- );
3858
- return lines.join("\n");
3859
3815
  }
3860
-
3861
- // src/scripts/checkCoverageWithRetry.ts
3862
- init_prompt();
3863
- var checkCoverageWithRetry = async (ctx) => {
3864
- const reqs = ctx.data.coverageRules ?? [];
3865
- if (reqs.length === 0) {
3866
- ctx.data.coverageMisses = [];
3867
- return;
3868
- }
3869
- if (!ctx.data.agentDone) {
3870
- ctx.data.coverageMisses = [];
3871
- return;
3816
+ function subscribe(dir, chatId, since, onRecord, onClose) {
3817
+ const backlog = readSince(dir, chatId, since);
3818
+ for (const rec of backlog) onRecord(rec);
3819
+ const lastReplayed = backlog.length ? backlog[backlog.length - 1] : null;
3820
+ if (lastReplayed && isTerminal(lastReplayed.event)) {
3821
+ onClose();
3822
+ return () => {
3823
+ };
3872
3824
  }
3873
- const misses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
3874
- if (misses.length === 0) {
3875
- ctx.data.coverageMisses = [];
3876
- return;
3825
+ const state = live.get(chatId);
3826
+ if (state && state.status === "running") {
3827
+ const fn = (rec) => {
3828
+ if (rec === null) {
3829
+ state.subscribers.delete(fn);
3830
+ onClose();
3831
+ return;
3832
+ }
3833
+ if (rec.seq > since) onRecord(rec);
3834
+ };
3835
+ state.subscribers.add(fn);
3836
+ return () => {
3837
+ state.subscribers.delete(fn);
3838
+ };
3877
3839
  }
3878
- const invoker = ctx.data.__invokeAgent;
3879
- const basePrompt = ctx.data.prompt;
3880
- if (!invoker || !basePrompt) {
3881
- ctx.data.coverageMisses = misses;
3882
- return;
3840
+ if (state && state.status === "ended" && state.terminal) {
3841
+ if (state.terminal.seq > since && !lastReplayed) onRecord(state.terminal);
3842
+ onClose();
3843
+ return () => {
3844
+ };
3883
3845
  }
3884
- process.stderr.write(`[kody] coverage check found ${misses.length} missing test(s); retrying agent once
3885
- `);
3886
- const retryPrompt = `${basePrompt}
3887
-
3888
- # Coverage failure (retry)
3889
- ${formatMissesForFeedback(misses)}`;
3890
- const retry = await invoker(retryPrompt);
3891
- const retryParsed = parseAgentResult(retry.finalText);
3892
- if (retry.outcome === "completed" && retryParsed.done) {
3893
- ctx.data.agentDone = true;
3894
- ctx.data.commitMessage = retryParsed.commitMessage || ctx.data.commitMessage;
3895
- ctx.data.prSummary = retryParsed.prSummary || ctx.data.prSummary;
3896
- }
3897
- const finalMisses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
3898
- ctx.data.coverageMisses = finalMisses;
3899
- };
3900
-
3901
- // src/scripts/classifyByLabel.ts
3902
- var VALID_CLASSES = /* @__PURE__ */ new Set(["feature", "bug", "spec", "chore"]);
3903
- var classifyByLabel = async (ctx) => {
3904
- const issue = ctx.data.issue;
3905
- const labels = issue?.labels;
3906
- if (!labels || labels.length === 0) return;
3907
- const cfgMap = ctx.config.classify?.labelMap;
3908
- const map = cfgMap ?? defaultLabelMap();
3909
- for (const label of labels) {
3910
- const candidate = map[label.toLowerCase()];
3911
- if (candidate && VALID_CLASSES.has(candidate)) {
3912
- ctx.data.classification = candidate;
3913
- ctx.data.classificationSource = "label";
3914
- ctx.data.classificationReason = `label \`${label}\` \u2192 ${candidate}`;
3915
- ctx.skipAgent = true;
3916
- return;
3917
- }
3846
+ if (lastReplayed) {
3847
+ onRecord({
3848
+ seq: lastReplayed.seq + 1,
3849
+ turn: lastReplayed.turn,
3850
+ ts: Date.now(),
3851
+ event: {
3852
+ type: "error",
3853
+ error: "stream interrupted (server restarted mid-reply) \u2014 resend your message",
3854
+ chatId
3855
+ }
3856
+ });
3918
3857
  }
3919
- };
3920
- function defaultLabelMap() {
3921
- return {
3922
- bug: "bug",
3923
- enhancement: "bug",
3924
- refactor: "feature",
3925
- feature: "feature",
3926
- performance: "feature",
3927
- rfc: "spec",
3928
- design: "spec",
3929
- spec: "spec",
3930
- docs: "chore",
3931
- chore: "chore",
3932
- dependencies: "chore"
3858
+ onClose();
3859
+ return () => {
3933
3860
  };
3934
3861
  }
3862
+ function getLastSeq(dir, chatId) {
3863
+ const state = live.get(chatId);
3864
+ if (state) return state.seq;
3865
+ return lastPersistedSeq(dir, chatId);
3866
+ }
3935
3867
 
3936
- // src/scripts/commitAndPush.ts
3937
- import * as fs15 from "fs";
3938
- import * as path13 from "path";
3939
- init_events();
3940
- var DEFAULT_COMMIT_MESSAGE = "chore: kody changes";
3941
- function sentinelPathForStage(cwd, profileName) {
3942
- const runId = resolveRunId();
3943
- return path13.join(cwd, ".kody", "runs", runId, `commit-${profileName}.lock`);
3868
+ // src/scripts/brainServe.ts
3869
+ var DEFAULT_PORT = 8080;
3870
+ function getApiKey() {
3871
+ const key = (process.env.BRAIN_API_KEY ?? "").trim();
3872
+ if (!key) {
3873
+ throw new Error(
3874
+ "BRAIN_API_KEY env var is required \u2014 set it on the Fly machine before boot."
3875
+ );
3876
+ }
3877
+ return key;
3944
3878
  }
3945
- var commitAndPush2 = async (ctx, profile) => {
3946
- const branch = ctx.data.branch;
3947
- if (!branch) {
3948
- ctx.data.commitResult = { committed: false, pushed: false };
3949
- return;
3879
+ function authOk(req, expected) {
3880
+ const xApiKey = req.headers["x-api-key"]?.trim();
3881
+ if (xApiKey && xApiKey === expected) return true;
3882
+ const auth = req.headers["authorization"]?.trim();
3883
+ if (auth && auth.toLowerCase().startsWith("bearer ")) {
3884
+ return auth.slice(7).trim() === expected;
3950
3885
  }
3951
- const idempotencyEnabled = process.env.KODY_COMMIT_IDEMPOTENCY !== "0";
3952
- const sentinel = idempotencyEnabled ? sentinelPathForStage(ctx.cwd, profile.name) : null;
3953
- if (sentinel && fs15.existsSync(sentinel)) {
3954
- try {
3955
- const replay = JSON.parse(fs15.readFileSync(sentinel, "utf-8"));
3956
- ctx.data.commitResult = replay.commitResult ?? { committed: false, pushed: false };
3957
- if (Array.isArray(replay.changedFiles)) ctx.data.changedFiles = replay.changedFiles;
3958
- if (typeof replay.hasCommitsAhead === "boolean") ctx.data.hasCommitsAhead = replay.hasCommitsAhead;
3959
- if (replay.salvagedFromMissingMarker) ctx.data.salvagedFromMissingMarker = true;
3960
- ctx.data.commitIdempotencyReplay = true;
3961
- process.stderr.write(`[kody commitAndPush] idempotency replay (sentinel ${sentinel})
3886
+ return false;
3887
+ }
3888
+ function readJsonBody(req) {
3889
+ return new Promise((resolve4, reject) => {
3890
+ const chunks = [];
3891
+ req.on("data", (c) => chunks.push(c));
3892
+ req.on("end", () => {
3893
+ const raw = Buffer.concat(chunks).toString("utf-8");
3894
+ if (!raw.trim()) {
3895
+ resolve4({});
3896
+ return;
3897
+ }
3898
+ try {
3899
+ resolve4(JSON.parse(raw));
3900
+ } catch (err) {
3901
+ reject(err instanceof Error ? err : new Error(String(err)));
3902
+ }
3903
+ });
3904
+ req.on("error", reject);
3905
+ });
3906
+ }
3907
+ function sendJson(res, status, body) {
3908
+ res.writeHead(status, { "content-type": "application/json" });
3909
+ res.end(JSON.stringify(body));
3910
+ }
3911
+ function writeSseHeaders(res) {
3912
+ res.writeHead(200, {
3913
+ "content-type": "text/event-stream; charset=utf-8",
3914
+ "cache-control": "no-cache, no-transform",
3915
+ connection: "keep-alive",
3916
+ "x-accel-buffering": "no"
3917
+ });
3918
+ }
3919
+ function emitSse(res, event) {
3920
+ res.write(`data: ${JSON.stringify(event)}
3921
+
3962
3922
  `);
3963
- return;
3964
- } catch {
3923
+ }
3924
+ function translateChatEvent(event, chatId) {
3925
+ switch (event.event) {
3926
+ case "chat.message": {
3927
+ const content = String(event.payload.content ?? "");
3928
+ if (content.length === 0) return null;
3929
+ return { type: "text", text: content, chatId };
3965
3930
  }
3931
+ case "chat.tool": {
3932
+ if (event.payload.phase !== "use") return null;
3933
+ return {
3934
+ type: "tool_use",
3935
+ name: typeof event.payload.name === "string" ? event.payload.name : "tool",
3936
+ input: event.payload.input ?? {},
3937
+ chatId
3938
+ };
3939
+ }
3940
+ case "chat.done":
3941
+ return { type: "done", chatId };
3942
+ case "chat.error":
3943
+ return {
3944
+ type: "error",
3945
+ error: typeof event.payload.error === "string" ? event.payload.error : "agent error",
3946
+ chatId
3947
+ };
3948
+ default:
3949
+ return null;
3966
3950
  }
3967
- if (ctx.data.verifyOk === false) {
3968
- ctx.data.commitResult = { committed: false, pushed: false, skippedReason: "verifyFailed" };
3969
- ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
3970
- return;
3971
- }
3972
- const markerMissing = ctx.data.agentMarkerMissing === true;
3973
- if (ctx.data.agentDone === false && !markerMissing) {
3974
- ctx.data.commitResult = { committed: false, pushed: false, skippedReason: "agentDone=false" };
3975
- ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
3976
- return;
3951
+ }
3952
+ var BrokerSink = class {
3953
+ constructor(emitToLog, chatId) {
3954
+ this.emitToLog = emitToLog;
3955
+ this.chatId = chatId;
3977
3956
  }
3978
- if (ctx.data.agentDone === false && markerMissing) {
3979
- ctx.data.salvagedFromMissingMarker = true;
3957
+ emitToLog;
3958
+ chatId;
3959
+ async emit(event) {
3960
+ const be = translateChatEvent(event, this.chatId);
3961
+ if (be) this.emitToLog(be);
3980
3962
  }
3981
- const message = ctx.data.commitMessage || DEFAULT_COMMIT_MESSAGE;
3982
- try {
3983
- const result2 = commitAndPush(branch, message, ctx.cwd);
3984
- ctx.data.commitResult = result2;
3985
- const postCommitFiles = result2.committed ? listFilesInCommit("HEAD", ctx.cwd) : listChangedFiles(ctx.cwd);
3986
- ctx.data.changedFiles = postCommitFiles.filter((f) => !isForbiddenPath(f));
3987
- if (result2.committed && !result2.pushed) {
3988
- const reason = result2.pushError ?? "push failed (no error detail)";
3989
- ctx.data.commitCrash = reason;
3990
- if (ctx.output.exitCode === void 0 || ctx.output.exitCode === 0) {
3991
- ctx.output.exitCode = 4;
3992
- }
3993
- if (!ctx.output.reason) ctx.output.reason = reason;
3994
- process.stderr.write(`[kody commitAndPush] ${reason}
3963
+ };
3964
+ var chatQueues = /* @__PURE__ */ new Map();
3965
+ function enqueue(chatId, fn) {
3966
+ const prev = chatQueues.get(chatId) ?? Promise.resolve();
3967
+ const next = prev.catch(() => {
3968
+ }).then(fn);
3969
+ chatQueues.set(
3970
+ chatId,
3971
+ next.finally(() => {
3972
+ if (chatQueues.get(chatId) === next) chatQueues.delete(chatId);
3973
+ })
3974
+ );
3975
+ return next;
3976
+ }
3977
+ function streamToRes(res, dir, chatId, since) {
3978
+ writeSseHeaders(res);
3979
+ emitSse(res, { type: "chat", chatId });
3980
+ let maxSent = since;
3981
+ const unsubscribe = subscribe(
3982
+ dir,
3983
+ chatId,
3984
+ since,
3985
+ (rec) => {
3986
+ if (rec.seq <= maxSent) return;
3987
+ maxSent = rec.seq;
3988
+ if (res.writableEnded) return;
3989
+ res.write(`data: ${JSON.stringify({ ...rec.event, seq: rec.seq })}
3990
+
3995
3991
  `);
3992
+ },
3993
+ () => {
3994
+ if (!res.writableEnded) {
3995
+ try {
3996
+ res.end();
3997
+ } catch {
3998
+ }
3999
+ }
3996
4000
  }
3997
- } catch (err) {
3998
- const reason = err instanceof Error ? err.message : String(err);
3999
- ctx.data.commitCrash = reason;
4000
- ctx.data.commitResult = { committed: false, pushed: false };
4001
- process.stderr.write(`[kody commitAndPush] failed: ${reason}
4002
- `);
4001
+ );
4002
+ res.on("close", unsubscribe);
4003
+ }
4004
+ async function handleChatTurn(req, res, chatId, opts) {
4005
+ let body;
4006
+ try {
4007
+ body = await readJsonBody(req);
4008
+ } catch {
4009
+ sendJson(res, 400, { error: "invalid JSON body" });
4010
+ return;
4003
4011
  }
4004
- ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
4005
- const result = ctx.data.commitResult;
4006
- if (sentinel && result?.committed) {
4007
- try {
4008
- fs15.mkdirSync(path13.dirname(sentinel), { recursive: true });
4009
- fs15.writeFileSync(
4010
- sentinel,
4011
- JSON.stringify(
4012
- {
4013
- commitResult: ctx.data.commitResult,
4014
- changedFiles: ctx.data.changedFiles,
4015
- hasCommitsAhead: ctx.data.hasCommitsAhead,
4016
- salvagedFromMissingMarker: ctx.data.salvagedFromMissingMarker === true,
4017
- writtenAt: (/* @__PURE__ */ new Date()).toISOString()
4018
- },
4019
- null,
4020
- 2
4021
- )
4012
+ const message = typeof body === "object" && body !== null && "message" in body ? body.message : void 0;
4013
+ if (typeof message !== "string" || !message.trim()) {
4014
+ sendJson(res, 400, { error: "message required" });
4015
+ return;
4016
+ }
4017
+ const sessionFile = sessionFilePath(opts.cwd, chatId);
4018
+ fs14.mkdirSync(path12.dirname(sessionFile), { recursive: true });
4019
+ appendTurn(sessionFile, {
4020
+ role: "user",
4021
+ content: message,
4022
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4023
+ });
4024
+ const sinceFloor = getLastSeq(opts.cwd, chatId);
4025
+ const emitToLog = beginTurn(opts.cwd, chatId);
4026
+ const sink = new BrokerSink(emitToLog, chatId);
4027
+ void enqueue(
4028
+ chatId,
4029
+ () => opts.runTurn({
4030
+ sessionId: chatId,
4031
+ sessionFile,
4032
+ cwd: opts.cwd,
4033
+ model: opts.model,
4034
+ litellmUrl: opts.litellmUrl,
4035
+ sink
4036
+ }).catch((err) => {
4037
+ const errMsg2 = err instanceof Error ? err.message : String(err);
4038
+ process.stderr.write(`[brain-serve] chat turn failed: ${errMsg2}
4039
+ `);
4040
+ endTurnIfUnterminated(opts.cwd, chatId, errMsg2);
4041
+ }).finally(() => {
4042
+ endTurnIfUnterminated(
4043
+ opts.cwd,
4044
+ chatId,
4045
+ "Brain turn ended without a reply (the machine may have restarted mid-turn) \u2014 please resend your message"
4022
4046
  );
4023
- } catch {
4047
+ })
4048
+ );
4049
+ streamToRes(res, opts.cwd, chatId, sinceFloor);
4050
+ }
4051
+ function buildServer(opts) {
4052
+ const runTurn = opts.runTurn ?? runChatTurn;
4053
+ return createServer(async (req, res) => {
4054
+ if (!req.method || !req.url) {
4055
+ sendJson(res, 400, { error: "bad request" });
4056
+ return;
4024
4057
  }
4025
- }
4026
- };
4027
-
4028
- // src/scripts/commitGoalState.ts
4029
- import { execFileSync as execFileSync9 } from "child_process";
4030
- import * as path14 from "path";
4031
- var commitGoalState = async (ctx) => {
4032
- const goal = ctx.data.goal;
4033
- if (!goal) return;
4034
- const stateRel = path14.posix.join(".kody", "goals", goal.id, "state.json");
4035
- try {
4036
- execFileSync9("git", ["add", stateRel], { cwd: ctx.cwd, stdio: "pipe" });
4037
- } catch (err) {
4038
- process.stderr.write(
4039
- `[goal-tick] commitGoalState: git add failed: ${err instanceof Error ? err.message : String(err)}
4058
+ const url = new URL(req.url, `http://localhost`);
4059
+ if (req.method === "GET" && url.pathname === "/healthz") {
4060
+ sendJson(res, 200, { ok: true });
4061
+ return;
4062
+ }
4063
+ if (!authOk(req, opts.apiKey)) {
4064
+ sendJson(res, 401, { error: "unauthorized" });
4065
+ return;
4066
+ }
4067
+ const m = url.pathname.match(/^\/chats\/([^/]+)\/messages\/?$/);
4068
+ if (req.method === "POST" && m) {
4069
+ const chatId = decodeURIComponent(m[1] ?? "");
4070
+ if (!chatId) {
4071
+ sendJson(res, 400, { error: "chatId required" });
4072
+ return;
4073
+ }
4074
+ await handleChatTurn(req, res, chatId, {
4075
+ cwd: opts.cwd,
4076
+ model: opts.model,
4077
+ litellmUrl: opts.litellmUrl,
4078
+ runTurn
4079
+ });
4080
+ return;
4081
+ }
4082
+ const sm = url.pathname.match(/^\/chats\/([^/]+)\/stream\/?$/);
4083
+ if (req.method === "GET" && sm) {
4084
+ const chatId = decodeURIComponent(sm[1] ?? "");
4085
+ if (!chatId) {
4086
+ sendJson(res, 400, { error: "chatId required" });
4087
+ return;
4088
+ }
4089
+ const sinceRaw = url.searchParams.get("since");
4090
+ const since = Number.isFinite(Number(sinceRaw)) ? Number(sinceRaw) : 0;
4091
+ streamToRes(res, opts.cwd, chatId, since);
4092
+ return;
4093
+ }
4094
+ sendJson(res, 404, { error: "not found" });
4095
+ });
4096
+ }
4097
+ var brainServe = async (ctx) => {
4098
+ ctx.skipAgent = true;
4099
+ const unpacked = unpackAllSecrets();
4100
+ if (unpacked > 0) {
4101
+ process.stdout.write(
4102
+ `[brain-serve] unpacked ${unpacked} secret(s) from ALL_SECRETS
4040
4103
  `
4041
4104
  );
4042
- return;
4043
- }
4044
- try {
4045
- execFileSync9("git", ["diff", "--cached", "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4046
- return;
4047
- } catch {
4048
4105
  }
4049
- const msg = describeCommitMessage(goal);
4050
- try {
4051
- execFileSync9("git", ["commit", "-m", msg, "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4052
- } catch (err) {
4053
- process.stderr.write(
4054
- `[goal-tick] commitGoalState: git commit failed: ${err instanceof Error ? err.message : String(err)}
4106
+ const apiKey = getApiKey();
4107
+ const port = Number(process.env.PORT ?? DEFAULT_PORT);
4108
+ const model = parseProviderModel(ctx.config.agent.model);
4109
+ const usesProxy = needsLitellmProxy(model);
4110
+ let handle = null;
4111
+ if (usesProxy) {
4112
+ process.stdout.write(
4113
+ `[brain-serve] starting LiteLLM proxy for ${model.provider}/${model.model}...
4114
+ `
4115
+ );
4116
+ handle = await startLitellmIfNeeded(model, ctx.cwd);
4117
+ process.stdout.write(
4118
+ `[brain-serve] LiteLLM ready at ${handle?.url ?? LITELLM_DEFAULT_URL}
4055
4119
  `
4056
4120
  );
4057
- return;
4058
- }
4059
- try {
4060
- execFileSync9("git", ["push", "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4061
- } catch {
4062
- process.stderr.write("[goal-tick] commitGoalState: push failed (will retry next tick)\n");
4063
4121
  }
4122
+ const litellmUrl = usesProxy ? handle?.url ?? LITELLM_DEFAULT_URL : null;
4123
+ const server = buildServer({
4124
+ apiKey,
4125
+ cwd: ctx.cwd,
4126
+ model,
4127
+ litellmUrl
4128
+ });
4129
+ await new Promise((resolve4) => {
4130
+ server.listen(port, "0.0.0.0", () => {
4131
+ process.stdout.write(
4132
+ `[brain-serve] listening on 0.0.0.0:${port} (cwd=${ctx.cwd})
4133
+ `
4134
+ );
4135
+ resolve4();
4136
+ });
4137
+ });
4138
+ const shutdown = (signal) => {
4139
+ process.stdout.write(`[brain-serve] ${signal} \u2014 shutting down
4140
+ `);
4141
+ server.close(() => {
4142
+ if (handle) {
4143
+ try {
4144
+ handle.kill();
4145
+ } catch {
4146
+ }
4147
+ }
4148
+ process.exit(0);
4149
+ });
4150
+ };
4151
+ process.once("SIGINT", () => shutdown("SIGINT"));
4152
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
4153
+ await new Promise(() => {
4154
+ });
4064
4155
  };
4065
- function describeCommitMessage(goal) {
4066
- if (goal.state === "closed") return `chore(goals): abandon ${goal.id} (cleanup complete)`;
4067
- if (goal.state === "done") return `chore(goals): mark ${goal.id} done`;
4068
- if (goal.lastDispatchedIssue !== void 0) {
4069
- return `chore(goals): dispatched #${goal.lastDispatchedIssue} for ${goal.id}`;
4070
- }
4071
- if (goal.phase === "in-flight") {
4072
- return `chore(goals): tick ${goal.id} (waiting for in-flight task)`;
4073
- }
4074
- return `chore(goals): tick ${goal.id} (idle)`;
4075
- }
4076
4156
 
4077
- // src/scripts/composePrompt.ts
4078
- import * as fs16 from "fs";
4079
- import * as path15 from "path";
4080
- var MUSTACHE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
4081
- var composePrompt = async (ctx, profile) => {
4082
- const explicit = ctx.data.promptTemplate;
4083
- const mode = ctx.args.mode;
4157
+ // src/scripts/buildSyntheticPlugin.ts
4158
+ import * as fs15 from "fs";
4159
+ import * as os3 from "os";
4160
+ import * as path13 from "path";
4161
+ function getPluginsCatalogRoot() {
4162
+ const here = path13.dirname(new URL(import.meta.url).pathname);
4084
4163
  const candidates = [
4085
- explicit ? path15.join(profile.dir, explicit) : null,
4086
- mode ? path15.join(profile.dir, "prompts", `${mode}.md`) : null,
4087
- path15.join(profile.dir, "prompt.md")
4088
- ].filter(Boolean);
4089
- let templatePath = "";
4164
+ path13.join(here, "..", "plugins"),
4165
+ // dev: src/scripts src/plugins
4166
+ path13.join(here, "..", "..", "plugins"),
4167
+ // built: dist/scripts → dist/plugins
4168
+ path13.join(here, "..", "..", "src", "plugins")
4169
+ // fallback
4170
+ ];
4090
4171
  for (const c of candidates) {
4091
- if (fs16.existsSync(c)) {
4092
- templatePath = c;
4093
- break;
4172
+ if (fs15.existsSync(c) && fs15.statSync(c).isDirectory()) return c;
4173
+ }
4174
+ return candidates[0];
4175
+ }
4176
+ var buildSyntheticPlugin = async (ctx, profile) => {
4177
+ const cc = profile.claudeCode;
4178
+ const needsSynthetic = cc.skills.length > 0 || cc.commands.length > 0 || cc.hooks.length > 0 || cc.subagents.length > 0;
4179
+ if (!needsSynthetic) return;
4180
+ const catalog = getPluginsCatalogRoot();
4181
+ const runId = `${profile.name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4182
+ const root = path13.join(os3.tmpdir(), `kody-synth-${runId}`);
4183
+ fs15.mkdirSync(path13.join(root, ".claude-plugin"), { recursive: true });
4184
+ const resolvePart = (bucket, entry) => {
4185
+ const local = path13.join(profile.dir, bucket, entry);
4186
+ if (fs15.existsSync(local)) return local;
4187
+ const central = path13.join(catalog, bucket, entry);
4188
+ if (fs15.existsSync(central)) return central;
4189
+ throw new Error(
4190
+ `buildSyntheticPlugin: ${bucket} entry '${entry}' not found in executable dir (${profile.dir}/${bucket}/) or catalog (${catalog}/${bucket}/)`
4191
+ );
4192
+ };
4193
+ if (cc.skills.length > 0) {
4194
+ const dst = path13.join(root, "skills");
4195
+ fs15.mkdirSync(dst, { recursive: true });
4196
+ for (const name of cc.skills) {
4197
+ copyDir(resolvePart("skills", name), path13.join(dst, name));
4094
4198
  }
4095
4199
  }
4096
- if (!templatePath) {
4097
- throw new Error(`profile at ${profile.dir}: no prompt template found (tried ${candidates.join(", ")})`);
4200
+ if (cc.commands.length > 0) {
4201
+ const dst = path13.join(root, "commands");
4202
+ fs15.mkdirSync(dst, { recursive: true });
4203
+ for (const name of cc.commands) {
4204
+ fs15.copyFileSync(resolvePart("commands", `${name}.md`), path13.join(dst, `${name}.md`));
4205
+ }
4098
4206
  }
4099
- const template = fs16.readFileSync(templatePath, "utf-8");
4100
- const tokens = {
4101
- ...stringifyAll(ctx.args, "args."),
4102
- ...stringifyAll(ctx.data, ""),
4103
- conventionsBlock: formatConventions(ctx.data.conventions),
4104
- coverageBlock: formatCoverageBlock(
4105
- ctx.data.coverageRules
4106
- ),
4107
- toolsUsage: formatToolsUsage(profile),
4108
- systemPromptAppend: profile.claudeCode.systemPromptAppend ?? "",
4109
- repoOwner: ctx.config.github.owner,
4110
- repoName: ctx.config.github.repo,
4111
- defaultBranch: ctx.config.git.defaultBranch,
4112
- branch: ctx.data.branch ?? ""
4113
- };
4114
- ctx.data.prompt = template.replace(MUSTACHE, (_, key) => tokens[key] ?? "");
4115
- };
4116
- function stringifyAll(source, prefix) {
4117
- const out = {};
4118
- for (const [k, v] of Object.entries(source)) {
4119
- const key = prefix + k;
4120
- if (v === null || v === void 0) continue;
4121
- if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
4122
- out[key] = String(v);
4123
- } else if (Array.isArray(v)) {
4124
- out[key] = v.map((x) => typeof x === "string" ? x : JSON.stringify(x)).join("\n");
4125
- } else if (typeof v === "object") {
4126
- for (const [k2, v2] of Object.entries(v)) {
4127
- if (typeof v2 === "string" || typeof v2 === "number" || typeof v2 === "boolean") {
4128
- out[`${key}.${k2}`] = String(v2);
4129
- }
4130
- }
4207
+ if (cc.subagents.length > 0) {
4208
+ const dst = path13.join(root, "agents");
4209
+ fs15.mkdirSync(dst, { recursive: true });
4210
+ for (const name of cc.subagents) {
4211
+ fs15.copyFileSync(resolvePart("agents", `${name}.md`), path13.join(dst, `${name}.md`));
4131
4212
  }
4132
4213
  }
4133
- return out;
4214
+ if (cc.hooks.length > 0) {
4215
+ const dst = path13.join(root, "hooks");
4216
+ fs15.mkdirSync(dst, { recursive: true });
4217
+ const merged = { hooks: {} };
4218
+ for (const name of cc.hooks) {
4219
+ const src = resolvePart("hooks", `${name}.json`);
4220
+ const parsed = JSON.parse(fs15.readFileSync(src, "utf-8"));
4221
+ for (const [event, entries] of Object.entries(parsed.hooks ?? {})) {
4222
+ if (!Array.isArray(entries)) continue;
4223
+ if (!merged.hooks[event]) merged.hooks[event] = [];
4224
+ merged.hooks[event].push(...entries);
4225
+ }
4226
+ }
4227
+ fs15.writeFileSync(path13.join(dst, "hooks.json"), `${JSON.stringify(merged, null, 2)}
4228
+ `);
4229
+ }
4230
+ const manifest = {
4231
+ name: `kody-synth-${profile.name}`,
4232
+ version: "1.0.0",
4233
+ description: `Synthetic plugin assembled by Kody for profile '${profile.name}' at runtime.`
4234
+ };
4235
+ if (cc.skills.length > 0) manifest.skills = ["./skills/"];
4236
+ if (cc.commands.length > 0) manifest.commands = ["./commands/"];
4237
+ if (cc.subagents.length > 0) manifest.agents = cc.subagents.map((n) => `./agents/${n}.md`);
4238
+ fs15.writeFileSync(path13.join(root, ".claude-plugin", "plugin.json"), `${JSON.stringify(manifest, null, 2)}
4239
+ `);
4240
+ ctx.data.syntheticPluginPath = root;
4241
+ };
4242
+ function copyDir(src, dst) {
4243
+ fs15.mkdirSync(dst, { recursive: true });
4244
+ for (const ent of fs15.readdirSync(src, { withFileTypes: true })) {
4245
+ const s = path13.join(src, ent.name);
4246
+ const d = path13.join(dst, ent.name);
4247
+ if (ent.isDirectory()) copyDir(s, d);
4248
+ else if (ent.isFile()) fs15.copyFileSync(s, d);
4249
+ }
4134
4250
  }
4135
- function formatConventions(conventions) {
4136
- if (!conventions || conventions.length === 0) return "";
4137
- const lines = ["# Project conventions (AUTHORITATIVE \u2014 follow these over patterns you infer from code)", ""];
4138
- for (const c of conventions) {
4139
- lines.push(`## ${c.path}${c.truncated ? " (truncated)" : ""}`);
4140
- lines.push("");
4141
- lines.push("```");
4142
- lines.push(c.content);
4143
- lines.push("```");
4144
- lines.push("");
4251
+
4252
+ // src/coverage.ts
4253
+ import { execFileSync as execFileSync8 } from "child_process";
4254
+ function patternToRegex(pattern) {
4255
+ let s = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
4256
+ s = s.replace(/\*\*\//g, "\xA7S").replace(/\*\*/g, "\xA7A").replace(/\*/g, "[^/]*");
4257
+ s = s.replace(/§S/g, "(?:.*/)?").replace(/§A/g, ".*");
4258
+ return new RegExp(`^${s}$`);
4259
+ }
4260
+ function renderSiblingPath(file, requireSibling) {
4261
+ const lastSlash = file.lastIndexOf("/");
4262
+ const dir = lastSlash === -1 ? "" : file.slice(0, lastSlash + 1);
4263
+ const base = lastSlash === -1 ? file : file.slice(lastSlash + 1);
4264
+ const name = base.replace(/\.[^.]+$/, "");
4265
+ const ext = base.match(/\.[^.]+$/)?.[0] ?? "";
4266
+ const sibling = requireSibling.replace(/\{name\}/g, name).replace(/\{ext\}/g, ext);
4267
+ return dir + sibling;
4268
+ }
4269
+ function safeGit(args, cwd) {
4270
+ try {
4271
+ return execFileSync8("git", args, { encoding: "utf-8", cwd, env: { ...process.env, HUSKY: "0" } }).trim();
4272
+ } catch {
4273
+ return "";
4145
4274
  }
4146
- return lines.join("\n");
4147
4275
  }
4148
- function formatCoverageBlock(reqs) {
4149
- if (!reqs || reqs.length === 0) return "";
4150
- const lines = [
4151
- "# Test coverage requirements (ENFORCED)",
4152
- "",
4153
- "Every newly added file matching one of these patterns MUST be accompanied by a sibling test file in the same commit. The wrapper checks this after you finish; if any sibling test is missing, the run will fail and the issue will be re-invoked with the gap as feedback.",
4154
- ""
4155
- ];
4156
- for (const r of reqs) lines.push(`- new \`${r.pattern}\` \u2192 must include sibling \`${r.requireSibling}\``);
4157
- lines.push("");
4158
- return lines.join("\n");
4276
+ function getAddedFiles(baseBranch, cwd) {
4277
+ const committed = safeGit(["diff", "--name-only", "--diff-filter=A", `origin/${baseBranch}...HEAD`], cwd);
4278
+ const untracked = safeGit(["ls-files", "--others", "--exclude-standard"], cwd);
4279
+ const set = /* @__PURE__ */ new Set();
4280
+ for (const f of committed.split("\n")) if (f) set.add(f);
4281
+ for (const f of untracked.split("\n")) if (f) set.add(f);
4282
+ return [...set];
4159
4283
  }
4160
- function formatToolsUsage(profile) {
4161
- const entries = (profile.cliTools ?? []).filter((t) => t.usage.trim().length > 0);
4162
- if (entries.length === 0) return "";
4163
- const lines = ["# Available CLI tools", ""];
4164
- for (const t of entries) {
4165
- lines.push(`## \`${t.name}\``);
4166
- lines.push(t.usage);
4167
- if (t.allowedUses.length > 0) {
4168
- lines.push(`Allowed sub-commands: ${t.allowedUses.map((u) => `\`${u}\``).join(", ")}`);
4284
+ function checkCoverage(addedFiles, requirements) {
4285
+ if (requirements.length === 0) return [];
4286
+ const addedSet = new Set(addedFiles);
4287
+ const misses = [];
4288
+ for (const file of addedFiles) {
4289
+ if (/\.(test|spec)\./.test(file)) continue;
4290
+ for (const req of requirements) {
4291
+ const re = patternToRegex(req.pattern);
4292
+ if (!re.test(file)) continue;
4293
+ const expected = renderSiblingPath(file, req.requireSibling);
4294
+ if (!addedSet.has(expected)) {
4295
+ misses.push({ file, expectedTest: expected });
4296
+ }
4297
+ break;
4169
4298
  }
4170
- lines.push("");
4171
4299
  }
4300
+ return misses;
4301
+ }
4302
+ function formatMissesForFeedback(misses) {
4303
+ if (misses.length === 0) return "";
4304
+ const lines = ["The following files were added without a sibling test file:"];
4305
+ for (const m of misses) lines.push(`- \`${m.file}\` \u2192 expected \`${m.expectedTest}\``);
4306
+ lines.push("");
4307
+ lines.push(
4308
+ "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."
4309
+ );
4172
4310
  return lines.join("\n");
4173
4311
  }
4174
4312
 
4175
- // src/scripts/createQaGoal.ts
4176
- init_issue();
4177
- import { execFileSync as execFileSync10 } from "child_process";
4178
- import * as fs17 from "fs";
4179
- import * as path16 from "path";
4313
+ // src/scripts/checkCoverageWithRetry.ts
4314
+ init_prompt();
4315
+ var checkCoverageWithRetry = async (ctx) => {
4316
+ const reqs = ctx.data.coverageRules ?? [];
4317
+ if (reqs.length === 0) {
4318
+ ctx.data.coverageMisses = [];
4319
+ return;
4320
+ }
4321
+ if (!ctx.data.agentDone) {
4322
+ ctx.data.coverageMisses = [];
4323
+ return;
4324
+ }
4325
+ const misses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
4326
+ if (misses.length === 0) {
4327
+ ctx.data.coverageMisses = [];
4328
+ return;
4329
+ }
4330
+ const invoker = ctx.data.__invokeAgent;
4331
+ const basePrompt = ctx.data.prompt;
4332
+ if (!invoker || !basePrompt) {
4333
+ ctx.data.coverageMisses = misses;
4334
+ return;
4335
+ }
4336
+ process.stderr.write(`[kody] coverage check found ${misses.length} missing test(s); retrying agent once
4337
+ `);
4338
+ const retryPrompt = `${basePrompt}
4180
4339
 
4181
- // src/scripts/postReviewResult.ts
4182
- init_issue();
4183
- function detectVerdict(body) {
4184
- const m = body.match(/##\s*Verdict\s*:\s*(PASS|CONCERNS|FAIL)\b/i);
4185
- if (!m) return "UNKNOWN";
4186
- return m[1].toUpperCase();
4187
- }
4188
- function reviewAction(verdict, payload) {
4189
- const type = verdict === "PASS" ? "REVIEW_PASS" : verdict === "CONCERNS" ? "REVIEW_CONCERNS" : verdict === "FAIL" ? "REVIEW_FAIL" : "REVIEW_COMPLETED";
4190
- return { type, payload: { verdict, ...payload }, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4340
+ # Coverage failure (retry)
4341
+ ${formatMissesForFeedback(misses)}`;
4342
+ const retry = await invoker(retryPrompt);
4343
+ const retryParsed = parseAgentResult(retry.finalText);
4344
+ if (retry.outcome === "completed" && retryParsed.done) {
4345
+ ctx.data.agentDone = true;
4346
+ ctx.data.commitMessage = retryParsed.commitMessage || ctx.data.commitMessage;
4347
+ ctx.data.prSummary = retryParsed.prSummary || ctx.data.prSummary;
4348
+ }
4349
+ const finalMisses = checkCoverage(getAddedFiles(ctx.config.git.defaultBranch, ctx.cwd), reqs);
4350
+ ctx.data.coverageMisses = finalMisses;
4351
+ };
4352
+
4353
+ // src/scripts/classifyByLabel.ts
4354
+ var VALID_CLASSES = /* @__PURE__ */ new Set(["feature", "bug", "spec", "chore"]);
4355
+ var classifyByLabel = async (ctx) => {
4356
+ const issue = ctx.data.issue;
4357
+ const labels = issue?.labels;
4358
+ if (!labels || labels.length === 0) return;
4359
+ const cfgMap = ctx.config.classify?.labelMap;
4360
+ const map = cfgMap ?? defaultLabelMap();
4361
+ for (const label of labels) {
4362
+ const candidate = map[label.toLowerCase()];
4363
+ if (candidate && VALID_CLASSES.has(candidate)) {
4364
+ ctx.data.classification = candidate;
4365
+ ctx.data.classificationSource = "label";
4366
+ ctx.data.classificationReason = `label \`${label}\` \u2192 ${candidate}`;
4367
+ ctx.skipAgent = true;
4368
+ return;
4369
+ }
4370
+ }
4371
+ };
4372
+ function defaultLabelMap() {
4373
+ return {
4374
+ bug: "bug",
4375
+ enhancement: "bug",
4376
+ refactor: "feature",
4377
+ feature: "feature",
4378
+ performance: "feature",
4379
+ rfc: "spec",
4380
+ design: "spec",
4381
+ spec: "spec",
4382
+ docs: "chore",
4383
+ chore: "chore",
4384
+ dependencies: "chore"
4385
+ };
4191
4386
  }
4192
- function failedAction(reason) {
4193
- return { type: "REVIEW_FAILED", payload: { reason }, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4387
+
4388
+ // src/scripts/commitAndPush.ts
4389
+ import * as fs17 from "fs";
4390
+ import * as path15 from "path";
4391
+ init_events();
4392
+ var DEFAULT_COMMIT_MESSAGE = "chore: kody changes";
4393
+ function sentinelPathForStage(cwd, profileName) {
4394
+ const runId = resolveRunId();
4395
+ return path15.join(cwd, ".kody", "runs", runId, `commit-${profileName}.lock`);
4194
4396
  }
4195
- var postReviewResult = async (ctx, _profile, agentResult) => {
4196
- const prNumber = ctx.data.commentTargetNumber;
4197
- if (!prNumber) {
4198
- ctx.output.exitCode = 99;
4199
- ctx.output.reason = "review postflight: no PR number in context";
4200
- ctx.data.action = failedAction(ctx.output.reason);
4397
+ var commitAndPush2 = async (ctx, profile) => {
4398
+ const branch = ctx.data.branch;
4399
+ if (!branch) {
4400
+ ctx.data.commitResult = { committed: false, pushed: false };
4201
4401
  return;
4202
4402
  }
4203
- if (!agentResult || agentResult.outcome !== "completed") {
4204
- const reason = agentResult?.error ?? "agent did not complete";
4403
+ const idempotencyEnabled = process.env.KODY_COMMIT_IDEMPOTENCY !== "0";
4404
+ const sentinel = idempotencyEnabled ? sentinelPathForStage(ctx.cwd, profile.name) : null;
4405
+ if (sentinel && fs17.existsSync(sentinel)) {
4205
4406
  try {
4206
- postPrReviewComment(prNumber, `\u26A0\uFE0F kody review FAILED: ${truncate2(reason, 1e3)}`, ctx.cwd);
4407
+ const replay = JSON.parse(fs17.readFileSync(sentinel, "utf-8"));
4408
+ ctx.data.commitResult = replay.commitResult ?? { committed: false, pushed: false };
4409
+ if (Array.isArray(replay.changedFiles)) ctx.data.changedFiles = replay.changedFiles;
4410
+ if (typeof replay.hasCommitsAhead === "boolean") ctx.data.hasCommitsAhead = replay.hasCommitsAhead;
4411
+ if (replay.salvagedFromMissingMarker) ctx.data.salvagedFromMissingMarker = true;
4412
+ ctx.data.commitIdempotencyReplay = true;
4413
+ process.stderr.write(`[kody commitAndPush] idempotency replay (sentinel ${sentinel})
4414
+ `);
4415
+ return;
4207
4416
  } catch {
4208
4417
  }
4209
- ctx.output.exitCode = 1;
4210
- ctx.output.reason = reason;
4211
- ctx.data.action = failedAction(reason);
4418
+ }
4419
+ if (ctx.data.verifyOk === false) {
4420
+ ctx.data.commitResult = { committed: false, pushed: false, skippedReason: "verifyFailed" };
4421
+ ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
4212
4422
  return;
4213
4423
  }
4214
- const reviewBody = agentResult.finalText.trim();
4215
- if (!reviewBody) {
4216
- try {
4217
- postPrReviewComment(prNumber, `\u26A0\uFE0F kody review FAILED: agent produced no review body`, ctx.cwd);
4218
- } catch {
4219
- }
4220
- ctx.output.exitCode = 1;
4221
- ctx.output.reason = "empty review body";
4222
- ctx.data.action = failedAction("empty review body");
4424
+ const markerMissing = ctx.data.agentMarkerMissing === true;
4425
+ if (ctx.data.agentDone === false && !markerMissing) {
4426
+ ctx.data.commitResult = { committed: false, pushed: false, skippedReason: "agentDone=false" };
4427
+ ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
4223
4428
  return;
4224
4429
  }
4225
- try {
4226
- postPrReviewComment(prNumber, reviewBody, ctx.cwd);
4227
- } catch (err) {
4430
+ if (ctx.data.agentDone === false && markerMissing) {
4431
+ ctx.data.salvagedFromMissingMarker = true;
4432
+ }
4433
+ const message = ctx.data.commitMessage || DEFAULT_COMMIT_MESSAGE;
4434
+ try {
4435
+ const result2 = commitAndPush(branch, message, ctx.cwd);
4436
+ ctx.data.commitResult = result2;
4437
+ const postCommitFiles = result2.committed ? listFilesInCommit("HEAD", ctx.cwd) : listChangedFiles(ctx.cwd);
4438
+ ctx.data.changedFiles = postCommitFiles.filter((f) => !isForbiddenPath(f));
4439
+ if (result2.committed && !result2.pushed) {
4440
+ const reason = result2.pushError ?? "push failed (no error detail)";
4441
+ ctx.data.commitCrash = reason;
4442
+ if (ctx.output.exitCode === void 0 || ctx.output.exitCode === 0) {
4443
+ ctx.output.exitCode = 4;
4444
+ }
4445
+ if (!ctx.output.reason) ctx.output.reason = reason;
4446
+ process.stderr.write(`[kody commitAndPush] ${reason}
4447
+ `);
4448
+ }
4449
+ } catch (err) {
4450
+ const reason = err instanceof Error ? err.message : String(err);
4451
+ ctx.data.commitCrash = reason;
4452
+ ctx.data.commitResult = { committed: false, pushed: false };
4453
+ process.stderr.write(`[kody commitAndPush] failed: ${reason}
4454
+ `);
4455
+ }
4456
+ ctx.data.hasCommitsAhead = hasCommitsAhead(branch, ctx.config.git.defaultBranch, ctx.cwd);
4457
+ const result = ctx.data.commitResult;
4458
+ if (sentinel && result?.committed) {
4459
+ try {
4460
+ fs17.mkdirSync(path15.dirname(sentinel), { recursive: true });
4461
+ fs17.writeFileSync(
4462
+ sentinel,
4463
+ JSON.stringify(
4464
+ {
4465
+ commitResult: ctx.data.commitResult,
4466
+ changedFiles: ctx.data.changedFiles,
4467
+ hasCommitsAhead: ctx.data.hasCommitsAhead,
4468
+ salvagedFromMissingMarker: ctx.data.salvagedFromMissingMarker === true,
4469
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
4470
+ },
4471
+ null,
4472
+ 2
4473
+ )
4474
+ );
4475
+ } catch {
4476
+ }
4477
+ }
4478
+ };
4479
+
4480
+ // src/scripts/commitGoalState.ts
4481
+ import { execFileSync as execFileSync9 } from "child_process";
4482
+ import * as path16 from "path";
4483
+ var commitGoalState = async (ctx) => {
4484
+ const goal = ctx.data.goal;
4485
+ if (!goal) return;
4486
+ const stateRel = path16.posix.join(".kody", "goals", goal.id, "state.json");
4487
+ try {
4488
+ execFileSync9("git", ["add", stateRel], { cwd: ctx.cwd, stdio: "pipe" });
4489
+ } catch (err) {
4490
+ process.stderr.write(
4491
+ `[goal-tick] commitGoalState: git add failed: ${err instanceof Error ? err.message : String(err)}
4492
+ `
4493
+ );
4494
+ return;
4495
+ }
4496
+ try {
4497
+ execFileSync9("git", ["diff", "--cached", "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4498
+ return;
4499
+ } catch {
4500
+ }
4501
+ const msg = describeCommitMessage(goal);
4502
+ try {
4503
+ execFileSync9("git", ["commit", "-m", msg, "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4504
+ } catch (err) {
4505
+ process.stderr.write(
4506
+ `[goal-tick] commitGoalState: git commit failed: ${err instanceof Error ? err.message : String(err)}
4507
+ `
4508
+ );
4509
+ return;
4510
+ }
4511
+ try {
4512
+ execFileSync9("git", ["push", "--quiet"], { cwd: ctx.cwd, stdio: "pipe" });
4513
+ } catch {
4514
+ process.stderr.write("[goal-tick] commitGoalState: push failed (will retry next tick)\n");
4515
+ }
4516
+ };
4517
+ function describeCommitMessage(goal) {
4518
+ if (goal.state === "closed") return `chore(goals): abandon ${goal.id} (cleanup complete)`;
4519
+ if (goal.state === "awaiting-merge") return `chore(goals): park ${goal.id} awaiting merge`;
4520
+ if (goal.state === "done") return `chore(goals): mark ${goal.id} done`;
4521
+ if (goal.lastDispatchedIssue !== void 0) {
4522
+ return `chore(goals): dispatched #${goal.lastDispatchedIssue} for ${goal.id}`;
4523
+ }
4524
+ if (goal.phase === "in-flight") {
4525
+ return `chore(goals): tick ${goal.id} (waiting for in-flight task)`;
4526
+ }
4527
+ return `chore(goals): tick ${goal.id} (idle)`;
4528
+ }
4529
+
4530
+ // src/scripts/composePrompt.ts
4531
+ import * as fs18 from "fs";
4532
+ import * as path17 from "path";
4533
+ var MUSTACHE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
4534
+ var composePrompt = async (ctx, profile) => {
4535
+ const explicit = ctx.data.promptTemplate;
4536
+ const mode = ctx.args.mode;
4537
+ const candidates = [
4538
+ explicit ? path17.join(profile.dir, explicit) : null,
4539
+ mode ? path17.join(profile.dir, "prompts", `${mode}.md`) : null,
4540
+ path17.join(profile.dir, "prompt.md")
4541
+ ].filter(Boolean);
4542
+ let templatePath = "";
4543
+ for (const c of candidates) {
4544
+ if (fs18.existsSync(c)) {
4545
+ templatePath = c;
4546
+ break;
4547
+ }
4548
+ }
4549
+ if (!templatePath) {
4550
+ throw new Error(`profile at ${profile.dir}: no prompt template found (tried ${candidates.join(", ")})`);
4551
+ }
4552
+ const template = fs18.readFileSync(templatePath, "utf-8");
4553
+ const tokens = {
4554
+ ...stringifyAll(ctx.args, "args."),
4555
+ ...stringifyAll(ctx.data, ""),
4556
+ conventionsBlock: formatConventions(ctx.data.conventions),
4557
+ coverageBlock: formatCoverageBlock(
4558
+ ctx.data.coverageRules
4559
+ ),
4560
+ toolsUsage: formatToolsUsage(profile),
4561
+ systemPromptAppend: profile.claudeCode.systemPromptAppend ?? "",
4562
+ repoOwner: ctx.config.github.owner,
4563
+ repoName: ctx.config.github.repo,
4564
+ defaultBranch: ctx.config.git.defaultBranch,
4565
+ branch: ctx.data.branch ?? ""
4566
+ };
4567
+ ctx.data.prompt = template.replace(MUSTACHE, (_, key) => tokens[key] ?? "");
4568
+ };
4569
+ function stringifyAll(source, prefix) {
4570
+ const out = {};
4571
+ for (const [k, v] of Object.entries(source)) {
4572
+ const key = prefix + k;
4573
+ if (v === null || v === void 0) continue;
4574
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
4575
+ out[key] = String(v);
4576
+ } else if (Array.isArray(v)) {
4577
+ out[key] = v.map((x) => typeof x === "string" ? x : JSON.stringify(x)).join("\n");
4578
+ } else if (typeof v === "object") {
4579
+ for (const [k2, v2] of Object.entries(v)) {
4580
+ if (typeof v2 === "string" || typeof v2 === "number" || typeof v2 === "boolean") {
4581
+ out[`${key}.${k2}`] = String(v2);
4582
+ }
4583
+ }
4584
+ }
4585
+ }
4586
+ return out;
4587
+ }
4588
+ function formatConventions(conventions) {
4589
+ if (!conventions || conventions.length === 0) return "";
4590
+ const lines = ["# Project conventions (AUTHORITATIVE \u2014 follow these over patterns you infer from code)", ""];
4591
+ for (const c of conventions) {
4592
+ lines.push(`## ${c.path}${c.truncated ? " (truncated)" : ""}`);
4593
+ lines.push("");
4594
+ lines.push("```");
4595
+ lines.push(c.content);
4596
+ lines.push("```");
4597
+ lines.push("");
4598
+ }
4599
+ return lines.join("\n");
4600
+ }
4601
+ function formatCoverageBlock(reqs) {
4602
+ if (!reqs || reqs.length === 0) return "";
4603
+ const lines = [
4604
+ "# Test coverage requirements (ENFORCED)",
4605
+ "",
4606
+ "Every newly added file matching one of these patterns MUST be accompanied by a sibling test file in the same commit. The wrapper checks this after you finish; if any sibling test is missing, the run will fail and the issue will be re-invoked with the gap as feedback.",
4607
+ ""
4608
+ ];
4609
+ for (const r of reqs) lines.push(`- new \`${r.pattern}\` \u2192 must include sibling \`${r.requireSibling}\``);
4610
+ lines.push("");
4611
+ return lines.join("\n");
4612
+ }
4613
+ function formatToolsUsage(profile) {
4614
+ const entries = (profile.cliTools ?? []).filter((t) => t.usage.trim().length > 0);
4615
+ if (entries.length === 0) return "";
4616
+ const lines = ["# Available CLI tools", ""];
4617
+ for (const t of entries) {
4618
+ lines.push(`## \`${t.name}\``);
4619
+ lines.push(t.usage);
4620
+ if (t.allowedUses.length > 0) {
4621
+ lines.push(`Allowed sub-commands: ${t.allowedUses.map((u) => `\`${u}\``).join(", ")}`);
4622
+ }
4623
+ lines.push("");
4624
+ }
4625
+ return lines.join("\n");
4626
+ }
4627
+
4628
+ // src/scripts/createQaGoal.ts
4629
+ init_issue();
4630
+ import { execFileSync as execFileSync10 } from "child_process";
4631
+ import * as fs19 from "fs";
4632
+ import * as path18 from "path";
4633
+
4634
+ // src/scripts/postReviewResult.ts
4635
+ init_issue();
4636
+ function detectVerdict(body) {
4637
+ const m = body.match(/##\s*Verdict\s*:\s*(PASS|CONCERNS|FAIL)\b/i);
4638
+ if (!m) return "UNKNOWN";
4639
+ return m[1].toUpperCase();
4640
+ }
4641
+ function reviewAction(verdict, payload) {
4642
+ const type = verdict === "PASS" ? "REVIEW_PASS" : verdict === "CONCERNS" ? "REVIEW_CONCERNS" : verdict === "FAIL" ? "REVIEW_FAIL" : "REVIEW_COMPLETED";
4643
+ return { type, payload: { verdict, ...payload }, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4644
+ }
4645
+ function failedAction(reason) {
4646
+ return { type: "REVIEW_FAILED", payload: { reason }, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
4647
+ }
4648
+ var postReviewResult = async (ctx, _profile, agentResult) => {
4649
+ const prNumber = ctx.data.commentTargetNumber;
4650
+ if (!prNumber) {
4651
+ ctx.output.exitCode = 99;
4652
+ ctx.output.reason = "review postflight: no PR number in context";
4653
+ ctx.data.action = failedAction(ctx.output.reason);
4654
+ return;
4655
+ }
4656
+ if (!agentResult || agentResult.outcome !== "completed") {
4657
+ const reason = agentResult?.error ?? "agent did not complete";
4658
+ try {
4659
+ postPrReviewComment(prNumber, `\u26A0\uFE0F kody review FAILED: ${truncate2(reason, 1e3)}`, ctx.cwd);
4660
+ } catch {
4661
+ }
4662
+ ctx.output.exitCode = 1;
4663
+ ctx.output.reason = reason;
4664
+ ctx.data.action = failedAction(reason);
4665
+ return;
4666
+ }
4667
+ const reviewBody = agentResult.finalText.trim();
4668
+ if (!reviewBody) {
4669
+ try {
4670
+ postPrReviewComment(prNumber, `\u26A0\uFE0F kody review FAILED: agent produced no review body`, ctx.cwd);
4671
+ } catch {
4672
+ }
4673
+ ctx.output.exitCode = 1;
4674
+ ctx.output.reason = "empty review body";
4675
+ ctx.data.action = failedAction("empty review body");
4676
+ return;
4677
+ }
4678
+ try {
4679
+ postPrReviewComment(prNumber, reviewBody, ctx.cwd);
4680
+ } catch (err) {
4228
4681
  const msg = err instanceof Error ? err.message : String(err);
4229
4682
  ctx.output.exitCode = 4;
4230
4683
  ctx.output.reason = `failed to post review comment: ${msg}`;
@@ -4429,8 +4882,8 @@ function createOrUpdateManifestIssue(number, manifest, cwd) {
4429
4882
  return { number: Number(m[1]), created: true };
4430
4883
  }
4431
4884
  function writeStateFile(cwd, goalId, lastDispatchedIssue) {
4432
- const dir = path16.join(cwd, ".kody", "goals", goalId);
4433
- fs17.mkdirSync(dir, { recursive: true });
4885
+ const dir = path18.join(cwd, ".kody", "goals", goalId);
4886
+ fs19.mkdirSync(dir, { recursive: true });
4434
4887
  const state = {
4435
4888
  version: 1,
4436
4889
  state: "active",
@@ -4438,8 +4891,8 @@ function writeStateFile(cwd, goalId, lastDispatchedIssue) {
4438
4891
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4439
4892
  ...typeof lastDispatchedIssue === "number" ? { lastDispatchedIssue } : {}
4440
4893
  };
4441
- const filePath = path16.join(dir, "state.json");
4442
- fs17.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}
4894
+ const filePath = path18.join(dir, "state.json");
4895
+ fs19.writeFileSync(filePath, `${JSON.stringify(state, null, 2)}
4443
4896
  `);
4444
4897
  return filePath;
4445
4898
  }
@@ -4870,6 +5323,7 @@ function derivePhase(snap) {
4870
5323
  if (!snap.lifecycleState) return "missing";
4871
5324
  if (snap.lifecycleState === "abandoned") return "abandoned";
4872
5325
  if (snap.lifecycleState === "closed" || snap.lifecycleState === "done") return "terminal";
5326
+ if (snap.lifecycleState === "awaiting-merge") return "awaiting-merge";
4873
5327
  const hasInFlight = snap.childTasks.some((t) => t.state === "OPEN" && t.prState === "draft");
4874
5328
  if (hasInFlight) return "in-flight";
4875
5329
  if (snap.childTasks.length === 0) return "idle";
@@ -4935,15 +5389,15 @@ function filterGoalTaskPrs(prs, taskIssueNumbers) {
4935
5389
 
4936
5390
  // src/scripts/diagMcp.ts
4937
5391
  import { execFileSync as execFileSync11 } from "child_process";
4938
- import * as fs18 from "fs";
5392
+ import * as fs20 from "fs";
4939
5393
  import * as os4 from "os";
4940
- import * as path17 from "path";
5394
+ import * as path19 from "path";
4941
5395
  var diagMcp = async (_ctx) => {
4942
5396
  const home = os4.homedir();
4943
- const cacheDir = path17.join(home, ".cache", "ms-playwright");
5397
+ const cacheDir = path19.join(home, ".cache", "ms-playwright");
4944
5398
  let entries = [];
4945
5399
  try {
4946
- entries = fs18.readdirSync(cacheDir);
5400
+ entries = fs20.readdirSync(cacheDir);
4947
5401
  } catch {
4948
5402
  }
4949
5403
  const hasChromium = entries.some((e) => e.startsWith("chromium"));
@@ -4969,17 +5423,17 @@ var diagMcp = async (_ctx) => {
4969
5423
  };
4970
5424
 
4971
5425
  // src/scripts/discoverQaContext.ts
4972
- import * as fs20 from "fs";
4973
- import * as path19 from "path";
5426
+ import * as fs22 from "fs";
5427
+ import * as path21 from "path";
4974
5428
 
4975
5429
  // src/scripts/frameworkDetectors.ts
4976
- import * as fs19 from "fs";
4977
- import * as path18 from "path";
5430
+ import * as fs21 from "fs";
5431
+ import * as path20 from "path";
4978
5432
  function detectFrameworks(cwd) {
4979
5433
  const out = [];
4980
5434
  let deps = {};
4981
5435
  try {
4982
- const pkg = JSON.parse(fs19.readFileSync(path18.join(cwd, "package.json"), "utf-8"));
5436
+ const pkg = JSON.parse(fs21.readFileSync(path20.join(cwd, "package.json"), "utf-8"));
4983
5437
  deps = { ...pkg.dependencies, ...pkg.devDependencies };
4984
5438
  } catch {
4985
5439
  return out;
@@ -5016,7 +5470,7 @@ function detectFrameworks(cwd) {
5016
5470
  }
5017
5471
  function findFile(cwd, candidates) {
5018
5472
  for (const c of candidates) {
5019
- if (fs19.existsSync(path18.join(cwd, c))) return c;
5473
+ if (fs21.existsSync(path20.join(cwd, c))) return c;
5020
5474
  }
5021
5475
  return null;
5022
5476
  }
@@ -5029,18 +5483,18 @@ var COLLECTION_DIRS = [
5029
5483
  function discoverPayloadCollections(cwd) {
5030
5484
  const out = [];
5031
5485
  for (const dir of COLLECTION_DIRS) {
5032
- const full = path18.join(cwd, dir);
5033
- if (!fs19.existsSync(full)) continue;
5486
+ const full = path20.join(cwd, dir);
5487
+ if (!fs21.existsSync(full)) continue;
5034
5488
  let files;
5035
5489
  try {
5036
- files = fs19.readdirSync(full).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
5490
+ files = fs21.readdirSync(full).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
5037
5491
  } catch {
5038
5492
  continue;
5039
5493
  }
5040
5494
  for (const file of files) {
5041
5495
  try {
5042
- const filePath = path18.join(full, file);
5043
- const content = fs19.readFileSync(filePath, "utf-8").slice(0, 1e4);
5496
+ const filePath = path20.join(full, file);
5497
+ const content = fs21.readFileSync(filePath, "utf-8").slice(0, 1e4);
5044
5498
  const slugMatch = content.match(/slug:\s*['"]([a-z0-9-]+)['"]/);
5045
5499
  if (!slugMatch) continue;
5046
5500
  const slug = slugMatch[1];
@@ -5054,7 +5508,7 @@ function discoverPayloadCollections(cwd) {
5054
5508
  out.push({
5055
5509
  name,
5056
5510
  slug,
5057
- filePath: path18.relative(cwd, filePath),
5511
+ filePath: path20.relative(cwd, filePath),
5058
5512
  fields: fields.slice(0, 20),
5059
5513
  hasAdmin
5060
5514
  });
@@ -5068,28 +5522,28 @@ var ADMIN_COMPONENT_DIRS = ["src/ui/admin", "src/admin/components", "src/compone
5068
5522
  function discoverAdminComponents(cwd, collections) {
5069
5523
  const out = [];
5070
5524
  for (const dir of ADMIN_COMPONENT_DIRS) {
5071
- const full = path18.join(cwd, dir);
5072
- if (!fs19.existsSync(full)) continue;
5525
+ const full = path20.join(cwd, dir);
5526
+ if (!fs21.existsSync(full)) continue;
5073
5527
  let entries;
5074
5528
  try {
5075
- entries = fs19.readdirSync(full, { withFileTypes: true });
5529
+ entries = fs21.readdirSync(full, { withFileTypes: true });
5076
5530
  } catch {
5077
5531
  continue;
5078
5532
  }
5079
5533
  for (const entry of entries) {
5080
- const entryPath = path18.join(full, entry.name);
5534
+ const entryPath = path20.join(full, entry.name);
5081
5535
  let name;
5082
5536
  let filePath;
5083
5537
  if (entry.isDirectory()) {
5084
5538
  const indexFile = ["index.tsx", "index.ts", "index.jsx", "index.js"].find(
5085
- (f) => fs19.existsSync(path18.join(entryPath, f))
5539
+ (f) => fs21.existsSync(path20.join(entryPath, f))
5086
5540
  );
5087
5541
  if (!indexFile) continue;
5088
5542
  name = entry.name;
5089
- filePath = path18.relative(cwd, path18.join(entryPath, indexFile));
5543
+ filePath = path20.relative(cwd, path20.join(entryPath, indexFile));
5090
5544
  } else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
5091
5545
  name = entry.name.replace(/\.(tsx?|jsx?)$/, "");
5092
- filePath = path18.relative(cwd, entryPath);
5546
+ filePath = path20.relative(cwd, entryPath);
5093
5547
  } else {
5094
5548
  continue;
5095
5549
  }
@@ -5097,7 +5551,7 @@ function discoverAdminComponents(cwd, collections) {
5097
5551
  if (collections) {
5098
5552
  for (const col of collections) {
5099
5553
  try {
5100
- const colContent = fs19.readFileSync(path18.join(cwd, col.filePath), "utf-8");
5554
+ const colContent = fs21.readFileSync(path20.join(cwd, col.filePath), "utf-8");
5101
5555
  if (colContent.includes(name)) {
5102
5556
  usedInCollection = col.slug;
5103
5557
  break;
@@ -5116,8 +5570,8 @@ function scanApiRoutes(cwd) {
5116
5570
  const out = [];
5117
5571
  const appDirs = ["src/app", "app"];
5118
5572
  for (const appDir of appDirs) {
5119
- const apiDir = path18.join(cwd, appDir, "api");
5120
- if (!fs19.existsSync(apiDir)) continue;
5573
+ const apiDir = path20.join(cwd, appDir, "api");
5574
+ if (!fs21.existsSync(apiDir)) continue;
5121
5575
  walkApiRoutes(apiDir, "/api", cwd, out);
5122
5576
  break;
5123
5577
  }
@@ -5126,14 +5580,14 @@ function scanApiRoutes(cwd) {
5126
5580
  function walkApiRoutes(dir, prefix, cwd, out) {
5127
5581
  let entries;
5128
5582
  try {
5129
- entries = fs19.readdirSync(dir, { withFileTypes: true });
5583
+ entries = fs21.readdirSync(dir, { withFileTypes: true });
5130
5584
  } catch {
5131
5585
  return;
5132
5586
  }
5133
5587
  const routeFile = entries.find((e) => e.isFile() && /^route\.(ts|js|tsx|jsx)$/.test(e.name));
5134
5588
  if (routeFile) {
5135
5589
  try {
5136
- const content = fs19.readFileSync(path18.join(dir, routeFile.name), "utf-8").slice(0, 5e3);
5590
+ const content = fs21.readFileSync(path20.join(dir, routeFile.name), "utf-8").slice(0, 5e3);
5137
5591
  const methods = HTTP_METHODS.filter(
5138
5592
  (m) => new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`).test(content)
5139
5593
  );
@@ -5141,7 +5595,7 @@ function walkApiRoutes(dir, prefix, cwd, out) {
5141
5595
  out.push({
5142
5596
  path: prefix,
5143
5597
  methods,
5144
- filePath: path18.relative(cwd, path18.join(dir, routeFile.name))
5598
+ filePath: path20.relative(cwd, path20.join(dir, routeFile.name))
5145
5599
  });
5146
5600
  }
5147
5601
  } catch {
@@ -5152,7 +5606,7 @@ function walkApiRoutes(dir, prefix, cwd, out) {
5152
5606
  if (entry.name === "node_modules" || entry.name === ".next") continue;
5153
5607
  let segment = entry.name;
5154
5608
  if (segment.startsWith("(") && segment.endsWith(")")) {
5155
- walkApiRoutes(path18.join(dir, entry.name), prefix, cwd, out);
5609
+ walkApiRoutes(path20.join(dir, entry.name), prefix, cwd, out);
5156
5610
  continue;
5157
5611
  }
5158
5612
  if (segment.startsWith("[[") && segment.endsWith("]]")) {
@@ -5160,7 +5614,7 @@ function walkApiRoutes(dir, prefix, cwd, out) {
5160
5614
  } else if (segment.startsWith("[") && segment.endsWith("]")) {
5161
5615
  segment = `:${segment.slice(1, -1)}`;
5162
5616
  }
5163
- walkApiRoutes(path18.join(dir, entry.name), `${prefix}/${segment}`, cwd, out);
5617
+ walkApiRoutes(path20.join(dir, entry.name), `${prefix}/${segment}`, cwd, out);
5164
5618
  }
5165
5619
  }
5166
5620
  var BUILTIN_ENV_VARS = /* @__PURE__ */ new Set([
@@ -5180,10 +5634,10 @@ var BUILTIN_ENV_VARS = /* @__PURE__ */ new Set([
5180
5634
  function scanEnvVars(cwd) {
5181
5635
  const candidates = [".env.example", ".env.local.example", ".env.template"];
5182
5636
  for (const envFile of candidates) {
5183
- const envPath = path18.join(cwd, envFile);
5184
- if (!fs19.existsSync(envPath)) continue;
5637
+ const envPath = path20.join(cwd, envFile);
5638
+ if (!fs21.existsSync(envPath)) continue;
5185
5639
  try {
5186
- const content = fs19.readFileSync(envPath, "utf-8");
5640
+ const content = fs21.readFileSync(envPath, "utf-8");
5187
5641
  const vars = [];
5188
5642
  for (const line of content.split("\n")) {
5189
5643
  const trimmed = line.trim();
@@ -5231,9 +5685,9 @@ function runQaDiscovery(cwd) {
5231
5685
  }
5232
5686
  function detectDevServer(cwd, out) {
5233
5687
  try {
5234
- const pkg = JSON.parse(fs20.readFileSync(path19.join(cwd, "package.json"), "utf-8"));
5688
+ const pkg = JSON.parse(fs22.readFileSync(path21.join(cwd, "package.json"), "utf-8"));
5235
5689
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
5236
- const pm = fs20.existsSync(path19.join(cwd, "pnpm-lock.yaml")) ? "pnpm" : fs20.existsSync(path19.join(cwd, "yarn.lock")) ? "yarn" : fs20.existsSync(path19.join(cwd, "bun.lockb")) ? "bun" : "npm";
5690
+ const pm = fs22.existsSync(path21.join(cwd, "pnpm-lock.yaml")) ? "pnpm" : fs22.existsSync(path21.join(cwd, "yarn.lock")) ? "yarn" : fs22.existsSync(path21.join(cwd, "bun.lockb")) ? "bun" : "npm";
5237
5691
  if (pkg.scripts?.dev) out.devCommand = `${pm} dev`;
5238
5692
  if (allDeps.next || allDeps.nuxt) out.devPort = 3e3;
5239
5693
  else if (allDeps.vite) out.devPort = 5173;
@@ -5243,8 +5697,8 @@ function detectDevServer(cwd, out) {
5243
5697
  function scanFrontendRoutes(cwd, out) {
5244
5698
  const appDirs = ["src/app", "app"];
5245
5699
  for (const appDir of appDirs) {
5246
- const full = path19.join(cwd, appDir);
5247
- if (!fs20.existsSync(full)) continue;
5700
+ const full = path21.join(cwd, appDir);
5701
+ if (!fs22.existsSync(full)) continue;
5248
5702
  walkFrontendRoutes(full, "", out);
5249
5703
  break;
5250
5704
  }
@@ -5252,7 +5706,7 @@ function scanFrontendRoutes(cwd, out) {
5252
5706
  function walkFrontendRoutes(dir, prefix, out) {
5253
5707
  let entries;
5254
5708
  try {
5255
- entries = fs20.readdirSync(dir, { withFileTypes: true });
5709
+ entries = fs22.readdirSync(dir, { withFileTypes: true });
5256
5710
  } catch {
5257
5711
  return;
5258
5712
  }
@@ -5269,7 +5723,7 @@ function walkFrontendRoutes(dir, prefix, out) {
5269
5723
  if (entry.name === "node_modules" || entry.name === ".next") continue;
5270
5724
  let segment = entry.name;
5271
5725
  if (segment.startsWith("(") && segment.endsWith(")")) {
5272
- walkFrontendRoutes(path19.join(dir, entry.name), prefix, out);
5726
+ walkFrontendRoutes(path21.join(dir, entry.name), prefix, out);
5273
5727
  continue;
5274
5728
  }
5275
5729
  if (segment.startsWith("[[") && segment.endsWith("]]")) {
@@ -5277,7 +5731,7 @@ function walkFrontendRoutes(dir, prefix, out) {
5277
5731
  } else if (segment.startsWith("[") && segment.endsWith("]")) {
5278
5732
  segment = `:${segment.slice(1, -1)}`;
5279
5733
  }
5280
- walkFrontendRoutes(path19.join(dir, entry.name), `${prefix}/${segment}`, out);
5734
+ walkFrontendRoutes(path21.join(dir, entry.name), `${prefix}/${segment}`, out);
5281
5735
  }
5282
5736
  }
5283
5737
  function detectAuthFiles(cwd, out) {
@@ -5294,23 +5748,23 @@ function detectAuthFiles(cwd, out) {
5294
5748
  "src/app/api/oauth"
5295
5749
  ];
5296
5750
  for (const c of candidates) {
5297
- if (fs20.existsSync(path19.join(cwd, c))) out.authFiles.push(c);
5751
+ if (fs22.existsSync(path21.join(cwd, c))) out.authFiles.push(c);
5298
5752
  }
5299
5753
  }
5300
5754
  function detectRoles(cwd, out) {
5301
5755
  const rolePaths = ["src/types", "src/lib", "src/utils", "src/constants", "src/access", "src/collections"];
5302
5756
  for (const rp of rolePaths) {
5303
- const dir = path19.join(cwd, rp);
5304
- if (!fs20.existsSync(dir)) continue;
5757
+ const dir = path21.join(cwd, rp);
5758
+ if (!fs22.existsSync(dir)) continue;
5305
5759
  let files;
5306
5760
  try {
5307
- files = fs20.readdirSync(dir).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
5761
+ files = fs22.readdirSync(dir).filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
5308
5762
  } catch {
5309
5763
  continue;
5310
5764
  }
5311
5765
  for (const f of files) {
5312
5766
  try {
5313
- const content = fs20.readFileSync(path19.join(dir, f), "utf-8").slice(0, 5e3);
5767
+ const content = fs22.readFileSync(path21.join(dir, f), "utf-8").slice(0, 5e3);
5314
5768
  const roleMatches = content.match(/(?:role|Role|ROLE)\s*[=:]\s*['"](\w+)['"]/g);
5315
5769
  if (roleMatches) {
5316
5770
  for (const m of roleMatches) {
@@ -5543,8 +5997,8 @@ function failedAction3(reason) {
5543
5997
  }
5544
5998
 
5545
5999
  // src/scripts/dispatchJobFileTicks.ts
5546
- import * as fs22 from "fs";
5547
- import * as path21 from "path";
6000
+ import * as fs24 from "fs";
6001
+ import * as path23 from "path";
5548
6002
 
5549
6003
  // src/scripts/jobFrontmatter.ts
5550
6004
  var SCHEDULE_EVERY_VALUES = [
@@ -5803,8 +6257,8 @@ var ContentsApiBackend = class {
5803
6257
  };
5804
6258
 
5805
6259
  // src/scripts/jobState/localFileBackend.ts
5806
- import * as fs21 from "fs";
5807
- import * as path20 from "path";
6260
+ import * as fs23 from "fs";
6261
+ import * as path22 from "path";
5808
6262
  var LocalFileBackend = class {
5809
6263
  name = "local-file";
5810
6264
  cwd;
@@ -5819,7 +6273,7 @@ var LocalFileBackend = class {
5819
6273
  if (!opts.owner || !opts.repo) throw new Error("LocalFileBackend: owner and repo are required");
5820
6274
  this.cwd = opts.cwd;
5821
6275
  this.jobsDir = opts.jobsDir;
5822
- this.absDir = path20.join(opts.cwd, opts.jobsDir);
6276
+ this.absDir = path22.join(opts.cwd, opts.jobsDir);
5823
6277
  this.owner = opts.owner;
5824
6278
  this.repo = opts.repo;
5825
6279
  this.cache = opts.cache ?? defaultCacheAdapter();
@@ -5834,7 +6288,7 @@ var LocalFileBackend = class {
5834
6288
  `);
5835
6289
  return;
5836
6290
  }
5837
- fs21.mkdirSync(this.absDir, { recursive: true });
6291
+ fs23.mkdirSync(this.absDir, { recursive: true });
5838
6292
  const prefix = this.cacheKeyPrefix();
5839
6293
  const probeKey = `${prefix}probe-${Date.now()}`;
5840
6294
  try {
@@ -5863,7 +6317,7 @@ var LocalFileBackend = class {
5863
6317
  `);
5864
6318
  return;
5865
6319
  }
5866
- if (!fs21.existsSync(this.absDir)) {
6320
+ if (!fs23.existsSync(this.absDir)) {
5867
6321
  return;
5868
6322
  }
5869
6323
  const key = `${this.cacheKeyPrefix()}${process.env.GITHUB_RUN_ID ?? "norunid"}-${Date.now()}`;
@@ -5879,11 +6333,11 @@ var LocalFileBackend = class {
5879
6333
  }
5880
6334
  load(slug) {
5881
6335
  const relPath = stateFilePath(this.jobsDir, slug);
5882
- const absPath = path20.join(this.cwd, relPath);
5883
- if (!fs21.existsSync(absPath)) {
6336
+ const absPath = path22.join(this.cwd, relPath);
6337
+ if (!fs23.existsSync(absPath)) {
5884
6338
  return { path: relPath, handle: null, state: initialStateEnvelope("seed"), created: true };
5885
6339
  }
5886
- const raw = fs21.readFileSync(absPath, "utf-8");
6340
+ const raw = fs23.readFileSync(absPath, "utf-8");
5887
6341
  let parsed;
5888
6342
  try {
5889
6343
  parsed = JSON.parse(raw);
@@ -5900,10 +6354,10 @@ var LocalFileBackend = class {
5900
6354
  if (!loaded.created && isStateUnchanged(loaded.state, next)) {
5901
6355
  return false;
5902
6356
  }
5903
- const absPath = path20.join(this.cwd, loaded.path);
5904
- fs21.mkdirSync(path20.dirname(absPath), { recursive: true });
6357
+ const absPath = path22.join(this.cwd, loaded.path);
6358
+ fs23.mkdirSync(path22.dirname(absPath), { recursive: true });
5905
6359
  const body = JSON.stringify(next, null, 2) + "\n";
5906
- fs21.writeFileSync(absPath, body, "utf-8");
6360
+ fs23.writeFileSync(absPath, body, "utf-8");
5907
6361
  return true;
5908
6362
  }
5909
6363
  cacheKeyPrefix() {
@@ -5981,7 +6435,7 @@ var dispatchJobFileTicks = async (ctx, _profile, args) => {
5981
6435
  await backend.hydrate();
5982
6436
  }
5983
6437
  try {
5984
- const slugs = listJobSlugs(path21.join(ctx.cwd, jobsDir));
6438
+ const slugs = listJobSlugs(path23.join(ctx.cwd, jobsDir));
5985
6439
  ctx.data.jobSlugCount = slugs.length;
5986
6440
  if (slugs.length === 0) {
5987
6441
  process.stdout.write(`[jobs] no job files in ${jobsDir}
@@ -6086,17 +6540,17 @@ function formatAgo(ms) {
6086
6540
  }
6087
6541
  function readJobFrontmatter(cwd, jobsDir, slug) {
6088
6542
  try {
6089
- const raw = fs22.readFileSync(path21.join(cwd, jobsDir, `${slug}.md`), "utf-8");
6543
+ const raw = fs24.readFileSync(path23.join(cwd, jobsDir, `${slug}.md`), "utf-8");
6090
6544
  return splitFrontmatter(raw).frontmatter;
6091
6545
  } catch {
6092
6546
  return {};
6093
6547
  }
6094
6548
  }
6095
6549
  function listJobSlugs(absDir) {
6096
- if (!fs22.existsSync(absDir)) return [];
6550
+ if (!fs24.existsSync(absDir)) return [];
6097
6551
  let entries;
6098
6552
  try {
6099
- entries = fs22.readdirSync(absDir, { withFileTypes: true });
6553
+ entries = fs24.readdirSync(absDir, { withFileTypes: true });
6100
6554
  } catch {
6101
6555
  return [];
6102
6556
  }
@@ -6779,7 +7233,7 @@ function ensureFeatureBranch(issueNumber, title, defaultBranch2, cwd, baseBranch
6779
7233
 
6780
7234
  // src/gha.ts
6781
7235
  import { execFileSync as execFileSync16 } from "child_process";
6782
- import * as fs23 from "fs";
7236
+ import * as fs25 from "fs";
6783
7237
  function getRunUrl() {
6784
7238
  const server = process.env.GITHUB_SERVER_URL;
6785
7239
  const repo = process.env.GITHUB_REPOSITORY;
@@ -6790,10 +7244,10 @@ function getRunUrl() {
6790
7244
  function reactToTriggerComment(cwd) {
6791
7245
  if (process.env.GITHUB_EVENT_NAME !== "issue_comment") return;
6792
7246
  const eventPath = process.env.GITHUB_EVENT_PATH;
6793
- if (!eventPath || !fs23.existsSync(eventPath)) return;
7247
+ if (!eventPath || !fs25.existsSync(eventPath)) return;
6794
7248
  let event = null;
6795
7249
  try {
6796
- event = JSON.parse(fs23.readFileSync(eventPath, "utf-8"));
7250
+ event = JSON.parse(fs25.readFileSync(eventPath, "utf-8"));
6797
7251
  } catch {
6798
7252
  return;
6799
7253
  }
@@ -7082,22 +7536,22 @@ var handleAbandonedGoal = async (ctx) => {
7082
7536
 
7083
7537
  // src/scripts/initFlow.ts
7084
7538
  import { execFileSync as execFileSync18 } from "child_process";
7085
- import * as fs25 from "fs";
7086
- import * as path23 from "path";
7539
+ import * as fs27 from "fs";
7540
+ import * as path25 from "path";
7087
7541
 
7088
7542
  // src/scripts/loadQaGuide.ts
7089
- import * as fs24 from "fs";
7090
- import * as path22 from "path";
7543
+ import * as fs26 from "fs";
7544
+ import * as path24 from "path";
7091
7545
  var QA_GUIDE_REL_PATH = ".kody/qa-guide.md";
7092
7546
  var loadQaGuide = async (ctx) => {
7093
- const full = path22.join(ctx.cwd, QA_GUIDE_REL_PATH);
7094
- if (!fs24.existsSync(full)) {
7547
+ const full = path24.join(ctx.cwd, QA_GUIDE_REL_PATH);
7548
+ if (!fs26.existsSync(full)) {
7095
7549
  ctx.data.qaGuide = "";
7096
7550
  ctx.data.qaGuidePath = "";
7097
7551
  return;
7098
7552
  }
7099
7553
  try {
7100
- ctx.data.qaGuide = fs24.readFileSync(full, "utf-8");
7554
+ ctx.data.qaGuide = fs26.readFileSync(full, "utf-8");
7101
7555
  ctx.data.qaGuidePath = QA_GUIDE_REL_PATH;
7102
7556
  } catch {
7103
7557
  ctx.data.qaGuide = "";
@@ -7107,9 +7561,9 @@ var loadQaGuide = async (ctx) => {
7107
7561
 
7108
7562
  // src/scripts/initFlow.ts
7109
7563
  function detectPackageManager(cwd) {
7110
- if (fs25.existsSync(path23.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
7111
- if (fs25.existsSync(path23.join(cwd, "yarn.lock"))) return "yarn";
7112
- if (fs25.existsSync(path23.join(cwd, "bun.lockb"))) return "bun";
7564
+ if (fs27.existsSync(path25.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
7565
+ if (fs27.existsSync(path25.join(cwd, "yarn.lock"))) return "yarn";
7566
+ if (fs27.existsSync(path25.join(cwd, "bun.lockb"))) return "bun";
7113
7567
  return "npm";
7114
7568
  }
7115
7569
  function qualityCommandsFor(pm) {
@@ -7231,48 +7685,48 @@ function performInit(cwd, force) {
7231
7685
  const pm = detectPackageManager(cwd);
7232
7686
  const ownerRepo = detectOwnerRepo(cwd);
7233
7687
  const defaultBranch2 = defaultBranchFromGit(cwd);
7234
- const configPath = path23.join(cwd, "kody.config.json");
7235
- if (fs25.existsSync(configPath) && !force) {
7688
+ const configPath = path25.join(cwd, "kody.config.json");
7689
+ if (fs27.existsSync(configPath) && !force) {
7236
7690
  skipped.push("kody.config.json");
7237
7691
  } else {
7238
7692
  const cfg = makeConfig(pm, ownerRepo, defaultBranch2);
7239
- fs25.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
7693
+ fs27.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
7240
7694
  `);
7241
7695
  wrote.push("kody.config.json");
7242
7696
  }
7243
- const workflowDir = path23.join(cwd, ".github", "workflows");
7244
- const workflowPath = path23.join(workflowDir, "kody.yml");
7245
- if (fs25.existsSync(workflowPath) && !force) {
7697
+ const workflowDir = path25.join(cwd, ".github", "workflows");
7698
+ const workflowPath = path25.join(workflowDir, "kody.yml");
7699
+ if (fs27.existsSync(workflowPath) && !force) {
7246
7700
  skipped.push(".github/workflows/kody.yml");
7247
7701
  } else {
7248
- fs25.mkdirSync(workflowDir, { recursive: true });
7249
- fs25.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
7702
+ fs27.mkdirSync(workflowDir, { recursive: true });
7703
+ fs27.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
7250
7704
  wrote.push(".github/workflows/kody.yml");
7251
7705
  }
7252
- const hasUi = fs25.existsSync(path23.join(cwd, "src/app")) || fs25.existsSync(path23.join(cwd, "app")) || fs25.existsSync(path23.join(cwd, "pages"));
7706
+ const hasUi = fs27.existsSync(path25.join(cwd, "src/app")) || fs27.existsSync(path25.join(cwd, "app")) || fs27.existsSync(path25.join(cwd, "pages"));
7253
7707
  if (hasUi) {
7254
- const qaGuidePath = path23.join(cwd, QA_GUIDE_REL_PATH);
7255
- if (fs25.existsSync(qaGuidePath) && !force) {
7708
+ const qaGuidePath = path25.join(cwd, QA_GUIDE_REL_PATH);
7709
+ if (fs27.existsSync(qaGuidePath) && !force) {
7256
7710
  skipped.push(QA_GUIDE_REL_PATH);
7257
7711
  } else {
7258
- fs25.mkdirSync(path23.dirname(qaGuidePath), { recursive: true });
7712
+ fs27.mkdirSync(path25.dirname(qaGuidePath), { recursive: true });
7259
7713
  const discovery = runQaDiscovery(cwd);
7260
- fs25.writeFileSync(qaGuidePath, generateQaGuideTemplate(discovery));
7714
+ fs27.writeFileSync(qaGuidePath, generateQaGuideTemplate(discovery));
7261
7715
  wrote.push(QA_GUIDE_REL_PATH);
7262
7716
  }
7263
7717
  }
7264
7718
  const builtinJobs = listBuiltinJobs();
7265
7719
  if (builtinJobs.length > 0) {
7266
- const jobsDir = path23.join(cwd, ".kody", "jobs");
7267
- fs25.mkdirSync(jobsDir, { recursive: true });
7720
+ const jobsDir = path25.join(cwd, ".kody", "jobs");
7721
+ fs27.mkdirSync(jobsDir, { recursive: true });
7268
7722
  for (const job of builtinJobs) {
7269
- const rel = path23.join(".kody", "jobs", `${job.slug}.md`);
7270
- const target = path23.join(cwd, rel);
7271
- if (fs25.existsSync(target) && !force) {
7723
+ const rel = path25.join(".kody", "jobs", `${job.slug}.md`);
7724
+ const target = path25.join(cwd, rel);
7725
+ if (fs27.existsSync(target) && !force) {
7272
7726
  skipped.push(rel);
7273
7727
  continue;
7274
7728
  }
7275
- fs25.writeFileSync(target, fs25.readFileSync(job.filePath, "utf-8"));
7729
+ fs27.writeFileSync(target, fs27.readFileSync(job.filePath, "utf-8"));
7276
7730
  wrote.push(rel);
7277
7731
  }
7278
7732
  }
@@ -7284,12 +7738,12 @@ function performInit(cwd, force) {
7284
7738
  continue;
7285
7739
  }
7286
7740
  if (profile.kind !== "scheduled" || !profile.schedule) continue;
7287
- const target = path23.join(workflowDir, `kody-${exe.name}.yml`);
7288
- if (fs25.existsSync(target) && !force) {
7741
+ const target = path25.join(workflowDir, `kody-${exe.name}.yml`);
7742
+ if (fs27.existsSync(target) && !force) {
7289
7743
  skipped.push(`.github/workflows/kody-${exe.name}.yml`);
7290
7744
  continue;
7291
7745
  }
7292
- fs25.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
7746
+ fs27.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
7293
7747
  wrote.push(`.github/workflows/kody-${exe.name}.yml`);
7294
7748
  }
7295
7749
  let labels;
@@ -7367,9 +7821,9 @@ init_loadConventions();
7367
7821
  init_loadCoverageRules();
7368
7822
 
7369
7823
  // src/goal/state.ts
7370
- import * as fs26 from "fs";
7371
- import * as path24 from "path";
7372
- var VALID_STATES = /* @__PURE__ */ new Set(["active", "abandoned", "closed", "done"]);
7824
+ import * as fs28 from "fs";
7825
+ import * as path26 from "path";
7826
+ var VALID_STATES = /* @__PURE__ */ new Set(["active", "abandoned", "closed", "awaiting-merge", "done"]);
7373
7827
  var GoalStateError = class extends Error {
7374
7828
  constructor(path34, message) {
7375
7829
  super(`Invalid goal state at ${path34}:
@@ -7395,6 +7849,9 @@ function parseGoalState(filePath, raw) {
7395
7849
  state: stateValue,
7396
7850
  extra: {}
7397
7851
  };
7852
+ if (typeof r.mergeApproved === "boolean") {
7853
+ parsed.mergeApproved = r.mergeApproved;
7854
+ }
7398
7855
  if (typeof r.lastDispatchedIssue === "number" && Number.isFinite(r.lastDispatchedIssue)) {
7399
7856
  parsed.lastDispatchedIssue = r.lastDispatchedIssue;
7400
7857
  }
@@ -7402,7 +7859,7 @@ function parseGoalState(filePath, raw) {
7402
7859
  const v = r[ts];
7403
7860
  if (typeof v === "string" && v.length > 0) parsed[ts] = v;
7404
7861
  }
7405
- const known = /* @__PURE__ */ new Set(["state", "lastDispatchedIssue", "updatedAt", "createdAt", "startedAt"]);
7862
+ const known = /* @__PURE__ */ new Set(["state", "mergeApproved", "lastDispatchedIssue", "updatedAt", "createdAt", "startedAt"]);
7406
7863
  for (const [k, v] of Object.entries(r)) {
7407
7864
  if (!known.has(k)) parsed.extra[k] = v;
7408
7865
  }
@@ -7410,6 +7867,7 @@ function parseGoalState(filePath, raw) {
7410
7867
  }
7411
7868
  function serializeGoalState(s) {
7412
7869
  const obj = { ...s.extra, state: s.state };
7870
+ if (s.mergeApproved !== void 0) obj.mergeApproved = s.mergeApproved;
7413
7871
  if (s.lastDispatchedIssue !== void 0) obj.lastDispatchedIssue = s.lastDispatchedIssue;
7414
7872
  if (s.createdAt !== void 0) obj.createdAt = s.createdAt;
7415
7873
  if (s.startedAt !== void 0) obj.startedAt = s.startedAt;
@@ -7418,16 +7876,16 @@ function serializeGoalState(s) {
7418
7876
  `;
7419
7877
  }
7420
7878
  function goalStatePath(cwd, goalId) {
7421
- return path24.join(cwd, ".kody", "goals", goalId, "state.json");
7879
+ return path26.join(cwd, ".kody", "goals", goalId, "state.json");
7422
7880
  }
7423
7881
  function readGoalState(cwd, goalId) {
7424
7882
  const file = goalStatePath(cwd, goalId);
7425
- if (!fs26.existsSync(file)) {
7883
+ if (!fs28.existsSync(file)) {
7426
7884
  throw new GoalStateError(file, "file not found");
7427
7885
  }
7428
7886
  let raw;
7429
7887
  try {
7430
- raw = JSON.parse(fs26.readFileSync(file, "utf-8"));
7888
+ raw = JSON.parse(fs28.readFileSync(file, "utf-8"));
7431
7889
  } catch (err) {
7432
7890
  throw new GoalStateError(file, `invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
7433
7891
  }
@@ -7435,8 +7893,8 @@ function readGoalState(cwd, goalId) {
7435
7893
  }
7436
7894
  function writeGoalState(cwd, goalId, state) {
7437
7895
  const file = goalStatePath(cwd, goalId);
7438
- fs26.mkdirSync(path24.dirname(file), { recursive: true });
7439
- fs26.writeFileSync(file, serializeGoalState(state), "utf-8");
7896
+ fs28.mkdirSync(path26.dirname(file), { recursive: true });
7897
+ fs28.writeFileSync(file, serializeGoalState(state), "utf-8");
7440
7898
  }
7441
7899
  function nowIso() {
7442
7900
  return (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -7538,8 +7996,8 @@ var loadIssueStateComment = async (ctx, _profile, args) => {
7538
7996
  };
7539
7997
 
7540
7998
  // src/scripts/loadJobFromFile.ts
7541
- import * as fs27 from "fs";
7542
- import * as path25 from "path";
7999
+ import * as fs29 from "fs";
8000
+ import * as path27 from "path";
7543
8001
  var loadJobFromFile = async (ctx, _profile, args) => {
7544
8002
  const jobsDir = String(args?.jobsDir ?? ".kody/jobs");
7545
8003
  const slugArg = String(args?.slugArg ?? "job");
@@ -7547,11 +8005,11 @@ var loadJobFromFile = async (ctx, _profile, args) => {
7547
8005
  if (!slug) {
7548
8006
  throw new Error(`loadJobFromFile: ctx.args.${slugArg} must be a non-empty slug`);
7549
8007
  }
7550
- const absPath = path25.join(ctx.cwd, jobsDir, `${slug}.md`);
7551
- if (!fs27.existsSync(absPath)) {
8008
+ const absPath = path27.join(ctx.cwd, jobsDir, `${slug}.md`);
8009
+ if (!fs29.existsSync(absPath)) {
7552
8010
  throw new Error(`loadJobFromFile: job file not found: ${absPath}`);
7553
8011
  }
7554
- const raw = fs27.readFileSync(absPath, "utf-8");
8012
+ const raw = fs29.readFileSync(absPath, "utf-8");
7555
8013
  const { title, body } = parseJobFile(raw, slug);
7556
8014
  const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, jobsDir });
7557
8015
  const loaded = await backend.load(slug);
@@ -7590,8 +8048,8 @@ init_loadPriorArt();
7590
8048
  init_events();
7591
8049
 
7592
8050
  // src/taskContext.ts
7593
- import * as fs29 from "fs";
7594
- import * as path27 from "path";
8051
+ import * as fs31 from "fs";
8052
+ import * as path29 from "path";
7595
8053
  var TASK_CONTEXT_SCHEMA_VERSION = 1;
7596
8054
  function buildTaskContext(args) {
7597
8055
  return {
@@ -7607,10 +8065,10 @@ function buildTaskContext(args) {
7607
8065
  }
7608
8066
  function persistTaskContext(cwd, ctx) {
7609
8067
  try {
7610
- const dir = path27.join(cwd, ".kody", "runs", ctx.runId);
7611
- fs29.mkdirSync(dir, { recursive: true });
7612
- const file = path27.join(dir, "task-context.json");
7613
- fs29.writeFileSync(file, `${JSON.stringify(ctx, null, 2)}
8068
+ const dir = path29.join(cwd, ".kody", "runs", ctx.runId);
8069
+ fs31.mkdirSync(dir, { recursive: true });
8070
+ const file = path29.join(dir, "task-context.json");
8071
+ fs31.writeFileSync(file, `${JSON.stringify(ctx, null, 2)}
7614
8072
  `);
7615
8073
  return file;
7616
8074
  } catch (err) {
@@ -7888,6 +8346,23 @@ QA_REPORT_POSTED=${created.url} (verdict: ${verdict})
7888
8346
  ctx.output.exitCode = verdict === "FAIL" ? 1 : 0;
7889
8347
  };
7890
8348
 
8349
+ // src/scripts/parkGoalForMerge.ts
8350
+ var parkGoalForMerge = async (ctx) => {
8351
+ const goal = ctx.data.goal;
8352
+ if (!goal) return;
8353
+ const approved = goal.raw?.mergeApproved === true;
8354
+ if (approved) {
8355
+ process.stdout.write(`[goal-tick] goal ${goal.id}: merge approved \u2014 running finalize (one-shot)
8356
+ `);
8357
+ if (goal.raw) goal.raw.mergeApproved = false;
8358
+ return;
8359
+ }
8360
+ process.stdout.write(`[goal-tick] all task(s) done \u2014 parking goal ${goal.id} for manual merge (no auto-merge)
8361
+ `);
8362
+ goal.state = "awaiting-merge";
8363
+ goal.phase = "awaiting-merge";
8364
+ };
8365
+
7891
8366
  // src/scripts/parseAgentResult.ts
7892
8367
  init_prompt();
7893
8368
  var parseAgentResult2 = async (ctx, profile, agentResult) => {
@@ -8858,688 +9333,289 @@ var revertFlow = async (ctx) => {
8858
9333
  function buildCommitMessage(resolved) {
8859
9334
  if (resolved.length === 1) {
8860
9335
  const { full, subject } = resolved[0];
8861
- return subject ? `revert: "${subject}" (${full.slice(0, 7)})` : `revert: ${full.slice(0, 7)}`;
8862
- }
8863
- const shas = resolved.map((r) => r.full.slice(0, 7)).join(", ");
8864
- return `revert: ${resolved.length} commit(s) (${shas})`;
8865
- }
8866
- function buildPrSummary(resolved) {
8867
- return resolved.map((r) => `- Reverted \`${r.full.slice(0, 7)}\`${r.subject ? ` \u2014 ${r.subject}` : ""}`).join("\n");
8868
- }
8869
- function git3(args, cwd) {
8870
- return execFileSync23("git", args, {
8871
- encoding: "utf-8",
8872
- timeout: 3e4,
8873
- cwd,
8874
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
8875
- stdio: ["pipe", "pipe", "pipe"]
8876
- }).trim();
8877
- }
8878
- function isAncestorOfHead(sha, cwd) {
8879
- try {
8880
- execFileSync23("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
8881
- cwd,
8882
- env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
8883
- stdio: ["ignore", "ignore", "ignore"]
8884
- });
8885
- return true;
8886
- } catch {
8887
- return false;
8888
- }
8889
- }
8890
- function tryPostPr4(prNumber, body, cwd) {
8891
- try {
8892
- postPrReviewComment(prNumber, body, cwd);
8893
- } catch (err) {
8894
- const msg = err instanceof Error ? err.message : String(err);
8895
- process.stderr.write(`[kody revertFlow] PR comment on #${prNumber} failed: ${msg}
8896
- `);
8897
- }
8898
- }
8899
-
8900
- // src/scripts/reviewFlow.ts
8901
- init_issue();
8902
- var reviewFlow = async (ctx) => {
8903
- const prNumber = ctx.args.pr;
8904
- const pr = getPr(prNumber, ctx.cwd);
8905
- if (pr.state !== "OPEN") {
8906
- ctx.output.exitCode = 1;
8907
- ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
8908
- ctx.skipAgent = true;
8909
- return;
8910
- }
8911
- ctx.data.pr = pr;
8912
- ctx.data.commentTargetType = "pr";
8913
- ctx.data.commentTargetNumber = prNumber;
8914
- checkoutPrBranch(prNumber, ctx.cwd);
8915
- ctx.data.branch = getCurrentBranch(ctx.cwd);
8916
- ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
8917
- const runUrl = getRunUrl();
8918
- const runSuffix = runUrl ? `, run ${runUrl}` : "";
8919
- tryPostPr5(prNumber, `\u{1F440} kody review started on PR #${prNumber}${runSuffix}`, ctx.cwd);
8920
- };
8921
- function tryPostPr5(prNumber, body, cwd) {
8922
- try {
8923
- postPrReviewComment(prNumber, body, cwd);
8924
- } catch {
8925
- }
8926
- }
8927
-
8928
- // src/scripts/runFlow.ts
8929
- init_issue();
8930
- var runFlow = async (ctx) => {
8931
- const issueNumber = ctx.args.issue;
8932
- const issue = getIssue(issueNumber, ctx.cwd);
8933
- ctx.data.issue = issue;
8934
- ctx.data.commentTargetType = "issue";
8935
- ctx.data.commentTargetNumber = issueNumber;
8936
- const argBase = resolveBaseOverride(ctx.args.base);
8937
- const baseRaw = ctx.args.base;
8938
- if (baseRaw && !argBase) {
8939
- process.stderr.write(`[kody runFlow] ignoring --base "${baseRaw}" (must match kody-task or goal-branch pattern)
8940
- `);
8941
- }
8942
- const base = argBase;
8943
- if (base) {
8944
- ctx.data.baseBranch = base;
8945
- process.stderr.write(`[kody runFlow] resolved base branch: ${base} (from --base)
8946
- `);
8947
- }
8948
- const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd, base ?? void 0);
8949
- ctx.data.branch = branchInfo.branch;
8950
- const runUrl = getRunUrl();
8951
- const startMsg = runUrl ? `\u2699\uFE0F kody started \u2014 branch \`${ctx.data.branch}\`, run ${runUrl}` : `\u2699\uFE0F kody started \u2014 branch \`${ctx.data.branch}\``;
8952
- tryPost(issueNumber, startMsg, ctx.cwd);
8953
- };
8954
- function tryPost(issueNumber, body, cwd) {
8955
- try {
8956
- postIssueComment(issueNumber, body, cwd);
8957
- } catch {
8958
- }
8959
- }
8960
- function resolveBaseOverride(value) {
8961
- if (!value) return null;
8962
- if (value.length > 200) return null;
8963
- if (value.includes("..")) return null;
8964
- if (!/^[a-z0-9][a-z0-9/._-]*$/.test(value)) return null;
8965
- return value;
8966
- }
8967
-
8968
- // src/scripts/runTickScript.ts
8969
- import { spawnSync } from "child_process";
8970
- import * as fs30 from "fs";
8971
- import * as path28 from "path";
8972
- var runTickScript = async (ctx, _profile, args) => {
8973
- ctx.skipAgent = true;
8974
- const jobsDir = String(args?.jobsDir ?? ".kody/jobs");
8975
- const slugArg = String(args?.slugArg ?? "job");
8976
- const fenceLabel = String(args?.fenceLabel ?? "kody-job-next-state");
8977
- const slug = String(ctx.args[slugArg] ?? "").trim();
8978
- if (!slug) {
8979
- ctx.output.exitCode = 99;
8980
- ctx.output.reason = `runTickScript: ctx.args.${slugArg} must be a non-empty slug`;
8981
- return;
8982
- }
8983
- const jobPath = path28.join(ctx.cwd, jobsDir, `${slug}.md`);
8984
- if (!fs30.existsSync(jobPath)) {
8985
- ctx.output.exitCode = 99;
8986
- ctx.output.reason = `runTickScript: job file not found: ${jobPath}`;
8987
- return;
8988
- }
8989
- const raw = fs30.readFileSync(jobPath, "utf-8");
8990
- const { frontmatter } = splitFrontmatter(raw);
8991
- const tickScript = frontmatter.tickScript;
8992
- if (!tickScript) {
8993
- ctx.output.exitCode = 99;
8994
- ctx.output.reason = `runTickScript: job ${slug} has no \`tickScript:\` frontmatter \u2014 route via job-tick instead`;
8995
- return;
8996
- }
8997
- const scriptPath = path28.isAbsolute(tickScript) ? tickScript : path28.join(ctx.cwd, tickScript);
8998
- if (!fs30.existsSync(scriptPath)) {
8999
- ctx.output.exitCode = 99;
9000
- ctx.output.reason = `runTickScript: tickScript not found: ${scriptPath}`;
9001
- return;
9002
- }
9003
- const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, jobsDir });
9004
- let loaded;
9005
- try {
9006
- loaded = await backend.load(slug);
9007
- } catch (err) {
9008
- ctx.output.exitCode = 99;
9009
- ctx.output.reason = `runTickScript: state load failed: ${err instanceof Error ? err.message : String(err)}`;
9010
- return;
9011
- }
9012
- ctx.data.jobSlug = slug;
9013
- ctx.data.jobState = loaded;
9014
- const childEnv = buildChildEnv(process.env, Boolean(ctx.args.force));
9015
- const result = spawnSync("bash", [scriptPath], {
9016
- cwd: ctx.cwd,
9017
- env: childEnv,
9018
- stdio: ["ignore", "pipe", "pipe"],
9019
- encoding: "utf-8",
9020
- timeout: 5 * 60 * 1e3,
9021
- // Default maxBuffer is 1MB — a chatty `gh pr list --json …` over a
9022
- // busy repo (or an accidental `set -x`) can blow that and silently
9023
- // truncate stdout, which is the exact "silent state drop" failure
9024
- // mode this whole executable was written to prevent. 16MB is well
9025
- // above any realistic tick output.
9026
- maxBuffer: 16 * 1024 * 1024
9027
- });
9028
- if (result.stdout) process.stdout.write(result.stdout);
9029
- if (result.stderr) process.stderr.write(result.stderr);
9030
- if (result.error) {
9031
- ctx.output.exitCode = 99;
9032
- ctx.output.reason = `runTickScript: spawn error: ${result.error.message}`;
9033
- return;
9034
- }
9035
- if (result.signal) {
9036
- ctx.output.exitCode = 124;
9037
- ctx.output.reason = `runTickScript: ${tickScript} killed by ${result.signal} (likely 5min timeout)`;
9038
- return;
9039
- }
9040
- if (result.status !== 0) {
9041
- ctx.output.exitCode = result.status ?? 99;
9042
- ctx.output.reason = `runTickScript: ${tickScript} exited ${result.status}`;
9043
- return;
9044
- }
9045
- const prevRev = loaded.state.rev ?? 0;
9046
- const parsed = extractNextStateFromText(result.stdout ?? "", fenceLabel, prevRev);
9047
- if (parsed.error) {
9048
- ctx.data.nextStateParseError = parsed.error;
9049
- ctx.output.exitCode = 1;
9050
- ctx.output.reason = `runTickScript: ${parsed.error}`;
9051
- return;
9052
- }
9053
- ctx.data.nextJobState = parsed.envelope;
9054
- };
9055
- function buildChildEnv(parent, force) {
9056
- const allow = /* @__PURE__ */ new Set([
9057
- "PATH",
9058
- "HOME",
9059
- "USER",
9060
- "LOGNAME",
9061
- "SHELL",
9062
- "TMPDIR",
9063
- "LANG",
9064
- "LC_ALL",
9065
- "TERM",
9066
- // GitHub auth — `gh` reads these.
9067
- "GH_TOKEN",
9068
- "GH_PAT",
9069
- "GITHUB_TOKEN",
9070
- // CI metadata commonly read by tick scripts (`gh repo view`,
9071
- // workflow run links, etc.). All public values from GitHub Actions.
9072
- "GITHUB_ACTIONS",
9073
- "GITHUB_ACTOR",
9074
- "GITHUB_REPOSITORY",
9075
- "GITHUB_REPOSITORY_OWNER",
9076
- "GITHUB_REF",
9077
- "GITHUB_SHA",
9078
- "GITHUB_RUN_ID",
9079
- "GITHUB_RUN_NUMBER",
9080
- "GITHUB_WORKFLOW",
9081
- "GITHUB_JOB",
9082
- "GITHUB_SERVER_URL",
9083
- "GITHUB_API_URL",
9084
- "GITHUB_EVENT_NAME",
9085
- "RUNNER_OS",
9086
- "RUNNER_ARCH"
9087
- ]);
9088
- const out = {};
9089
- for (const [key, value] of Object.entries(parent)) {
9090
- if (value === void 0) continue;
9091
- if (allow.has(key) || key.startsWith("KODY_PUBLIC_")) {
9092
- out[key] = value;
9093
- }
9094
- }
9095
- if (force) out.KODY_FORCE = "1";
9096
- return out;
9097
- }
9098
-
9099
- // src/scripts/brainServe.ts
9100
- import { createServer } from "http";
9101
- import * as fs32 from "fs";
9102
- import * as path30 from "path";
9103
-
9104
- // src/scripts/brainTurnLog.ts
9105
- import * as fs31 from "fs";
9106
- import * as path29 from "path";
9107
- var live = /* @__PURE__ */ new Map();
9108
- function eventsPath(dir, chatId) {
9109
- return path29.join(dir, ".kody", "brain-events", `${chatId}.jsonl`);
9110
- }
9111
- function lastPersistedSeq(dir, chatId) {
9112
- const p = eventsPath(dir, chatId);
9113
- if (!fs31.existsSync(p)) return 0;
9114
- const lines = fs31.readFileSync(p, "utf-8").split("\n").filter(Boolean);
9115
- if (lines.length === 0) return 0;
9116
- try {
9117
- return JSON.parse(lines[lines.length - 1]).seq || 0;
9118
- } catch {
9119
- return 0;
9120
- }
9121
- }
9122
- function readSince(dir, chatId, since) {
9123
- const p = eventsPath(dir, chatId);
9124
- if (!fs31.existsSync(p)) return [];
9125
- const out = [];
9126
- for (const line of fs31.readFileSync(p, "utf-8").split("\n")) {
9127
- if (!line) continue;
9128
- try {
9129
- const rec = JSON.parse(line);
9130
- if (rec.seq > since) out.push(rec);
9131
- } catch {
9132
- }
9133
- }
9134
- return out;
9135
- }
9136
- function isTerminal(event) {
9137
- return event.type === "done" || event.type === "error";
9138
- }
9139
- function beginTurn(dir, chatId) {
9140
- const existing = live.get(chatId);
9141
- const seqFloor = existing ? existing.seq : lastPersistedSeq(dir, chatId);
9142
- const turn = (existing?.turn ?? 0) + 1;
9143
- const state = {
9144
- seq: seqFloor,
9145
- turn,
9146
- status: "running",
9147
- terminal: null,
9148
- subscribers: /* @__PURE__ */ new Set()
9149
- };
9150
- live.set(chatId, state);
9151
- const p = eventsPath(dir, chatId);
9152
- fs31.mkdirSync(path29.dirname(p), { recursive: true });
9153
- return (event) => {
9154
- state.seq += 1;
9155
- const rec = { seq: state.seq, turn, ts: Date.now(), event };
9156
- try {
9157
- fs31.appendFileSync(p, JSON.stringify(rec) + "\n");
9158
- } catch (err) {
9159
- process.stderr.write(
9160
- `[brain-turn-log] append failed for ${chatId}: ${err instanceof Error ? err.message : String(err)}
9161
- `
9162
- );
9163
- }
9164
- for (const fn of state.subscribers) {
9165
- try {
9166
- fn(rec);
9167
- } catch {
9168
- }
9169
- }
9170
- if (isTerminal(event)) {
9171
- state.status = "ended";
9172
- state.terminal = rec;
9173
- const subs = [...state.subscribers];
9174
- state.subscribers.clear();
9175
- for (const fn of subs) {
9176
- try {
9177
- fn(null);
9178
- } catch {
9179
- }
9180
- }
9181
- }
9182
- };
9183
- }
9184
- function endTurnIfUnterminated(dir, chatId, errMessage) {
9185
- const state = live.get(chatId);
9186
- if (!state || state.status === "ended") return;
9187
- state.seq += 1;
9188
- const rec = {
9189
- seq: state.seq,
9190
- turn: state.turn,
9191
- ts: Date.now(),
9192
- event: { type: "error", error: errMessage || "turn ended unexpectedly", chatId }
9193
- };
9194
- try {
9195
- fs31.appendFileSync(eventsPath(dir, chatId), JSON.stringify(rec) + "\n");
9196
- } catch {
9197
- }
9198
- state.status = "ended";
9199
- state.terminal = rec;
9200
- const subs = [...state.subscribers];
9201
- state.subscribers.clear();
9202
- for (const fn of subs) {
9203
- try {
9204
- fn(rec);
9205
- fn(null);
9206
- } catch {
9207
- }
9208
- }
9209
- }
9210
- function subscribe(dir, chatId, since, onRecord, onClose) {
9211
- const backlog = readSince(dir, chatId, since);
9212
- for (const rec of backlog) onRecord(rec);
9213
- const lastReplayed = backlog.length ? backlog[backlog.length - 1] : null;
9214
- if (lastReplayed && isTerminal(lastReplayed.event)) {
9215
- onClose();
9216
- return () => {
9217
- };
9218
- }
9219
- const state = live.get(chatId);
9220
- if (state && state.status === "running") {
9221
- const fn = (rec) => {
9222
- if (rec === null) {
9223
- state.subscribers.delete(fn);
9224
- onClose();
9225
- return;
9226
- }
9227
- if (rec.seq > since) onRecord(rec);
9228
- };
9229
- state.subscribers.add(fn);
9230
- return () => {
9231
- state.subscribers.delete(fn);
9232
- };
9233
- }
9234
- if (state && state.status === "ended" && state.terminal) {
9235
- if (state.terminal.seq > since && !lastReplayed) onRecord(state.terminal);
9236
- onClose();
9237
- return () => {
9238
- };
9239
- }
9240
- if (lastReplayed) {
9241
- onRecord({
9242
- seq: lastReplayed.seq + 1,
9243
- turn: lastReplayed.turn,
9244
- ts: Date.now(),
9245
- event: {
9246
- type: "error",
9247
- error: "stream interrupted (server restarted mid-reply) \u2014 resend your message",
9248
- chatId
9249
- }
9250
- });
9251
- }
9252
- onClose();
9253
- return () => {
9254
- };
9255
- }
9256
- function getLastSeq(dir, chatId) {
9257
- const state = live.get(chatId);
9258
- if (state) return state.seq;
9259
- return lastPersistedSeq(dir, chatId);
9260
- }
9261
-
9262
- // src/scripts/brainServe.ts
9263
- var DEFAULT_PORT = 8080;
9264
- function getApiKey() {
9265
- const key = (process.env.BRAIN_API_KEY ?? "").trim();
9266
- if (!key) {
9267
- throw new Error(
9268
- "BRAIN_API_KEY env var is required \u2014 set it on the Fly machine before boot."
9269
- );
9270
- }
9271
- return key;
9272
- }
9273
- function authOk(req, expected) {
9274
- const xApiKey = req.headers["x-api-key"]?.trim();
9275
- if (xApiKey && xApiKey === expected) return true;
9276
- const auth = req.headers["authorization"]?.trim();
9277
- if (auth && auth.toLowerCase().startsWith("bearer ")) {
9278
- return auth.slice(7).trim() === expected;
9279
- }
9280
- return false;
9281
- }
9282
- function readJsonBody(req) {
9283
- return new Promise((resolve4, reject) => {
9284
- const chunks = [];
9285
- req.on("data", (c) => chunks.push(c));
9286
- req.on("end", () => {
9287
- const raw = Buffer.concat(chunks).toString("utf-8");
9288
- if (!raw.trim()) {
9289
- resolve4({});
9290
- return;
9291
- }
9292
- try {
9293
- resolve4(JSON.parse(raw));
9294
- } catch (err) {
9295
- reject(err instanceof Error ? err : new Error(String(err)));
9296
- }
9297
- });
9298
- req.on("error", reject);
9299
- });
9300
- }
9301
- function sendJson(res, status, body) {
9302
- res.writeHead(status, { "content-type": "application/json" });
9303
- res.end(JSON.stringify(body));
9304
- }
9305
- function writeSseHeaders(res) {
9306
- res.writeHead(200, {
9307
- "content-type": "text/event-stream; charset=utf-8",
9308
- "cache-control": "no-cache, no-transform",
9309
- connection: "keep-alive",
9310
- "x-accel-buffering": "no"
9311
- });
9336
+ return subject ? `revert: "${subject}" (${full.slice(0, 7)})` : `revert: ${full.slice(0, 7)}`;
9337
+ }
9338
+ const shas = resolved.map((r) => r.full.slice(0, 7)).join(", ");
9339
+ return `revert: ${resolved.length} commit(s) (${shas})`;
9312
9340
  }
9313
- function emitSse(res, event) {
9314
- res.write(`data: ${JSON.stringify(event)}
9315
-
9316
- `);
9341
+ function buildPrSummary(resolved) {
9342
+ return resolved.map((r) => `- Reverted \`${r.full.slice(0, 7)}\`${r.subject ? ` \u2014 ${r.subject}` : ""}`).join("\n");
9317
9343
  }
9318
- function translateChatEvent(event, chatId) {
9319
- switch (event.event) {
9320
- case "chat.message": {
9321
- const content = String(event.payload.content ?? "");
9322
- if (content.length === 0) return null;
9323
- return { type: "text", text: content, chatId };
9324
- }
9325
- case "chat.tool": {
9326
- if (event.payload.phase !== "use") return null;
9327
- return {
9328
- type: "tool_use",
9329
- name: typeof event.payload.name === "string" ? event.payload.name : "tool",
9330
- input: event.payload.input ?? {},
9331
- chatId
9332
- };
9333
- }
9334
- case "chat.done":
9335
- return { type: "done", chatId };
9336
- case "chat.error":
9337
- return {
9338
- type: "error",
9339
- error: typeof event.payload.error === "string" ? event.payload.error : "agent error",
9340
- chatId
9341
- };
9342
- default:
9343
- return null;
9344
+ function git3(args, cwd) {
9345
+ return execFileSync23("git", args, {
9346
+ encoding: "utf-8",
9347
+ timeout: 3e4,
9348
+ cwd,
9349
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
9350
+ stdio: ["pipe", "pipe", "pipe"]
9351
+ }).trim();
9352
+ }
9353
+ function isAncestorOfHead(sha, cwd) {
9354
+ try {
9355
+ execFileSync23("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
9356
+ cwd,
9357
+ env: { ...process.env, HUSKY: "0", SKIP_HOOKS: "1" },
9358
+ stdio: ["ignore", "ignore", "ignore"]
9359
+ });
9360
+ return true;
9361
+ } catch {
9362
+ return false;
9344
9363
  }
9345
9364
  }
9346
- var BrokerSink = class {
9347
- constructor(emitToLog, chatId) {
9348
- this.emitToLog = emitToLog;
9349
- this.chatId = chatId;
9365
+ function tryPostPr4(prNumber, body, cwd) {
9366
+ try {
9367
+ postPrReviewComment(prNumber, body, cwd);
9368
+ } catch (err) {
9369
+ const msg = err instanceof Error ? err.message : String(err);
9370
+ process.stderr.write(`[kody revertFlow] PR comment on #${prNumber} failed: ${msg}
9371
+ `);
9350
9372
  }
9351
- emitToLog;
9352
- chatId;
9353
- async emit(event) {
9354
- const be = translateChatEvent(event, this.chatId);
9355
- if (be) this.emitToLog(be);
9373
+ }
9374
+
9375
+ // src/scripts/reviewFlow.ts
9376
+ init_issue();
9377
+ var reviewFlow = async (ctx) => {
9378
+ const prNumber = ctx.args.pr;
9379
+ const pr = getPr(prNumber, ctx.cwd);
9380
+ if (pr.state !== "OPEN") {
9381
+ ctx.output.exitCode = 1;
9382
+ ctx.output.reason = `PR #${prNumber} is not OPEN (state: ${pr.state})`;
9383
+ ctx.skipAgent = true;
9384
+ return;
9356
9385
  }
9386
+ ctx.data.pr = pr;
9387
+ ctx.data.commentTargetType = "pr";
9388
+ ctx.data.commentTargetNumber = prNumber;
9389
+ checkoutPrBranch(prNumber, ctx.cwd);
9390
+ ctx.data.branch = getCurrentBranch(ctx.cwd);
9391
+ ctx.data.prDiff = getPrDiff(prNumber, ctx.cwd);
9392
+ const runUrl = getRunUrl();
9393
+ const runSuffix = runUrl ? `, run ${runUrl}` : "";
9394
+ tryPostPr5(prNumber, `\u{1F440} kody review started on PR #${prNumber}${runSuffix}`, ctx.cwd);
9357
9395
  };
9358
- var chatQueues = /* @__PURE__ */ new Map();
9359
- function enqueue(chatId, fn) {
9360
- const prev = chatQueues.get(chatId) ?? Promise.resolve();
9361
- const next = prev.catch(() => {
9362
- }).then(fn);
9363
- chatQueues.set(
9364
- chatId,
9365
- next.finally(() => {
9366
- if (chatQueues.get(chatId) === next) chatQueues.delete(chatId);
9367
- })
9368
- );
9369
- return next;
9396
+ function tryPostPr5(prNumber, body, cwd) {
9397
+ try {
9398
+ postPrReviewComment(prNumber, body, cwd);
9399
+ } catch {
9400
+ }
9370
9401
  }
9371
- function streamToRes(res, dir, chatId, since) {
9372
- writeSseHeaders(res);
9373
- emitSse(res, { type: "chat", chatId });
9374
- let maxSent = since;
9375
- const unsubscribe = subscribe(
9376
- dir,
9377
- chatId,
9378
- since,
9379
- (rec) => {
9380
- if (rec.seq <= maxSent) return;
9381
- maxSent = rec.seq;
9382
- if (res.writableEnded) return;
9383
- res.write(`data: ${JSON.stringify({ ...rec.event, seq: rec.seq })}
9384
9402
 
9403
+ // src/scripts/runFlow.ts
9404
+ init_issue();
9405
+ var runFlow = async (ctx) => {
9406
+ const issueNumber = ctx.args.issue;
9407
+ const issue = getIssue(issueNumber, ctx.cwd);
9408
+ ctx.data.issue = issue;
9409
+ ctx.data.commentTargetType = "issue";
9410
+ ctx.data.commentTargetNumber = issueNumber;
9411
+ const argBase = resolveBaseOverride(ctx.args.base);
9412
+ const baseRaw = ctx.args.base;
9413
+ if (baseRaw && !argBase) {
9414
+ process.stderr.write(`[kody runFlow] ignoring --base "${baseRaw}" (must match kody-task or goal-branch pattern)
9385
9415
  `);
9386
- },
9387
- () => {
9388
- if (!res.writableEnded) {
9389
- try {
9390
- res.end();
9391
- } catch {
9392
- }
9393
- }
9394
- }
9395
- );
9396
- res.on("close", unsubscribe);
9397
- }
9398
- async function handleChatTurn(req, res, chatId, opts) {
9399
- let body;
9416
+ }
9417
+ const base = argBase;
9418
+ if (base) {
9419
+ ctx.data.baseBranch = base;
9420
+ process.stderr.write(`[kody runFlow] resolved base branch: ${base} (from --base)
9421
+ `);
9422
+ }
9423
+ const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd, base ?? void 0);
9424
+ ctx.data.branch = branchInfo.branch;
9425
+ const runUrl = getRunUrl();
9426
+ const startMsg = runUrl ? `\u2699\uFE0F kody started \u2014 branch \`${ctx.data.branch}\`, run ${runUrl}` : `\u2699\uFE0F kody started \u2014 branch \`${ctx.data.branch}\``;
9427
+ tryPost(issueNumber, startMsg, ctx.cwd);
9428
+ };
9429
+ function tryPost(issueNumber, body, cwd) {
9400
9430
  try {
9401
- body = await readJsonBody(req);
9431
+ postIssueComment(issueNumber, body, cwd);
9402
9432
  } catch {
9403
- sendJson(res, 400, { error: "invalid JSON body" });
9404
- return;
9405
- }
9406
- const message = typeof body === "object" && body !== null && "message" in body ? body.message : void 0;
9407
- if (typeof message !== "string" || !message.trim()) {
9408
- sendJson(res, 400, { error: "message required" });
9409
- return;
9410
9433
  }
9411
- const sessionFile = sessionFilePath(opts.cwd, chatId);
9412
- fs32.mkdirSync(path30.dirname(sessionFile), { recursive: true });
9413
- appendTurn(sessionFile, {
9414
- role: "user",
9415
- content: message,
9416
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9417
- });
9418
- const sinceFloor = getLastSeq(opts.cwd, chatId);
9419
- const emitToLog = beginTurn(opts.cwd, chatId);
9420
- const sink = new BrokerSink(emitToLog, chatId);
9421
- void enqueue(
9422
- chatId,
9423
- () => opts.runTurn({
9424
- sessionId: chatId,
9425
- sessionFile,
9426
- cwd: opts.cwd,
9427
- model: opts.model,
9428
- litellmUrl: opts.litellmUrl,
9429
- sink
9430
- }).catch((err) => {
9431
- const errMsg2 = err instanceof Error ? err.message : String(err);
9432
- process.stderr.write(`[brain-serve] chat turn failed: ${errMsg2}
9433
- `);
9434
- endTurnIfUnterminated(opts.cwd, chatId, errMsg2);
9435
- }).finally(() => {
9436
- endTurnIfUnterminated(
9437
- opts.cwd,
9438
- chatId,
9439
- "Brain turn ended without a reply (the machine may have restarted mid-turn) \u2014 please resend your message"
9440
- );
9441
- })
9442
- );
9443
- streamToRes(res, opts.cwd, chatId, sinceFloor);
9444
9434
  }
9445
- function buildServer(opts) {
9446
- const runTurn = opts.runTurn ?? runChatTurn;
9447
- return createServer(async (req, res) => {
9448
- if (!req.method || !req.url) {
9449
- sendJson(res, 400, { error: "bad request" });
9450
- return;
9451
- }
9452
- const url = new URL(req.url, `http://localhost`);
9453
- if (req.method === "GET" && url.pathname === "/healthz") {
9454
- sendJson(res, 200, { ok: true });
9455
- return;
9456
- }
9457
- if (!authOk(req, opts.apiKey)) {
9458
- sendJson(res, 401, { error: "unauthorized" });
9459
- return;
9460
- }
9461
- const m = url.pathname.match(/^\/chats\/([^/]+)\/messages\/?$/);
9462
- if (req.method === "POST" && m) {
9463
- const chatId = decodeURIComponent(m[1] ?? "");
9464
- if (!chatId) {
9465
- sendJson(res, 400, { error: "chatId required" });
9466
- return;
9467
- }
9468
- await handleChatTurn(req, res, chatId, {
9469
- cwd: opts.cwd,
9470
- model: opts.model,
9471
- litellmUrl: opts.litellmUrl,
9472
- runTurn
9473
- });
9474
- return;
9475
- }
9476
- const sm = url.pathname.match(/^\/chats\/([^/]+)\/stream\/?$/);
9477
- if (req.method === "GET" && sm) {
9478
- const chatId = decodeURIComponent(sm[1] ?? "");
9479
- if (!chatId) {
9480
- sendJson(res, 400, { error: "chatId required" });
9481
- return;
9482
- }
9483
- const sinceRaw = url.searchParams.get("since");
9484
- const since = Number.isFinite(Number(sinceRaw)) ? Number(sinceRaw) : 0;
9485
- streamToRes(res, opts.cwd, chatId, since);
9486
- return;
9487
- }
9488
- sendJson(res, 404, { error: "not found" });
9489
- });
9435
+ function resolveBaseOverride(value) {
9436
+ if (!value) return null;
9437
+ if (value.length > 200) return null;
9438
+ if (value.includes("..")) return null;
9439
+ if (!/^[a-z0-9][a-z0-9/._-]*$/.test(value)) return null;
9440
+ return value;
9490
9441
  }
9491
- var brainServe = async (ctx) => {
9442
+
9443
+ // src/scripts/runTickScript.ts
9444
+ import { spawnSync } from "child_process";
9445
+ import * as fs32 from "fs";
9446
+ import * as path30 from "path";
9447
+ var runTickScript = async (ctx, _profile, args) => {
9492
9448
  ctx.skipAgent = true;
9493
- const apiKey = getApiKey();
9494
- const port = Number(process.env.PORT ?? DEFAULT_PORT);
9495
- const model = parseProviderModel(ctx.config.agent.model);
9496
- const usesProxy = needsLitellmProxy(model);
9497
- let handle = null;
9498
- if (usesProxy) {
9499
- process.stdout.write(
9500
- `[brain-serve] starting LiteLLM proxy for ${model.provider}/${model.model}...
9501
- `
9502
- );
9503
- handle = await startLitellmIfNeeded(model, ctx.cwd);
9504
- process.stdout.write(
9505
- `[brain-serve] LiteLLM ready at ${handle?.url ?? LITELLM_DEFAULT_URL}
9506
- `
9507
- );
9449
+ const jobsDir = String(args?.jobsDir ?? ".kody/jobs");
9450
+ const slugArg = String(args?.slugArg ?? "job");
9451
+ const fenceLabel = String(args?.fenceLabel ?? "kody-job-next-state");
9452
+ const slug = String(ctx.args[slugArg] ?? "").trim();
9453
+ if (!slug) {
9454
+ ctx.output.exitCode = 99;
9455
+ ctx.output.reason = `runTickScript: ctx.args.${slugArg} must be a non-empty slug`;
9456
+ return;
9508
9457
  }
9509
- const litellmUrl = usesProxy ? handle?.url ?? LITELLM_DEFAULT_URL : null;
9510
- const server = buildServer({
9511
- apiKey,
9458
+ const jobPath = path30.join(ctx.cwd, jobsDir, `${slug}.md`);
9459
+ if (!fs32.existsSync(jobPath)) {
9460
+ ctx.output.exitCode = 99;
9461
+ ctx.output.reason = `runTickScript: job file not found: ${jobPath}`;
9462
+ return;
9463
+ }
9464
+ const raw = fs32.readFileSync(jobPath, "utf-8");
9465
+ const { frontmatter } = splitFrontmatter(raw);
9466
+ const tickScript = frontmatter.tickScript;
9467
+ if (!tickScript) {
9468
+ ctx.output.exitCode = 99;
9469
+ ctx.output.reason = `runTickScript: job ${slug} has no \`tickScript:\` frontmatter \u2014 route via job-tick instead`;
9470
+ return;
9471
+ }
9472
+ const scriptPath = path30.isAbsolute(tickScript) ? tickScript : path30.join(ctx.cwd, tickScript);
9473
+ if (!fs32.existsSync(scriptPath)) {
9474
+ ctx.output.exitCode = 99;
9475
+ ctx.output.reason = `runTickScript: tickScript not found: ${scriptPath}`;
9476
+ return;
9477
+ }
9478
+ const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, jobsDir });
9479
+ let loaded;
9480
+ try {
9481
+ loaded = await backend.load(slug);
9482
+ } catch (err) {
9483
+ ctx.output.exitCode = 99;
9484
+ ctx.output.reason = `runTickScript: state load failed: ${err instanceof Error ? err.message : String(err)}`;
9485
+ return;
9486
+ }
9487
+ ctx.data.jobSlug = slug;
9488
+ ctx.data.jobState = loaded;
9489
+ const childEnv = buildChildEnv(process.env, Boolean(ctx.args.force));
9490
+ const result = spawnSync("bash", [scriptPath], {
9512
9491
  cwd: ctx.cwd,
9513
- model,
9514
- litellmUrl
9515
- });
9516
- await new Promise((resolve4) => {
9517
- server.listen(port, "0.0.0.0", () => {
9518
- process.stdout.write(
9519
- `[brain-serve] listening on 0.0.0.0:${port} (cwd=${ctx.cwd})
9520
- `
9521
- );
9522
- resolve4();
9523
- });
9492
+ env: childEnv,
9493
+ stdio: ["ignore", "pipe", "pipe"],
9494
+ encoding: "utf-8",
9495
+ timeout: 5 * 60 * 1e3,
9496
+ // Default maxBuffer is 1MB — a chatty `gh pr list --json …` over a
9497
+ // busy repo (or an accidental `set -x`) can blow that and silently
9498
+ // truncate stdout, which is the exact "silent state drop" failure
9499
+ // mode this whole executable was written to prevent. 16MB is well
9500
+ // above any realistic tick output.
9501
+ maxBuffer: 16 * 1024 * 1024
9524
9502
  });
9525
- const shutdown = (signal) => {
9526
- process.stdout.write(`[brain-serve] ${signal} \u2014 shutting down
9527
- `);
9528
- server.close(() => {
9529
- if (handle) {
9530
- try {
9531
- handle.kill();
9532
- } catch {
9533
- }
9534
- }
9535
- process.exit(0);
9536
- });
9503
+ if (result.stdout) process.stdout.write(result.stdout);
9504
+ if (result.stderr) process.stderr.write(result.stderr);
9505
+ if (result.error) {
9506
+ ctx.output.exitCode = 99;
9507
+ ctx.output.reason = `runTickScript: spawn error: ${result.error.message}`;
9508
+ return;
9509
+ }
9510
+ if (result.signal) {
9511
+ ctx.output.exitCode = 124;
9512
+ ctx.output.reason = `runTickScript: ${tickScript} killed by ${result.signal} (likely 5min timeout)`;
9513
+ return;
9514
+ }
9515
+ if (result.status !== 0) {
9516
+ ctx.output.exitCode = result.status ?? 99;
9517
+ ctx.output.reason = `runTickScript: ${tickScript} exited ${result.status}`;
9518
+ return;
9519
+ }
9520
+ const prevRev = loaded.state.rev ?? 0;
9521
+ const parsed = extractNextStateFromText(result.stdout ?? "", fenceLabel, prevRev);
9522
+ if (parsed.error) {
9523
+ ctx.data.nextStateParseError = parsed.error;
9524
+ ctx.output.exitCode = 1;
9525
+ ctx.output.reason = `runTickScript: ${parsed.error}`;
9526
+ return;
9527
+ }
9528
+ ctx.data.nextJobState = parsed.envelope;
9529
+ };
9530
+ function buildChildEnv(parent, force) {
9531
+ const allow = /* @__PURE__ */ new Set([
9532
+ "PATH",
9533
+ "HOME",
9534
+ "USER",
9535
+ "LOGNAME",
9536
+ "SHELL",
9537
+ "TMPDIR",
9538
+ "LANG",
9539
+ "LC_ALL",
9540
+ "TERM",
9541
+ // GitHub auth — `gh` reads these.
9542
+ "GH_TOKEN",
9543
+ "GH_PAT",
9544
+ "GITHUB_TOKEN",
9545
+ // CI metadata commonly read by tick scripts (`gh repo view`,
9546
+ // workflow run links, etc.). All public values from GitHub Actions.
9547
+ "GITHUB_ACTIONS",
9548
+ "GITHUB_ACTOR",
9549
+ "GITHUB_REPOSITORY",
9550
+ "GITHUB_REPOSITORY_OWNER",
9551
+ "GITHUB_REF",
9552
+ "GITHUB_SHA",
9553
+ "GITHUB_RUN_ID",
9554
+ "GITHUB_RUN_NUMBER",
9555
+ "GITHUB_WORKFLOW",
9556
+ "GITHUB_JOB",
9557
+ "GITHUB_SERVER_URL",
9558
+ "GITHUB_API_URL",
9559
+ "GITHUB_EVENT_NAME",
9560
+ "RUNNER_OS",
9561
+ "RUNNER_ARCH"
9562
+ ]);
9563
+ const out = {};
9564
+ for (const [key, value] of Object.entries(parent)) {
9565
+ if (value === void 0) continue;
9566
+ if (allow.has(key) || key.startsWith("KODY_PUBLIC_")) {
9567
+ out[key] = value;
9568
+ }
9569
+ }
9570
+ if (force) out.KODY_FORCE = "1";
9571
+ return out;
9572
+ }
9573
+
9574
+ // src/scripts/saveGoalState.ts
9575
+ var saveGoalState = async (ctx) => {
9576
+ const goal = ctx.data.goal;
9577
+ if (!goal) {
9578
+ ctx.skipAgent = true;
9579
+ return;
9580
+ }
9581
+ const updated = {
9582
+ ...goal.raw ?? { state: goal.state, extra: {} },
9583
+ state: goal.state,
9584
+ lastDispatchedIssue: goal.lastDispatchedIssue,
9585
+ updatedAt: nowIso()
9537
9586
  };
9538
- process.once("SIGINT", () => shutdown("SIGINT"));
9539
- process.once("SIGTERM", () => shutdown("SIGTERM"));
9540
- await new Promise(() => {
9541
- });
9587
+ writeGoalState(ctx.cwd, goal.id, updated);
9588
+ ctx.skipAgent = true;
9589
+ };
9590
+
9591
+ // src/scripts/saveTaskState.ts
9592
+ var saveTaskState = async (ctx, profile) => {
9593
+ const target = ctx.data.commentTargetType;
9594
+ const number = ctx.data.commentTargetNumber;
9595
+ const state = ctx.data.taskState;
9596
+ if (!target || !number || !state) return;
9597
+ const executable = profile.name;
9598
+ const action = ctx.data.action ?? synthesizeAction(ctx);
9599
+ if (ctx.output.prUrl && !state.core.prUrl) state.core.prUrl = ctx.output.prUrl;
9600
+ if (typeof ctx.data.runUrl === "string") state.core.runUrl = ctx.data.runUrl;
9601
+ const next = reduce(state, executable, action, profile.phase);
9602
+ if (ctx.output.prUrl) next.core.prUrl = ctx.output.prUrl;
9603
+ if (typeof ctx.data.runUrl === "string") next.core.runUrl = ctx.data.runUrl;
9604
+ writeTaskState(target, number, next, ctx.cwd);
9605
+ ctx.data.taskStateRendered = renderStateComment(next);
9542
9606
  };
9607
+ function synthesizeAction(ctx) {
9608
+ const ok = ctx.output.exitCode === 0;
9609
+ return {
9610
+ type: ok ? "RUN_COMPLETED" : "RUN_FAILED",
9611
+ payload: {
9612
+ exitCode: ctx.output.exitCode,
9613
+ reason: ctx.output.reason,
9614
+ prUrl: ctx.output.prUrl
9615
+ },
9616
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
9617
+ };
9618
+ }
9543
9619
 
9544
9620
  // src/scripts/serveFlow.ts
9545
9621
  import { spawn as spawn3 } from "child_process";
@@ -9643,52 +9719,6 @@ var serveFlow = async (ctx) => {
9643
9719
  });
9644
9720
  };
9645
9721
 
9646
- // src/scripts/saveGoalState.ts
9647
- var saveGoalState = async (ctx) => {
9648
- const goal = ctx.data.goal;
9649
- if (!goal) {
9650
- ctx.skipAgent = true;
9651
- return;
9652
- }
9653
- const updated = {
9654
- ...goal.raw ?? { state: goal.state, extra: {} },
9655
- state: goal.state,
9656
- lastDispatchedIssue: goal.lastDispatchedIssue,
9657
- updatedAt: nowIso()
9658
- };
9659
- writeGoalState(ctx.cwd, goal.id, updated);
9660
- ctx.skipAgent = true;
9661
- };
9662
-
9663
- // src/scripts/saveTaskState.ts
9664
- var saveTaskState = async (ctx, profile) => {
9665
- const target = ctx.data.commentTargetType;
9666
- const number = ctx.data.commentTargetNumber;
9667
- const state = ctx.data.taskState;
9668
- if (!target || !number || !state) return;
9669
- const executable = profile.name;
9670
- const action = ctx.data.action ?? synthesizeAction(ctx);
9671
- if (ctx.output.prUrl && !state.core.prUrl) state.core.prUrl = ctx.output.prUrl;
9672
- if (typeof ctx.data.runUrl === "string") state.core.runUrl = ctx.data.runUrl;
9673
- const next = reduce(state, executable, action, profile.phase);
9674
- if (ctx.output.prUrl) next.core.prUrl = ctx.output.prUrl;
9675
- if (typeof ctx.data.runUrl === "string") next.core.runUrl = ctx.data.runUrl;
9676
- writeTaskState(target, number, next, ctx.cwd);
9677
- ctx.data.taskStateRendered = renderStateComment(next);
9678
- };
9679
- function synthesizeAction(ctx) {
9680
- const ok = ctx.output.exitCode === 0;
9681
- return {
9682
- type: ok ? "RUN_COMPLETED" : "RUN_FAILED",
9683
- payload: {
9684
- exitCode: ctx.output.exitCode,
9685
- reason: ctx.output.reason,
9686
- prUrl: ctx.output.prUrl
9687
- },
9688
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
9689
- };
9690
- }
9691
-
9692
9722
  // src/scripts/setCommentTarget.ts
9693
9723
  var setCommentTarget = async (ctx, _profile, args) => {
9694
9724
  const type = args?.type ?? "issue";
@@ -10531,6 +10561,7 @@ var preflightScripts = {
10531
10561
  handleAbandonedGoal,
10532
10562
  deriveGoalPhase,
10533
10563
  dispatchNextTask,
10564
+ parkGoalForMerge,
10534
10565
  finalizeGoal,
10535
10566
  saveGoalState
10536
10567
  };