@kody-ade/kody-engine 0.3.57 → 0.3.60

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
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@kody-ade/kody-engine",
6
- version: "0.3.57",
6
+ version: "0.3.60",
7
7
  description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
8
8
  license: "MIT",
9
9
  type: "module",
@@ -28,6 +28,7 @@ var package_default = {
28
28
  prepublishOnly: "pnpm build"
29
29
  },
30
30
  dependencies: {
31
+ "@actions/cache": "^6.0.0",
31
32
  "@anthropic-ai/claude-agent-sdk": "0.2.119"
32
33
  },
33
34
  devDependencies: {
@@ -51,8 +52,8 @@ var package_default = {
51
52
 
52
53
  // src/chat-cli.ts
53
54
  import { execFileSync as execFileSync26 } from "child_process";
54
- import * as fs26 from "fs";
55
- import * as path23 from "path";
55
+ import * as fs27 from "fs";
56
+ import * as path24 from "path";
56
57
 
57
58
  // src/chat/events.ts
58
59
  import * as fs from "fs";
@@ -186,9 +187,23 @@ function loadConfig(projectDir = process.cwd()) {
186
187
  defaultPrExecutable: typeof raw.defaultPrExecutable === "string" && raw.defaultPrExecutable.length > 0 ? raw.defaultPrExecutable : "fix",
187
188
  aliases: mergeAliases(raw.aliases),
188
189
  classify: parseClassifyConfig(raw.classify),
189
- release: parseReleaseConfig(raw.release)
190
+ release: parseReleaseConfig(raw.release),
191
+ missions: parseMissionsConfig(raw.missions)
190
192
  };
191
193
  }
194
+ function parseMissionsConfig(raw) {
195
+ if (!raw || typeof raw !== "object") return void 0;
196
+ const r = raw;
197
+ const out = {};
198
+ if (r.stateBackend === "contents-api" || r.stateBackend === "local-file") {
199
+ out.stateBackend = r.stateBackend;
200
+ } else if (typeof r.stateBackend === "string") {
201
+ throw new Error(
202
+ `kody.config.json: missions.stateBackend must be "contents-api" or "local-file", got "${r.stateBackend}"`
203
+ );
204
+ }
205
+ return Object.keys(out).length > 0 ? out : void 0;
206
+ }
192
207
  var BUILTIN_ALIASES = {
193
208
  build: "run",
194
209
  orchestrate: "bug",
@@ -606,8 +621,8 @@ async function emit(sink, type, sessionId, suffix, payload) {
606
621
 
607
622
  // src/kody-cli.ts
608
623
  import { execFileSync as execFileSync25 } from "child_process";
609
- import * as fs25 from "fs";
610
- import * as path22 from "path";
624
+ import * as fs26 from "fs";
625
+ import * as path23 from "path";
611
626
 
612
627
  // src/dispatch.ts
613
628
  import * as fs6 from "fs";
@@ -959,9 +974,9 @@ function coerceBare(spec, value) {
959
974
  }
960
975
 
961
976
  // src/executor.ts
962
- import { spawnSync } from "child_process";
963
- import * as fs24 from "fs";
964
- import * as path21 from "path";
977
+ import { spawn as spawn3 } from "child_process";
978
+ import * as fs25 from "fs";
979
+ import * as path22 from "path";
965
980
 
966
981
  // src/litellm.ts
967
982
  import { execFileSync, spawn } from "child_process";
@@ -2851,62 +2866,8 @@ function failedAction(reason) {
2851
2866
  }
2852
2867
 
2853
2868
  // src/scripts/dispatchMissionFileTicks.ts
2854
- import * as fs16 from "fs";
2855
- import * as path15 from "path";
2856
- var dispatchMissionFileTicks = async (ctx, _profile, args) => {
2857
- ctx.skipAgent = true;
2858
- const targetExecutable = String(args?.targetExecutable ?? "");
2859
- if (!targetExecutable) {
2860
- throw new Error("dispatchMissionFileTicks: `with.targetExecutable` is required");
2861
- }
2862
- const missionsDir = String(args?.missionsDir ?? ".kody/missions");
2863
- const slugArg = String(args?.slugArg ?? "mission");
2864
- const slugs = listMissionSlugs(path15.join(ctx.cwd, missionsDir));
2865
- ctx.data.missionSlugCount = slugs.length;
2866
- if (slugs.length === 0) {
2867
- process.stdout.write(`[missions] no mission files in ${missionsDir}
2868
- `);
2869
- return;
2870
- }
2871
- process.stdout.write(`[missions] ticking ${slugs.length} mission(s) via ${targetExecutable}
2872
- `);
2873
- const results = [];
2874
- for (const slug of slugs) {
2875
- process.stdout.write(`[missions] \u2192 tick ${slug}
2876
- `);
2877
- try {
2878
- const out = await runExecutable(targetExecutable, {
2879
- cliArgs: { [slugArg]: slug },
2880
- cwd: ctx.cwd,
2881
- config: ctx.config,
2882
- verbose: ctx.verbose,
2883
- quiet: ctx.quiet
2884
- });
2885
- results.push({ slug, exitCode: out.exitCode, reason: out.reason });
2886
- if (out.exitCode !== 0) {
2887
- process.stderr.write(`[missions] tick ${slug} failed (exit ${out.exitCode}): ${out.reason ?? ""}
2888
- `);
2889
- }
2890
- } catch (err) {
2891
- const msg = err instanceof Error ? err.message : String(err);
2892
- process.stderr.write(`[missions] tick ${slug} crashed: ${msg}
2893
- `);
2894
- results.push({ slug, exitCode: 99, reason: msg });
2895
- }
2896
- }
2897
- ctx.data.missionTickResults = results;
2898
- ctx.output.exitCode = 0;
2899
- };
2900
- function listMissionSlugs(absDir) {
2901
- if (!fs16.existsSync(absDir)) return [];
2902
- let entries;
2903
- try {
2904
- entries = fs16.readdirSync(absDir, { withFileTypes: true });
2905
- } catch {
2906
- return [];
2907
- }
2908
- return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.replace(/\.md$/, "")).filter((slug) => slug.length > 0 && !slug.startsWith("_") && !slug.startsWith(".")).sort();
2909
- }
2869
+ import * as fs17 from "fs";
2870
+ import * as path16 from "path";
2910
2871
 
2911
2872
  // src/issue.ts
2912
2873
  import { execFileSync as execFileSync9 } from "child_process";
@@ -3049,6 +3010,411 @@ function postPrReviewComment(prNumber, body, cwd) {
3049
3010
  }
3050
3011
  }
3051
3012
 
3013
+ // src/scripts/issueStateComment.ts
3014
+ function isStateEnvelope(x) {
3015
+ if (x === null || typeof x !== "object") return false;
3016
+ const o = x;
3017
+ return o.version === 1 && typeof o.rev === "number" && Number.isInteger(o.rev) && o.rev >= 0 && typeof o.cursor === "string" && typeof o.done === "boolean" && o.data !== null && typeof o.data === "object" && !Array.isArray(o.data);
3018
+ }
3019
+ function initialStateEnvelope(cursor = "seed") {
3020
+ return { version: 1, rev: 0, cursor, data: {}, done: false };
3021
+ }
3022
+ function formatStateCommentBody(marker, state) {
3023
+ return `<!-- ${marker} -->
3024
+
3025
+ \`\`\`json
3026
+ ${JSON.stringify(state, null, 2)}
3027
+ \`\`\`
3028
+ `;
3029
+ }
3030
+ function parseStateCommentBody(marker, body) {
3031
+ const markerLine = `<!-- ${marker} -->`;
3032
+ if (!body.trimStart().startsWith(markerLine)) return null;
3033
+ const fenceOpen = body.indexOf("```json");
3034
+ if (fenceOpen === -1) return null;
3035
+ const after = body.slice(fenceOpen + "```json".length);
3036
+ const fenceClose = after.indexOf("```");
3037
+ if (fenceClose === -1) return null;
3038
+ const jsonText = after.slice(0, fenceClose).trim();
3039
+ let parsed;
3040
+ try {
3041
+ parsed = JSON.parse(jsonText);
3042
+ } catch {
3043
+ return null;
3044
+ }
3045
+ return isStateEnvelope(parsed) ? parsed : null;
3046
+ }
3047
+ function listIssueComments(owner, repo, issueNumber, cwd) {
3048
+ const raw = gh2(["api", "--paginate", `repos/${owner}/${repo}/issues/${issueNumber}/comments`], { cwd });
3049
+ let parsed;
3050
+ try {
3051
+ parsed = JSON.parse(raw);
3052
+ } catch {
3053
+ return [];
3054
+ }
3055
+ if (!Array.isArray(parsed)) return [];
3056
+ return parsed.filter((c) => typeof c.id === "number" && typeof c.node_id === "string" && typeof c.body === "string").map((c) => ({ id: c.id, node_id: c.node_id, body: c.body }));
3057
+ }
3058
+ function findStateComment2(owner, repo, issueNumber, marker, cwd) {
3059
+ const comments = listIssueComments(owner, repo, issueNumber, cwd);
3060
+ for (const c of comments) {
3061
+ const state = parseStateCommentBody(marker, c.body);
3062
+ if (!state) continue;
3063
+ return { commentId: c.id, commentNodeId: c.node_id, state };
3064
+ }
3065
+ return null;
3066
+ }
3067
+ function createStateComment(owner, repo, issueNumber, marker, state, cwd) {
3068
+ const body = formatStateCommentBody(marker, state);
3069
+ const raw = gh2(["api", "--method", "POST", `repos/${owner}/${repo}/issues/${issueNumber}/comments`, "--input", "-"], {
3070
+ cwd,
3071
+ input: JSON.stringify({ body })
3072
+ });
3073
+ const parsed = JSON.parse(raw);
3074
+ try {
3075
+ minimizeComment(parsed.node_id, cwd);
3076
+ } catch {
3077
+ }
3078
+ return { commentId: parsed.id, commentNodeId: parsed.node_id, state };
3079
+ }
3080
+ function updateStateComment(owner, repo, commentId, commentNodeId, marker, state, cwd) {
3081
+ const body = formatStateCommentBody(marker, state);
3082
+ gh2(["api", "--method", "PATCH", `repos/${owner}/${repo}/issues/comments/${commentId}`, "--input", "-"], {
3083
+ cwd,
3084
+ input: JSON.stringify({ body })
3085
+ });
3086
+ try {
3087
+ minimizeComment(commentNodeId, cwd);
3088
+ } catch {
3089
+ }
3090
+ }
3091
+ function minimizeComment(nodeId, cwd) {
3092
+ const mutation = "mutation($id: ID!) { minimizeComment(input: { classifier: OUTDATED, subjectId: $id }) { minimizedComment { isMinimized } } }";
3093
+ gh2(["api", "graphql", "-f", `query=${mutation}`, "-f", `id=${nodeId}`], { cwd });
3094
+ }
3095
+
3096
+ // src/scripts/missionState/backend.ts
3097
+ function isStateUnchanged(prev, next) {
3098
+ if (prev.cursor !== next.cursor) return false;
3099
+ if (prev.done !== next.done) return false;
3100
+ return JSON.stringify(prev.data) === JSON.stringify(next.data);
3101
+ }
3102
+ function stateFilePath(missionsDir, slug) {
3103
+ return `${missionsDir.replace(/\/+$/, "")}/${slug}.state.json`;
3104
+ }
3105
+ function slugFromStateFilePath(filePath) {
3106
+ const last = filePath.split("/").pop() ?? filePath;
3107
+ return last.replace(/\.state\.json$/i, "");
3108
+ }
3109
+
3110
+ // src/scripts/missionState/contentsApiBackend.ts
3111
+ var ContentsApiBackend = class {
3112
+ name = "contents-api";
3113
+ owner;
3114
+ repo;
3115
+ missionsDir;
3116
+ cwd;
3117
+ constructor(opts) {
3118
+ if (!opts.owner || !opts.repo) {
3119
+ throw new Error("ContentsApiBackend: owner and repo are required");
3120
+ }
3121
+ this.owner = opts.owner;
3122
+ this.repo = opts.repo;
3123
+ this.missionsDir = opts.missionsDir;
3124
+ this.cwd = opts.cwd;
3125
+ }
3126
+ load(slug) {
3127
+ const filePath = stateFilePath(this.missionsDir, slug);
3128
+ let raw = "";
3129
+ try {
3130
+ raw = gh2(["api", `/repos/${this.owner}/${this.repo}/contents/${filePath}`], { cwd: this.cwd });
3131
+ } catch (err) {
3132
+ const msg = err instanceof Error ? err.message : String(err);
3133
+ if (/HTTP 404/i.test(msg) || /Not Found/i.test(msg)) {
3134
+ return { path: filePath, handle: null, state: initialStateEnvelope("seed"), created: true };
3135
+ }
3136
+ throw err;
3137
+ }
3138
+ let parsed;
3139
+ try {
3140
+ parsed = JSON.parse(raw);
3141
+ } catch {
3142
+ throw new Error(`ContentsApiBackend: contents API for ${filePath} did not return JSON`);
3143
+ }
3144
+ if (!parsed || typeof parsed !== "object") {
3145
+ throw new Error(`ContentsApiBackend: contents API for ${filePath} returned non-object`);
3146
+ }
3147
+ const o = parsed;
3148
+ if (o.type !== "file" || o.encoding !== "base64" || typeof o.content !== "string") {
3149
+ throw new Error(`ContentsApiBackend: ${filePath} is not a base64 file`);
3150
+ }
3151
+ const decoded = Buffer.from(o.content, "base64").toString("utf-8");
3152
+ let envelope;
3153
+ try {
3154
+ envelope = JSON.parse(decoded);
3155
+ } catch {
3156
+ throw new Error(`ContentsApiBackend: ${filePath} is not valid JSON`);
3157
+ }
3158
+ if (!isStateEnvelope(envelope)) {
3159
+ throw new Error(`ContentsApiBackend: ${filePath} is not a StateEnvelope`);
3160
+ }
3161
+ return { path: filePath, handle: o.sha, state: envelope, created: false };
3162
+ }
3163
+ save(loaded, next) {
3164
+ if (!loaded.created && isStateUnchanged(loaded.state, next)) {
3165
+ return false;
3166
+ }
3167
+ const slug = slugFromStateFilePath(loaded.path);
3168
+ const body = JSON.stringify(next, null, 2) + "\n";
3169
+ const payload = {
3170
+ message: `chore(missions): update state for ${slug} (rev ${next.rev})`,
3171
+ content: Buffer.from(body, "utf-8").toString("base64")
3172
+ };
3173
+ if (typeof loaded.handle === "string") payload.sha = loaded.handle;
3174
+ gh2(["api", "--method", "PUT", `/repos/${this.owner}/${this.repo}/contents/${loaded.path}`, "--input", "-"], {
3175
+ cwd: this.cwd,
3176
+ input: JSON.stringify(payload)
3177
+ });
3178
+ return true;
3179
+ }
3180
+ };
3181
+
3182
+ // src/scripts/missionState/localFileBackend.ts
3183
+ import * as fs16 from "fs";
3184
+ import * as path15 from "path";
3185
+ var LocalFileBackend = class {
3186
+ name = "local-file";
3187
+ cwd;
3188
+ missionsDir;
3189
+ absDir;
3190
+ owner;
3191
+ repo;
3192
+ cache;
3193
+ constructor(opts) {
3194
+ if (!opts.cwd) throw new Error("LocalFileBackend: cwd is required");
3195
+ if (!opts.missionsDir) throw new Error("LocalFileBackend: missionsDir is required");
3196
+ if (!opts.owner || !opts.repo) throw new Error("LocalFileBackend: owner and repo are required");
3197
+ this.cwd = opts.cwd;
3198
+ this.missionsDir = opts.missionsDir;
3199
+ this.absDir = path15.join(opts.cwd, opts.missionsDir);
3200
+ this.owner = opts.owner;
3201
+ this.repo = opts.repo;
3202
+ this.cache = opts.cache ?? defaultCacheAdapter();
3203
+ }
3204
+ /**
3205
+ * Restore the mission directory from the most recent Actions cache entry
3206
+ * for this repo. No-op when not running in Actions or when no cache exists.
3207
+ */
3208
+ async hydrate() {
3209
+ if (!this.cache.isAvailable()) {
3210
+ process.stdout.write(`[missions/state] hydrate skipped: actions cache unavailable
3211
+ `);
3212
+ return;
3213
+ }
3214
+ fs16.mkdirSync(this.absDir, { recursive: true });
3215
+ const prefix = this.cacheKeyPrefix();
3216
+ const probeKey = `${prefix}probe-${Date.now()}`;
3217
+ try {
3218
+ const matched = await this.cache.restore([this.absDir], probeKey, [prefix]);
3219
+ if (matched) {
3220
+ process.stdout.write(`[missions/state] hydrate hit: ${matched}
3221
+ `);
3222
+ } else {
3223
+ process.stdout.write(`[missions/state] hydrate miss (cold start)
3224
+ `);
3225
+ }
3226
+ } catch (err) {
3227
+ const msg = err instanceof Error ? err.message : String(err);
3228
+ process.stderr.write(`[missions/state] hydrate failed (continuing): ${msg}
3229
+ `);
3230
+ }
3231
+ }
3232
+ /**
3233
+ * Save the mission directory to the Actions cache under a unique key.
3234
+ * No-op when not running in Actions. Errors are logged, never thrown —
3235
+ * callers run this in a finally block and must not swallow real errors.
3236
+ */
3237
+ async persist() {
3238
+ if (!this.cache.isAvailable()) {
3239
+ process.stdout.write(`[missions/state] persist skipped: actions cache unavailable
3240
+ `);
3241
+ return;
3242
+ }
3243
+ if (!fs16.existsSync(this.absDir)) {
3244
+ return;
3245
+ }
3246
+ const key = `${this.cacheKeyPrefix()}${process.env.GITHUB_RUN_ID ?? "norunid"}-${Date.now()}`;
3247
+ try {
3248
+ await this.cache.save([this.absDir], key);
3249
+ process.stdout.write(`[missions/state] persist saved: ${key}
3250
+ `);
3251
+ } catch (err) {
3252
+ const msg = err instanceof Error ? err.message : String(err);
3253
+ process.stderr.write(`[missions/state] persist failed (continuing): ${msg}
3254
+ `);
3255
+ }
3256
+ }
3257
+ load(slug) {
3258
+ const relPath = stateFilePath(this.missionsDir, slug);
3259
+ const absPath = path15.join(this.cwd, relPath);
3260
+ if (!fs16.existsSync(absPath)) {
3261
+ return { path: relPath, handle: null, state: initialStateEnvelope("seed"), created: true };
3262
+ }
3263
+ const raw = fs16.readFileSync(absPath, "utf-8");
3264
+ let parsed;
3265
+ try {
3266
+ parsed = JSON.parse(raw);
3267
+ } catch (err) {
3268
+ const msg = err instanceof Error ? err.message : String(err);
3269
+ throw new Error(`LocalFileBackend: ${relPath} is not valid JSON: ${msg}`);
3270
+ }
3271
+ if (!isStateEnvelope(parsed)) {
3272
+ throw new Error(`LocalFileBackend: ${relPath} is not a StateEnvelope`);
3273
+ }
3274
+ return { path: relPath, handle: null, state: parsed, created: false };
3275
+ }
3276
+ save(loaded, next) {
3277
+ if (!loaded.created && isStateUnchanged(loaded.state, next)) {
3278
+ return false;
3279
+ }
3280
+ const absPath = path15.join(this.cwd, loaded.path);
3281
+ fs16.mkdirSync(path15.dirname(absPath), { recursive: true });
3282
+ const body = JSON.stringify(next, null, 2) + "\n";
3283
+ fs16.writeFileSync(absPath, body, "utf-8");
3284
+ return true;
3285
+ }
3286
+ cacheKeyPrefix() {
3287
+ return `kody-mission-state-${sanitizeKey(this.owner)}-${sanitizeKey(this.repo)}-`;
3288
+ }
3289
+ };
3290
+ function sanitizeKey(s) {
3291
+ return s.replace(/[^A-Za-z0-9._-]/g, "-");
3292
+ }
3293
+ function defaultCacheAdapter() {
3294
+ let mod = null;
3295
+ const load = async () => {
3296
+ if (!mod) {
3297
+ mod = await import("@actions/cache");
3298
+ }
3299
+ return mod;
3300
+ };
3301
+ const available = () => {
3302
+ if (process.env.GITHUB_ACTIONS !== "true") return false;
3303
+ return Boolean(process.env.ACTIONS_CACHE_URL || process.env.ACTIONS_RESULTS_URL);
3304
+ };
3305
+ return {
3306
+ isAvailable: available,
3307
+ async restore(paths, primaryKey, restoreKeys) {
3308
+ if (!available()) return void 0;
3309
+ const m = await load();
3310
+ return m.restoreCache(paths, primaryKey, restoreKeys);
3311
+ },
3312
+ async save(paths, primaryKey) {
3313
+ if (!available()) return;
3314
+ const m = await load();
3315
+ try {
3316
+ await m.saveCache(paths, primaryKey);
3317
+ } catch (err) {
3318
+ const name = err?.name ?? "";
3319
+ if (name === "ReserveCacheError") return;
3320
+ throw err;
3321
+ }
3322
+ }
3323
+ };
3324
+ }
3325
+
3326
+ // src/scripts/missionState/index.ts
3327
+ function resolveBackend(opts) {
3328
+ const owner = opts.config.github?.owner;
3329
+ const repo = opts.config.github?.repo;
3330
+ if (!owner || !repo) {
3331
+ throw new Error("resolveBackend: config.github.owner and config.github.repo must be set");
3332
+ }
3333
+ const requested = opts.config.missions?.stateBackend ?? "contents-api";
3334
+ switch (requested) {
3335
+ case "contents-api":
3336
+ return new ContentsApiBackend({ owner, repo, missionsDir: opts.missionsDir, cwd: opts.cwd });
3337
+ case "local-file":
3338
+ return new LocalFileBackend({ cwd: opts.cwd, missionsDir: opts.missionsDir, owner, repo });
3339
+ default: {
3340
+ const _exhaustive = requested;
3341
+ throw new Error(`resolveBackend: unknown stateBackend "${String(_exhaustive)}"`);
3342
+ }
3343
+ }
3344
+ }
3345
+
3346
+ // src/scripts/dispatchMissionFileTicks.ts
3347
+ var dispatchMissionFileTicks = async (ctx, _profile, args) => {
3348
+ ctx.skipAgent = true;
3349
+ const targetExecutable = String(args?.targetExecutable ?? "");
3350
+ if (!targetExecutable) {
3351
+ throw new Error("dispatchMissionFileTicks: `with.targetExecutable` is required");
3352
+ }
3353
+ const missionsDir = String(args?.missionsDir ?? ".kody/missions");
3354
+ const slugArg = String(args?.slugArg ?? "mission");
3355
+ const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, missionsDir });
3356
+ if (backend.hydrate) {
3357
+ await backend.hydrate();
3358
+ }
3359
+ try {
3360
+ const slugs = listMissionSlugs(path16.join(ctx.cwd, missionsDir));
3361
+ ctx.data.missionSlugCount = slugs.length;
3362
+ if (slugs.length === 0) {
3363
+ process.stdout.write(`[missions] no mission files in ${missionsDir}
3364
+ `);
3365
+ return;
3366
+ }
3367
+ process.stdout.write(`[missions] ticking ${slugs.length} mission(s) via ${targetExecutable}
3368
+ `);
3369
+ const results = [];
3370
+ for (const slug of slugs) {
3371
+ process.stdout.write(`[missions] \u2192 tick ${slug}
3372
+ `);
3373
+ try {
3374
+ const out = await runExecutable(targetExecutable, {
3375
+ cliArgs: { [slugArg]: slug },
3376
+ cwd: ctx.cwd,
3377
+ config: ctx.config,
3378
+ verbose: ctx.verbose,
3379
+ quiet: ctx.quiet
3380
+ });
3381
+ results.push({ slug, exitCode: out.exitCode, reason: out.reason });
3382
+ if (out.exitCode !== 0) {
3383
+ process.stderr.write(`[missions] tick ${slug} failed (exit ${out.exitCode}): ${out.reason ?? ""}
3384
+ `);
3385
+ }
3386
+ } catch (err) {
3387
+ const msg = err instanceof Error ? err.message : String(err);
3388
+ process.stderr.write(`[missions] tick ${slug} crashed: ${msg}
3389
+ `);
3390
+ results.push({ slug, exitCode: 99, reason: msg });
3391
+ }
3392
+ }
3393
+ ctx.data.missionTickResults = results;
3394
+ ctx.output.exitCode = 0;
3395
+ } finally {
3396
+ if (backend.persist) {
3397
+ try {
3398
+ await backend.persist();
3399
+ } catch (err) {
3400
+ const msg = err instanceof Error ? err.message : String(err);
3401
+ process.stderr.write(`[missions] backend persist failed: ${msg}
3402
+ `);
3403
+ }
3404
+ }
3405
+ }
3406
+ };
3407
+ function listMissionSlugs(absDir) {
3408
+ if (!fs17.existsSync(absDir)) return [];
3409
+ let entries;
3410
+ try {
3411
+ entries = fs17.readdirSync(absDir, { withFileTypes: true });
3412
+ } catch {
3413
+ return [];
3414
+ }
3415
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name.replace(/\.md$/, "")).filter((slug) => slug.length > 0 && !slug.startsWith("_") && !slug.startsWith(".")).sort();
3416
+ }
3417
+
3052
3418
  // src/scripts/dispatchMissionTicks.ts
3053
3419
  var dispatchMissionTicks = async (ctx, _profile, args) => {
3054
3420
  ctx.skipAgent = true;
@@ -3681,7 +4047,7 @@ function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
3681
4047
 
3682
4048
  // src/gha.ts
3683
4049
  import { execFileSync as execFileSync12 } from "child_process";
3684
- import * as fs17 from "fs";
4050
+ import * as fs18 from "fs";
3685
4051
  function getRunUrl() {
3686
4052
  const server = process.env.GITHUB_SERVER_URL;
3687
4053
  const repo = process.env.GITHUB_REPOSITORY;
@@ -3692,10 +4058,10 @@ function getRunUrl() {
3692
4058
  function reactToTriggerComment(cwd) {
3693
4059
  if (process.env.GITHUB_EVENT_NAME !== "issue_comment") return;
3694
4060
  const eventPath = process.env.GITHUB_EVENT_PATH;
3695
- if (!eventPath || !fs17.existsSync(eventPath)) return;
4061
+ if (!eventPath || !fs18.existsSync(eventPath)) return;
3696
4062
  let event = null;
3697
4063
  try {
3698
- event = JSON.parse(fs17.readFileSync(eventPath, "utf-8"));
4064
+ event = JSON.parse(fs18.readFileSync(eventPath, "utf-8"));
3699
4065
  } catch {
3700
4066
  return;
3701
4067
  }
@@ -3935,22 +4301,22 @@ function tryPostPr2(prNumber, body, cwd) {
3935
4301
 
3936
4302
  // src/scripts/initFlow.ts
3937
4303
  import { execFileSync as execFileSync14 } from "child_process";
3938
- import * as fs19 from "fs";
3939
- import * as path17 from "path";
4304
+ import * as fs20 from "fs";
4305
+ import * as path18 from "path";
3940
4306
 
3941
4307
  // src/scripts/loadQaGuide.ts
3942
- import * as fs18 from "fs";
3943
- import * as path16 from "path";
4308
+ import * as fs19 from "fs";
4309
+ import * as path17 from "path";
3944
4310
  var QA_GUIDE_REL_PATH = ".kody/qa-guide.md";
3945
4311
  var loadQaGuide = async (ctx) => {
3946
- const full = path16.join(ctx.cwd, QA_GUIDE_REL_PATH);
3947
- if (!fs18.existsSync(full)) {
4312
+ const full = path17.join(ctx.cwd, QA_GUIDE_REL_PATH);
4313
+ if (!fs19.existsSync(full)) {
3948
4314
  ctx.data.qaGuide = "";
3949
4315
  ctx.data.qaGuidePath = "";
3950
4316
  return;
3951
4317
  }
3952
4318
  try {
3953
- ctx.data.qaGuide = fs18.readFileSync(full, "utf-8");
4319
+ ctx.data.qaGuide = fs19.readFileSync(full, "utf-8");
3954
4320
  ctx.data.qaGuidePath = QA_GUIDE_REL_PATH;
3955
4321
  } catch {
3956
4322
  ctx.data.qaGuide = "";
@@ -3960,9 +4326,9 @@ var loadQaGuide = async (ctx) => {
3960
4326
 
3961
4327
  // src/scripts/initFlow.ts
3962
4328
  function detectPackageManager(cwd) {
3963
- if (fs19.existsSync(path17.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
3964
- if (fs19.existsSync(path17.join(cwd, "yarn.lock"))) return "yarn";
3965
- if (fs19.existsSync(path17.join(cwd, "bun.lockb"))) return "bun";
4329
+ if (fs20.existsSync(path18.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
4330
+ if (fs20.existsSync(path18.join(cwd, "yarn.lock"))) return "yarn";
4331
+ if (fs20.existsSync(path18.join(cwd, "bun.lockb"))) return "bun";
3966
4332
  return "npm";
3967
4333
  }
3968
4334
  function qualityCommandsFor(pm) {
@@ -4084,33 +4450,33 @@ function performInit(cwd, force) {
4084
4450
  const pm = detectPackageManager(cwd);
4085
4451
  const ownerRepo = detectOwnerRepo(cwd);
4086
4452
  const defaultBranch = defaultBranchFromGit(cwd);
4087
- const configPath = path17.join(cwd, "kody.config.json");
4088
- if (fs19.existsSync(configPath) && !force) {
4453
+ const configPath = path18.join(cwd, "kody.config.json");
4454
+ if (fs20.existsSync(configPath) && !force) {
4089
4455
  skipped.push("kody.config.json");
4090
4456
  } else {
4091
4457
  const cfg = makeConfig(pm, ownerRepo, defaultBranch);
4092
- fs19.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
4458
+ fs20.writeFileSync(configPath, `${JSON.stringify(cfg, null, 2)}
4093
4459
  `);
4094
4460
  wrote.push("kody.config.json");
4095
4461
  }
4096
- const workflowDir = path17.join(cwd, ".github", "workflows");
4097
- const workflowPath = path17.join(workflowDir, "kody.yml");
4098
- if (fs19.existsSync(workflowPath) && !force) {
4462
+ const workflowDir = path18.join(cwd, ".github", "workflows");
4463
+ const workflowPath = path18.join(workflowDir, "kody.yml");
4464
+ if (fs20.existsSync(workflowPath) && !force) {
4099
4465
  skipped.push(".github/workflows/kody.yml");
4100
4466
  } else {
4101
- fs19.mkdirSync(workflowDir, { recursive: true });
4102
- fs19.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
4467
+ fs20.mkdirSync(workflowDir, { recursive: true });
4468
+ fs20.writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
4103
4469
  wrote.push(".github/workflows/kody.yml");
4104
4470
  }
4105
- const hasUi = fs19.existsSync(path17.join(cwd, "src/app")) || fs19.existsSync(path17.join(cwd, "app")) || fs19.existsSync(path17.join(cwd, "pages"));
4471
+ const hasUi = fs20.existsSync(path18.join(cwd, "src/app")) || fs20.existsSync(path18.join(cwd, "app")) || fs20.existsSync(path18.join(cwd, "pages"));
4106
4472
  if (hasUi) {
4107
- const qaGuidePath = path17.join(cwd, QA_GUIDE_REL_PATH);
4108
- if (fs19.existsSync(qaGuidePath) && !force) {
4473
+ const qaGuidePath = path18.join(cwd, QA_GUIDE_REL_PATH);
4474
+ if (fs20.existsSync(qaGuidePath) && !force) {
4109
4475
  skipped.push(QA_GUIDE_REL_PATH);
4110
4476
  } else {
4111
- fs19.mkdirSync(path17.dirname(qaGuidePath), { recursive: true });
4477
+ fs20.mkdirSync(path18.dirname(qaGuidePath), { recursive: true });
4112
4478
  const discovery = runQaDiscovery(cwd);
4113
- fs19.writeFileSync(qaGuidePath, generateQaGuideTemplate(discovery));
4479
+ fs20.writeFileSync(qaGuidePath, generateQaGuideTemplate(discovery));
4114
4480
  wrote.push(QA_GUIDE_REL_PATH);
4115
4481
  }
4116
4482
  }
@@ -4122,12 +4488,12 @@ function performInit(cwd, force) {
4122
4488
  continue;
4123
4489
  }
4124
4490
  if (profile.kind !== "scheduled" || !profile.schedule) continue;
4125
- const target = path17.join(workflowDir, `kody-${exe.name}.yml`);
4126
- if (fs19.existsSync(target) && !force) {
4491
+ const target = path18.join(workflowDir, `kody-${exe.name}.yml`);
4492
+ if (fs20.existsSync(target) && !force) {
4127
4493
  skipped.push(`.github/workflows/kody-${exe.name}.yml`);
4128
4494
  continue;
4129
4495
  }
4130
- fs19.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
4496
+ fs20.writeFileSync(target, renderScheduledWorkflow(exe.name, profile.schedule));
4131
4497
  wrote.push(`.github/workflows/kody-${exe.name}.yml`);
4132
4498
  }
4133
4499
  let labels;
@@ -4238,89 +4604,6 @@ var loadIssueContext = async (ctx) => {
4238
4604
  ctx.data.commentTargetNumber = issueNumber;
4239
4605
  };
4240
4606
 
4241
- // src/scripts/issueStateComment.ts
4242
- function isStateEnvelope(x) {
4243
- if (x === null || typeof x !== "object") return false;
4244
- const o = x;
4245
- return o.version === 1 && typeof o.rev === "number" && Number.isInteger(o.rev) && o.rev >= 0 && typeof o.cursor === "string" && typeof o.done === "boolean" && o.data !== null && typeof o.data === "object" && !Array.isArray(o.data);
4246
- }
4247
- function initialStateEnvelope(cursor = "seed") {
4248
- return { version: 1, rev: 0, cursor, data: {}, done: false };
4249
- }
4250
- function formatStateCommentBody(marker, state) {
4251
- return `<!-- ${marker} -->
4252
-
4253
- \`\`\`json
4254
- ${JSON.stringify(state, null, 2)}
4255
- \`\`\`
4256
- `;
4257
- }
4258
- function parseStateCommentBody(marker, body) {
4259
- const markerLine = `<!-- ${marker} -->`;
4260
- if (!body.trimStart().startsWith(markerLine)) return null;
4261
- const fenceOpen = body.indexOf("```json");
4262
- if (fenceOpen === -1) return null;
4263
- const after = body.slice(fenceOpen + "```json".length);
4264
- const fenceClose = after.indexOf("```");
4265
- if (fenceClose === -1) return null;
4266
- const jsonText = after.slice(0, fenceClose).trim();
4267
- let parsed;
4268
- try {
4269
- parsed = JSON.parse(jsonText);
4270
- } catch {
4271
- return null;
4272
- }
4273
- return isStateEnvelope(parsed) ? parsed : null;
4274
- }
4275
- function listIssueComments(owner, repo, issueNumber, cwd) {
4276
- const raw = gh2(["api", "--paginate", `repos/${owner}/${repo}/issues/${issueNumber}/comments`], { cwd });
4277
- let parsed;
4278
- try {
4279
- parsed = JSON.parse(raw);
4280
- } catch {
4281
- return [];
4282
- }
4283
- if (!Array.isArray(parsed)) return [];
4284
- return parsed.filter((c) => typeof c.id === "number" && typeof c.node_id === "string" && typeof c.body === "string").map((c) => ({ id: c.id, node_id: c.node_id, body: c.body }));
4285
- }
4286
- function findStateComment2(owner, repo, issueNumber, marker, cwd) {
4287
- const comments = listIssueComments(owner, repo, issueNumber, cwd);
4288
- for (const c of comments) {
4289
- const state = parseStateCommentBody(marker, c.body);
4290
- if (!state) continue;
4291
- return { commentId: c.id, commentNodeId: c.node_id, state };
4292
- }
4293
- return null;
4294
- }
4295
- function createStateComment(owner, repo, issueNumber, marker, state, cwd) {
4296
- const body = formatStateCommentBody(marker, state);
4297
- const raw = gh2(["api", "--method", "POST", `repos/${owner}/${repo}/issues/${issueNumber}/comments`, "--input", "-"], {
4298
- cwd,
4299
- input: JSON.stringify({ body })
4300
- });
4301
- const parsed = JSON.parse(raw);
4302
- try {
4303
- minimizeComment(parsed.node_id, cwd);
4304
- } catch {
4305
- }
4306
- return { commentId: parsed.id, commentNodeId: parsed.node_id, state };
4307
- }
4308
- function updateStateComment(owner, repo, commentId, commentNodeId, marker, state, cwd) {
4309
- const body = formatStateCommentBody(marker, state);
4310
- gh2(["api", "--method", "PATCH", `repos/${owner}/${repo}/issues/comments/${commentId}`, "--input", "-"], {
4311
- cwd,
4312
- input: JSON.stringify({ body })
4313
- });
4314
- try {
4315
- minimizeComment(commentNodeId, cwd);
4316
- } catch {
4317
- }
4318
- }
4319
- function minimizeComment(nodeId, cwd) {
4320
- const mutation = "mutation($id: ID!) { minimizeComment(input: { classifier: OUTDATED, subjectId: $id }) { minimizedComment { isMinimized } } }";
4321
- gh2(["api", "graphql", "-f", `query=${mutation}`, "-f", `id=${nodeId}`], { cwd });
4322
- }
4323
-
4324
4607
  // src/scripts/loadIssueStateComment.ts
4325
4608
  var loadIssueStateComment = async (ctx, _profile, args) => {
4326
4609
  const marker = String(args?.marker ?? "");
@@ -4348,75 +4631,8 @@ var loadIssueStateComment = async (ctx, _profile, args) => {
4348
4631
  };
4349
4632
 
4350
4633
  // src/scripts/loadMissionFromFile.ts
4351
- import * as fs20 from "fs";
4352
- import * as path18 from "path";
4353
-
4354
- // src/scripts/missionStateFile.ts
4355
- function stateFilePath(missionsDir, slug) {
4356
- return `${missionsDir.replace(/\/+$/, "")}/${slug}.state.json`;
4357
- }
4358
- function loadMissionState(owner, repo, filePath, cwd) {
4359
- let raw = "";
4360
- try {
4361
- raw = gh2(["api", `/repos/${owner}/${repo}/contents/${filePath}`], { cwd });
4362
- } catch (err) {
4363
- const msg = err instanceof Error ? err.message : String(err);
4364
- if (/HTTP 404/i.test(msg) || /Not Found/i.test(msg)) {
4365
- return { path: filePath, sha: null, state: initialStateEnvelope("seed"), created: true };
4366
- }
4367
- throw err;
4368
- }
4369
- let parsed;
4370
- try {
4371
- parsed = JSON.parse(raw);
4372
- } catch {
4373
- throw new Error(`loadMissionState: contents API for ${filePath} did not return JSON`);
4374
- }
4375
- if (!parsed || typeof parsed !== "object") {
4376
- throw new Error(`loadMissionState: contents API for ${filePath} returned non-object`);
4377
- }
4378
- const o = parsed;
4379
- if (o.type !== "file" || o.encoding !== "base64" || typeof o.content !== "string") {
4380
- throw new Error(`loadMissionState: ${filePath} is not a base64 file`);
4381
- }
4382
- const decoded = Buffer.from(o.content, "base64").toString("utf-8");
4383
- let envelope;
4384
- try {
4385
- envelope = JSON.parse(decoded);
4386
- } catch {
4387
- throw new Error(`loadMissionState: ${filePath} is not valid JSON`);
4388
- }
4389
- if (!isStateEnvelope(envelope)) {
4390
- throw new Error(`loadMissionState: ${filePath} is not a StateEnvelope`);
4391
- }
4392
- return { path: filePath, sha: o.sha, state: envelope, created: false };
4393
- }
4394
- function writeMissionState(owner, repo, loaded, next, cwd) {
4395
- if (!loaded.created && deepEqualsState(loaded.state, next)) {
4396
- return false;
4397
- }
4398
- const body = JSON.stringify(next, null, 2) + "\n";
4399
- const payload = {
4400
- message: `chore(missions): update state for ${stateFileSlug(loaded.path)} (rev ${next.rev})`,
4401
- content: Buffer.from(body, "utf-8").toString("base64")
4402
- };
4403
- if (loaded.sha) payload.sha = loaded.sha;
4404
- gh2(["api", "--method", "PUT", `/repos/${owner}/${repo}/contents/${loaded.path}`, "--input", "-"], {
4405
- cwd,
4406
- input: JSON.stringify(payload)
4407
- });
4408
- return true;
4409
- }
4410
- function deepEqualsState(a, b) {
4411
- if (a.cursor !== b.cursor || a.done !== b.done) return false;
4412
- return JSON.stringify(a.data) === JSON.stringify(b.data);
4413
- }
4414
- function stateFileSlug(filePath) {
4415
- const last = filePath.split("/").pop() ?? filePath;
4416
- return last.replace(/\.state\.json$/i, "");
4417
- }
4418
-
4419
- // src/scripts/loadMissionFromFile.ts
4634
+ import * as fs21 from "fs";
4635
+ import * as path19 from "path";
4420
4636
  var loadMissionFromFile = async (ctx, _profile, args) => {
4421
4637
  const missionsDir = String(args?.missionsDir ?? ".kody/missions");
4422
4638
  const slugArg = String(args?.slugArg ?? "mission");
@@ -4424,18 +4640,14 @@ var loadMissionFromFile = async (ctx, _profile, args) => {
4424
4640
  if (!slug) {
4425
4641
  throw new Error(`loadMissionFromFile: ctx.args.${slugArg} must be a non-empty slug`);
4426
4642
  }
4427
- const owner = ctx.config.github.owner;
4428
- const repo = ctx.config.github.repo;
4429
- if (!owner || !repo) {
4430
- throw new Error("loadMissionFromFile: ctx.config.github.owner/repo must be set");
4431
- }
4432
- const absPath = path18.join(ctx.cwd, missionsDir, `${slug}.md`);
4433
- if (!fs20.existsSync(absPath)) {
4643
+ const absPath = path19.join(ctx.cwd, missionsDir, `${slug}.md`);
4644
+ if (!fs21.existsSync(absPath)) {
4434
4645
  throw new Error(`loadMissionFromFile: mission file not found: ${absPath}`);
4435
4646
  }
4436
- const raw = fs20.readFileSync(absPath, "utf-8");
4647
+ const raw = fs21.readFileSync(absPath, "utf-8");
4437
4648
  const { title, body } = parseMissionFile(raw, slug);
4438
- const loaded = loadMissionState(owner, repo, stateFilePath(missionsDir, slug), ctx.cwd);
4649
+ const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, missionsDir });
4650
+ const loaded = await backend.load(slug);
4439
4651
  ctx.data.missionSlug = slug;
4440
4652
  ctx.data.missionTitle = title;
4441
4653
  ctx.data.missionIntent = body;
@@ -4566,16 +4778,16 @@ var loadTaskState = async (ctx) => {
4566
4778
  };
4567
4779
 
4568
4780
  // src/scripts/loadVaultContext.ts
4569
- import * as fs21 from "fs";
4570
- import * as path19 from "path";
4781
+ import * as fs22 from "fs";
4782
+ import * as path20 from "path";
4571
4783
  var VAULT_DIR_RELATIVE = ".kody/vault";
4572
4784
  var MAX_PAGES = 8;
4573
4785
  var PER_PAGE_MAX_BYTES = 4e3;
4574
4786
  var TOTAL_MAX_BYTES2 = 24e3;
4575
4787
  var TRUNCATED_SUFFIX2 = "\n\n\u2026 (truncated)";
4576
4788
  var loadVaultContext = async (ctx) => {
4577
- const vaultAbs = path19.join(ctx.cwd, VAULT_DIR_RELATIVE);
4578
- if (!fs21.existsSync(vaultAbs)) {
4789
+ const vaultAbs = path20.join(ctx.cwd, VAULT_DIR_RELATIVE);
4790
+ if (!fs22.existsSync(vaultAbs)) {
4579
4791
  ctx.data.vaultContext = "";
4580
4792
  return;
4581
4793
  }
@@ -4600,21 +4812,21 @@ function collectPages(vaultAbs) {
4600
4812
  walkMd(vaultAbs, (file) => {
4601
4813
  let stat;
4602
4814
  try {
4603
- stat = fs21.statSync(file);
4815
+ stat = fs22.statSync(file);
4604
4816
  } catch {
4605
4817
  return;
4606
4818
  }
4607
4819
  let raw;
4608
4820
  try {
4609
- raw = fs21.readFileSync(file, "utf-8");
4821
+ raw = fs22.readFileSync(file, "utf-8");
4610
4822
  } catch {
4611
4823
  return;
4612
4824
  }
4613
4825
  const fm = raw.match(/^---\s*\n([\s\S]*?)\n---/);
4614
- const title = fm?.[1]?.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? path19.basename(file, ".md");
4826
+ const title = fm?.[1]?.match(/^title:\s*(.+)$/m)?.[1]?.trim() ?? path20.basename(file, ".md");
4615
4827
  const updated = fm?.[1]?.match(/^updated:\s*([0-9T:.+\-Z]+)/m)?.[1]?.trim() ?? "";
4616
4828
  out.push({
4617
- relPath: path19.relative(vaultAbs, file),
4829
+ relPath: path20.relative(vaultAbs, file),
4618
4830
  title,
4619
4831
  updated,
4620
4832
  content: raw.length > PER_PAGE_MAX_BYTES ? raw.slice(0, PER_PAGE_MAX_BYTES) + TRUNCATED_SUFFIX2 : raw,
@@ -4682,16 +4894,16 @@ function walkMd(root, visit) {
4682
4894
  const dir = stack.pop();
4683
4895
  let names;
4684
4896
  try {
4685
- names = fs21.readdirSync(dir);
4897
+ names = fs22.readdirSync(dir);
4686
4898
  } catch {
4687
4899
  continue;
4688
4900
  }
4689
4901
  for (const name of names) {
4690
4902
  if (name.startsWith(".")) continue;
4691
- const full = path19.join(dir, name);
4903
+ const full = path20.join(dir, name);
4692
4904
  let stat;
4693
4905
  try {
4694
- stat = fs21.statSync(full);
4906
+ stat = fs22.statSync(full);
4695
4907
  } catch {
4696
4908
  continue;
4697
4909
  }
@@ -4714,15 +4926,15 @@ var markFlowSuccess = async (ctx) => {
4714
4926
 
4715
4927
  // src/scripts/memorizeFlow.ts
4716
4928
  import { execFileSync as execFileSync15 } from "child_process";
4717
- import * as fs22 from "fs";
4718
- import * as path20 from "path";
4929
+ import * as fs23 from "fs";
4930
+ import * as path21 from "path";
4719
4931
  var VAULT_DIR_RELATIVE2 = ".kody/vault";
4720
4932
  var DEFAULT_LOOKBACK_HOURS = 36;
4721
4933
  var MAX_RECENT_PRS = 25;
4722
4934
  var MAX_VAULT_INDEX_ENTRIES = 200;
4723
4935
  var PR_BODY_TRUNC = 2e3;
4724
4936
  var memorizeFlow = async (ctx) => {
4725
- const vaultAbs = path20.join(ctx.cwd, VAULT_DIR_RELATIVE2);
4937
+ const vaultAbs = path21.join(ctx.cwd, VAULT_DIR_RELATIVE2);
4726
4938
  ensureBranch(ctx, vaultAbs);
4727
4939
  if (ctx.skipAgent) return;
4728
4940
  const sinceIso = computeSinceIso(vaultAbs);
@@ -4732,8 +4944,8 @@ var memorizeFlow = async (ctx) => {
4732
4944
  const recent = fetchRecentPrs(ctx.cwd, sinceIso);
4733
4945
  ctx.data.recentPrs = formatRecentPrs(recent);
4734
4946
  ctx.data.recentPrCount = recent.length;
4735
- if (!fs22.existsSync(vaultAbs)) {
4736
- fs22.mkdirSync(vaultAbs, { recursive: true });
4947
+ if (!fs23.existsSync(vaultAbs)) {
4948
+ fs23.mkdirSync(vaultAbs, { recursive: true });
4737
4949
  }
4738
4950
  ctx.data.vaultIndex = formatVaultIndex(vaultAbs);
4739
4951
  if (recent.length === 0) {
@@ -4765,18 +4977,18 @@ function ensureBranch(ctx, vaultAbs) {
4765
4977
  }
4766
4978
  }
4767
4979
  ctx.data.branch = branch;
4768
- if (!fs22.existsSync(vaultAbs)) {
4769
- fs22.mkdirSync(vaultAbs, { recursive: true });
4980
+ if (!fs23.existsSync(vaultAbs)) {
4981
+ fs23.mkdirSync(vaultAbs, { recursive: true });
4770
4982
  }
4771
4983
  }
4772
4984
  function computeSinceIso(vaultAbs) {
4773
4985
  const fallback = new Date(Date.now() - DEFAULT_LOOKBACK_HOURS * 60 * 60 * 1e3).toISOString();
4774
- if (!fs22.existsSync(vaultAbs)) return fallback;
4986
+ if (!fs23.existsSync(vaultAbs)) return fallback;
4775
4987
  let latest = "";
4776
4988
  walkMd2(vaultAbs, (file) => {
4777
4989
  let raw;
4778
4990
  try {
4779
- raw = fs22.readFileSync(file, "utf-8");
4991
+ raw = fs23.readFileSync(file, "utf-8");
4780
4992
  } catch {
4781
4993
  return;
4782
4994
  }
@@ -4853,10 +5065,10 @@ function formatVaultIndex(vaultAbs) {
4853
5065
  const entries = [];
4854
5066
  walkMd2(vaultAbs, (file) => {
4855
5067
  if (entries.length >= MAX_VAULT_INDEX_ENTRIES) return;
4856
- const rel = path20.relative(vaultAbs, file);
5068
+ const rel = path21.relative(vaultAbs, file);
4857
5069
  let title = rel;
4858
5070
  try {
4859
- const raw = fs22.readFileSync(file, "utf-8");
5071
+ const raw = fs23.readFileSync(file, "utf-8");
4860
5072
  const m = raw.match(/^---\s*\n([\s\S]*?)\n---/);
4861
5073
  const titleMatch = m?.[1]?.match(/^title:\s*(.+)$/m);
4862
5074
  if (titleMatch) title = `${titleMatch[1].trim()} (${rel})`;
@@ -4868,22 +5080,22 @@ function formatVaultIndex(vaultAbs) {
4868
5080
  return entries.join("\n");
4869
5081
  }
4870
5082
  function walkMd2(root, visit) {
4871
- if (!fs22.existsSync(root)) return;
5083
+ if (!fs23.existsSync(root)) return;
4872
5084
  const stack = [root];
4873
5085
  while (stack.length > 0) {
4874
5086
  const dir = stack.pop();
4875
5087
  let names;
4876
5088
  try {
4877
- names = fs22.readdirSync(dir);
5089
+ names = fs23.readdirSync(dir);
4878
5090
  } catch {
4879
5091
  continue;
4880
5092
  }
4881
5093
  for (const name of names) {
4882
5094
  if (name.startsWith(".")) continue;
4883
- const full = path20.join(dir, name);
5095
+ const full = path21.join(dir, name);
4884
5096
  let stat;
4885
5097
  try {
4886
- stat = fs22.statSync(full);
5098
+ stat = fs23.statSync(full);
4887
5099
  } catch {
4888
5100
  continue;
4889
5101
  }
@@ -5212,7 +5424,15 @@ var postIssueComment2 = async (ctx) => {
5212
5424
  const isFailure = failureReason.length > 0;
5213
5425
  const justPushedToExistingPr = prAction === "updated" && commitResult?.committed === true;
5214
5426
  const successMsg = justPushedToExistingPr ? `\u2705 kody pushed to ${prUrl}` : prAction === "updated" ? `\u2139\uFE0F kody made no changes \u2014 PR: ${prUrl}` : `\u2705 kody PR opened: ${prUrl}`;
5215
- const failurePrSuffix = prUrl ? prAction === "updated" ? ` \u2014 PR: ${prUrl}` : ` \u2014 draft PR: ${prUrl}` : "";
5427
+ const branch = ctx.data.branch;
5428
+ const failurePrSuffix = computeFailureSuffix({
5429
+ prUrl,
5430
+ prAction,
5431
+ branch,
5432
+ branchPushed: commitResult?.committed === true,
5433
+ githubOwner: ctx.config.github?.owner,
5434
+ githubRepo: ctx.config.github?.repo
5435
+ });
5216
5436
  const msg = isFailure ? `\u26A0\uFE0F kody FAILED: ${truncate2(failureReason, 1500)}${failurePrSuffix}` : successMsg;
5217
5437
  postWith(targetType, targetNumber, msg, ctx.cwd);
5218
5438
  let exitCode = 0;
@@ -5224,6 +5444,13 @@ var postIssueComment2 = async (ctx) => {
5224
5444
  ctx.output.exitCode = exitCode;
5225
5445
  ctx.output.reason = failureReason || void 0;
5226
5446
  };
5447
+ function computeFailureSuffix(input) {
5448
+ if (input.prUrl) {
5449
+ return input.prAction === "updated" ? ` \u2014 PR: ${input.prUrl}` : ` \u2014 draft PR: ${input.prUrl}`;
5450
+ }
5451
+ if (!input.branchPushed || !input.branch || !input.githubOwner || !input.githubRepo) return "";
5452
+ return ` \u2014 branch: https://github.com/${input.githubOwner}/${input.githubRepo}/tree/${input.branch}`;
5453
+ }
5227
5454
  function computeFailureReason2(ctx) {
5228
5455
  const misses = ctx.data.coverageMisses ?? [];
5229
5456
  if (misses.length > 0) return `missing tests: ${misses.map((m) => m.expectedTest).join(", ")}`;
@@ -6389,7 +6616,7 @@ var writeIssueStateComment = async (ctx, _profile, _agentResult, args) => {
6389
6616
  };
6390
6617
 
6391
6618
  // src/scripts/writeMissionStateFile.ts
6392
- var writeMissionStateFile = async (ctx, _profile, _agentResult) => {
6619
+ var writeMissionStateFile = async (ctx, _profile, _agentResult, args) => {
6393
6620
  const parseError = ctx.data.nextStateParseError;
6394
6621
  if (parseError) {
6395
6622
  process.stderr.write(`[kody] mission state write skipped: ${parseError}
@@ -6406,16 +6633,13 @@ var writeMissionStateFile = async (ctx, _profile, _agentResult) => {
6406
6633
  if (!loaded) {
6407
6634
  throw new Error("writeMissionStateFile: ctx.data.missionState missing \u2014 preflight must run first");
6408
6635
  }
6409
- const owner = ctx.config.github.owner;
6410
- const repo = ctx.config.github.repo;
6411
- if (!owner || !repo) {
6412
- throw new Error("writeMissionStateFile: ctx.config.github.owner/repo must be set");
6413
- }
6414
- writeMissionState(owner, repo, loaded, next, ctx.cwd);
6636
+ const missionsDir = String(args?.missionsDir ?? ".kody/missions");
6637
+ const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, missionsDir });
6638
+ await backend.save(loaded, next);
6415
6639
  };
6416
6640
 
6417
6641
  // src/scripts/writeRunSummary.ts
6418
- import * as fs23 from "fs";
6642
+ import * as fs24 from "fs";
6419
6643
  var writeRunSummary = async (ctx, profile) => {
6420
6644
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
6421
6645
  if (!summaryPath) return;
@@ -6437,7 +6661,7 @@ var writeRunSummary = async (ctx, profile) => {
6437
6661
  if (reason) lines.push(`- **Reason:** ${reason}`);
6438
6662
  lines.push("");
6439
6663
  try {
6440
- fs23.appendFileSync(summaryPath, `${lines.join("\n")}
6664
+ fs24.appendFileSync(summaryPath, `${lines.join("\n")}
6441
6665
  `);
6442
6666
  } catch {
6443
6667
  }
@@ -6620,9 +6844,9 @@ async function runExecutable(profileName, input) {
6620
6844
  data: {},
6621
6845
  output: { exitCode: 0 }
6622
6846
  };
6623
- const ndjsonDir = path21.join(input.cwd, ".kody");
6847
+ const ndjsonDir = path22.join(input.cwd, ".kody");
6624
6848
  const invokeAgent = async (prompt) => {
6625
- const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path21.isAbsolute(p) ? p : path21.resolve(profile.dir, p)).filter((p) => p.length > 0);
6849
+ const externalPlugins = (profile.claudeCode.plugins ?? []).map((p) => path22.isAbsolute(p) ? p : path22.resolve(profile.dir, p)).filter((p) => p.length > 0);
6626
6850
  const syntheticPath = ctx.data.syntheticPluginPath;
6627
6851
  const pluginPaths = [...externalPlugins, ...syntheticPath ? [syntheticPath] : []];
6628
6852
  return runAgent({
@@ -6648,7 +6872,7 @@ async function runExecutable(profileName, input) {
6648
6872
  for (const entry of profile.scripts.preflight) {
6649
6873
  if (!shouldRun(entry, ctx)) continue;
6650
6874
  if (entry.shell) {
6651
- runShellEntry(entry, ctx, profile);
6875
+ await runShellEntry(entry, ctx, profile);
6652
6876
  } else {
6653
6877
  const fn = preflightScripts[entry.script];
6654
6878
  if (!fn) return finish({ exitCode: 99, reason: `preflight script not registered: ${entry.script}` });
@@ -6671,7 +6895,7 @@ async function runExecutable(profileName, input) {
6671
6895
  const label = entry.script ?? entry.shell ?? "<unknown>";
6672
6896
  try {
6673
6897
  if (entry.shell) {
6674
- runShellEntry(entry, ctx, profile);
6898
+ await runShellEntry(entry, ctx, profile);
6675
6899
  } else {
6676
6900
  const fn = postflightScripts[entry.script];
6677
6901
  if (!fn) return finish({ exitCode: 99, reason: `postflight script not registered: ${entry.script}` });
@@ -6701,17 +6925,17 @@ async function runExecutable(profileName, input) {
6701
6925
  function resolveProfilePath(profileName) {
6702
6926
  const found = resolveExecutable(profileName);
6703
6927
  if (found) return found;
6704
- const here = path21.dirname(new URL(import.meta.url).pathname);
6928
+ const here = path22.dirname(new URL(import.meta.url).pathname);
6705
6929
  const candidates = [
6706
- path21.join(here, "executables", profileName, "profile.json"),
6930
+ path22.join(here, "executables", profileName, "profile.json"),
6707
6931
  // same-dir sibling (dev)
6708
- path21.join(here, "..", "executables", profileName, "profile.json"),
6932
+ path22.join(here, "..", "executables", profileName, "profile.json"),
6709
6933
  // up one (prod: dist/bin → dist/executables)
6710
- path21.join(here, "..", "src", "executables", profileName, "profile.json")
6934
+ path22.join(here, "..", "src", "executables", profileName, "profile.json")
6711
6935
  // fallback
6712
6936
  ];
6713
6937
  for (const c of candidates) {
6714
- if (fs24.existsSync(c)) return c;
6938
+ if (fs25.existsSync(c)) return c;
6715
6939
  }
6716
6940
  return candidates[0];
6717
6941
  }
@@ -6812,10 +7036,11 @@ function resolveShellTimeoutMs(entry) {
6812
7036
  }
6813
7037
  return DEFAULT_SHELL_TIMEOUT_MS;
6814
7038
  }
6815
- function runShellEntry(entry, ctx, profile) {
7039
+ var SIGKILL_GRACE_MS = 5e3;
7040
+ async function runShellEntry(entry, ctx, profile) {
6816
7041
  const shellName = entry.shell;
6817
- const shellPath = path21.join(profile.dir, shellName);
6818
- if (!fs24.existsSync(shellPath)) {
7042
+ const shellPath = path22.join(profile.dir, shellName);
7043
+ if (!fs25.existsSync(shellPath)) {
6819
7044
  ctx.skipAgent = true;
6820
7045
  ctx.output.exitCode = 99;
6821
7046
  ctx.output.reason = `shell script not found: ${shellName} (looked in ${profile.dir})`;
@@ -6831,17 +7056,63 @@ function runShellEntry(entry, ctx, profile) {
6831
7056
  env[`KODY_CFG_${k}`] = v;
6832
7057
  }
6833
7058
  const timeoutMs = resolveShellTimeoutMs(entry);
6834
- const r = spawnSync("bash", [shellPath, ...positional], {
7059
+ const child = spawn3("bash", [shellPath, ...positional], {
6835
7060
  cwd: ctx.cwd,
6836
- encoding: "utf-8",
6837
7061
  env,
6838
7062
  stdio: ["pipe", "pipe", "pipe"],
6839
- timeout: timeoutMs
7063
+ detached: true
7064
+ });
7065
+ let stdout = "";
7066
+ let stderr = "";
7067
+ child.stdout?.on("data", (chunk) => {
7068
+ const s = chunk.toString("utf-8");
7069
+ stdout += s;
7070
+ process.stdout.write(s);
7071
+ });
7072
+ child.stderr?.on("data", (chunk) => {
7073
+ const s = chunk.toString("utf-8");
7074
+ stderr += s;
7075
+ process.stderr.write(s);
6840
7076
  });
6841
- const stdout = r.stdout ?? "";
6842
- const stderr = r.stderr ?? "";
6843
- if (stdout) process.stdout.write(stdout);
6844
- if (stderr) process.stderr.write(stderr);
7077
+ let timedOut = false;
7078
+ let killTimer;
7079
+ let escalateTimer;
7080
+ const result = await new Promise(
7081
+ (resolve4) => {
7082
+ let settled = false;
7083
+ const settle = (code, signal, spawnErr) => {
7084
+ if (settled) return;
7085
+ settled = true;
7086
+ if (killTimer) clearTimeout(killTimer);
7087
+ if (escalateTimer) clearTimeout(escalateTimer);
7088
+ resolve4({ code, signal, spawnErr });
7089
+ };
7090
+ child.on("error", (err) => settle(null, null, err));
7091
+ child.on("close", (code, signal) => settle(code, signal));
7092
+ if (typeof child.pid === "number") {
7093
+ const pgid = child.pid;
7094
+ killTimer = setTimeout(() => {
7095
+ timedOut = true;
7096
+ try {
7097
+ process.kill(-pgid, "SIGTERM");
7098
+ } catch {
7099
+ }
7100
+ escalateTimer = setTimeout(() => {
7101
+ try {
7102
+ process.kill(-pgid, "SIGKILL");
7103
+ } catch {
7104
+ }
7105
+ }, SIGKILL_GRACE_MS);
7106
+ }, timeoutMs);
7107
+ }
7108
+ }
7109
+ );
7110
+ if (result.spawnErr) {
7111
+ ctx.skipAgent = true;
7112
+ ctx.output.exitCode = 99;
7113
+ ctx.output.reason = `shell '${shellName}' failed to spawn: ${result.spawnErr.message}`;
7114
+ return;
7115
+ }
6845
7116
  if (/^KODY_SKIP_AGENT=true\s*$/m.test(stdout)) {
6846
7117
  ctx.skipAgent = true;
6847
7118
  if (ctx.output.exitCode === void 0) ctx.output.exitCode = 0;
@@ -6850,7 +7121,6 @@ function runShellEntry(entry, ctx, profile) {
6850
7121
  if (prUrlMatch?.[1]) ctx.output.prUrl = prUrlMatch[1].trim();
6851
7122
  const reasonMatch = stdout.match(/^KODY_REASON=(.+)$/m);
6852
7123
  if (reasonMatch?.[1]) ctx.output.reason = reasonMatch[1].trim();
6853
- const timedOut = r.status === null && r.signal !== null;
6854
7124
  if (timedOut) {
6855
7125
  ctx.skipAgent = true;
6856
7126
  const seconds = Math.round(timeoutMs / 1e3);
@@ -6858,11 +7128,11 @@ function runShellEntry(entry, ctx, profile) {
6858
7128
  ctx.output.exitCode = 124;
6859
7129
  }
6860
7130
  if (!ctx.output.reason) {
6861
- ctx.output.reason = `shell '${shellName}' timed out after ${seconds}s (signal=${r.signal})`;
7131
+ ctx.output.reason = `shell '${shellName}' timed out after ${seconds}s (process group signalled SIGTERM/SIGKILL)`;
6862
7132
  }
6863
7133
  return;
6864
7134
  }
6865
- const exit = r.status ?? -1;
7135
+ const exit = result.code ?? -1;
6866
7136
  if (exit !== 0) {
6867
7137
  ctx.skipAgent = true;
6868
7138
  if (ctx.output.exitCode === void 0 || ctx.output.exitCode === 0) {
@@ -6916,7 +7186,7 @@ Environment:
6916
7186
  Exit codes (inherited from kody run):
6917
7187
  0 success (PR opened, verify passed)
6918
7188
  1 agent reported FAILED (draft PR opened)
6919
- 2 verify failed (draft PR opened)
7189
+ 2 verify failed (no PR opened \u2014 branch pushed for inspection)
6920
7190
  3 no commits to ship
6921
7191
  4 PR creation failed
6922
7192
  5 uncommitted changes on target branch
@@ -6978,9 +7248,9 @@ function resolveAuthToken(env = process.env) {
6978
7248
  return token;
6979
7249
  }
6980
7250
  function detectPackageManager2(cwd) {
6981
- if (fs25.existsSync(path22.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
6982
- if (fs25.existsSync(path22.join(cwd, "yarn.lock"))) return "yarn";
6983
- if (fs25.existsSync(path22.join(cwd, "bun.lockb"))) return "bun";
7251
+ if (fs26.existsSync(path23.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
7252
+ if (fs26.existsSync(path23.join(cwd, "yarn.lock"))) return "yarn";
7253
+ if (fs26.existsSync(path23.join(cwd, "bun.lockb"))) return "bun";
6984
7254
  return "npm";
6985
7255
  }
6986
7256
  function shellOut(cmd, args, cwd, stream = true) {
@@ -7060,11 +7330,11 @@ function configureGitIdentity(cwd) {
7060
7330
  }
7061
7331
  function postFailureTail(issueNumber, cwd, reason) {
7062
7332
  if (!issueNumber) return;
7063
- const logPath = path22.join(cwd, ".kody", "last-run.jsonl");
7333
+ const logPath = path23.join(cwd, ".kody", "last-run.jsonl");
7064
7334
  let tail = "";
7065
7335
  try {
7066
- if (fs25.existsSync(logPath)) {
7067
- const content = fs25.readFileSync(logPath, "utf-8");
7336
+ if (fs26.existsSync(logPath)) {
7337
+ const content = fs26.readFileSync(logPath, "utf-8");
7068
7338
  tail = content.slice(-3e3);
7069
7339
  }
7070
7340
  } catch {
@@ -7089,7 +7359,7 @@ async function runCi(argv) {
7089
7359
  return 0;
7090
7360
  }
7091
7361
  const args = parseCiArgs(argv);
7092
- const cwd = args.cwd ? path22.resolve(args.cwd) : process.cwd();
7362
+ const cwd = args.cwd ? path23.resolve(args.cwd) : process.cwd();
7093
7363
  let earlyConfig;
7094
7364
  try {
7095
7365
  earlyConfig = loadConfig(cwd);
@@ -7099,9 +7369,9 @@ async function runCi(argv) {
7099
7369
  const eventName = process.env.GITHUB_EVENT_NAME;
7100
7370
  const dispatchEventPath = process.env.GITHUB_EVENT_PATH;
7101
7371
  let manualWorkflowDispatch = false;
7102
- if (!args.issueNumber && !autoFallback && eventName === "workflow_dispatch" && dispatchEventPath && fs25.existsSync(dispatchEventPath)) {
7372
+ if (!args.issueNumber && !autoFallback && eventName === "workflow_dispatch" && dispatchEventPath && fs26.existsSync(dispatchEventPath)) {
7103
7373
  try {
7104
- const evt = JSON.parse(fs25.readFileSync(dispatchEventPath, "utf-8"));
7374
+ const evt = JSON.parse(fs26.readFileSync(dispatchEventPath, "utf-8"));
7105
7375
  const issueInput = parseInt(String(evt?.inputs?.issue_number ?? ""), 10);
7106
7376
  const sessionInput = String(evt?.inputs?.sessionId ?? "");
7107
7377
  manualWorkflowDispatch = !sessionInput && !(Number.isFinite(issueInput) && issueInput > 0);
@@ -7316,9 +7586,9 @@ function parseChatArgs(argv, env = process.env) {
7316
7586
  return result;
7317
7587
  }
7318
7588
  function commitChatFiles(cwd, sessionId, verbose) {
7319
- const sessionFile = path23.relative(cwd, sessionFilePath(cwd, sessionId));
7320
- const eventsFile = path23.relative(cwd, eventsFilePath(cwd, sessionId));
7321
- const paths = [sessionFile, eventsFile].filter((p) => fs26.existsSync(path23.join(cwd, p)));
7589
+ const sessionFile = path24.relative(cwd, sessionFilePath(cwd, sessionId));
7590
+ const eventsFile = path24.relative(cwd, eventsFilePath(cwd, sessionId));
7591
+ const paths = [sessionFile, eventsFile].filter((p) => fs27.existsSync(path24.join(cwd, p)));
7322
7592
  if (paths.length === 0) return;
7323
7593
  const opts = { cwd, stdio: verbose ? "inherit" : "pipe" };
7324
7594
  try {
@@ -7356,7 +7626,7 @@ async function runChat(argv) {
7356
7626
  ${CHAT_HELP}`);
7357
7627
  return 64;
7358
7628
  }
7359
- const cwd = args.cwd ? path23.resolve(args.cwd) : process.cwd();
7629
+ const cwd = args.cwd ? path24.resolve(args.cwd) : process.cwd();
7360
7630
  const sessionId = args.sessionId;
7361
7631
  const unpackedSecrets = unpackAllSecrets();
7362
7632
  if (unpackedSecrets > 0) {
@@ -7443,7 +7713,7 @@ directory to add a new command.
7443
7713
  Exit codes:
7444
7714
  0 success (PR opened, verify passed \u2014 or resolve produced a merge commit)
7445
7715
  1 agent reported FAILED (draft PR opened)
7446
- 2 verify failed (draft PR opened) \u2014 skipped in resolve mode
7716
+ 2 verify failed (no PR opened \u2014 branch pushed for inspection) \u2014 skipped in resolve mode
7447
7717
  3 no commits to ship (also the resolve clean-merge short-circuit)
7448
7718
  4 PR creation failed
7449
7719
  5 uncommitted changes on target branch