@kody-ade/kody-engine 0.3.58 → 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.58",
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";
@@ -960,8 +975,8 @@ function coerceBare(spec, value) {
960
975
 
961
976
  // src/executor.ts
962
977
  import { spawn as spawn3 } from "child_process";
963
- import * as fs24 from "fs";
964
- import * as path21 from "path";
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({
@@ -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
  }
@@ -6815,8 +7039,8 @@ function resolveShellTimeoutMs(entry) {
6815
7039
  var SIGKILL_GRACE_MS = 5e3;
6816
7040
  async function runShellEntry(entry, ctx, profile) {
6817
7041
  const shellName = entry.shell;
6818
- const shellPath = path21.join(profile.dir, shellName);
6819
- if (!fs24.existsSync(shellPath)) {
7042
+ const shellPath = path22.join(profile.dir, shellName);
7043
+ if (!fs25.existsSync(shellPath)) {
6820
7044
  ctx.skipAgent = true;
6821
7045
  ctx.output.exitCode = 99;
6822
7046
  ctx.output.reason = `shell script not found: ${shellName} (looked in ${profile.dir})`;
@@ -6962,7 +7186,7 @@ Environment:
6962
7186
  Exit codes (inherited from kody run):
6963
7187
  0 success (PR opened, verify passed)
6964
7188
  1 agent reported FAILED (draft PR opened)
6965
- 2 verify failed (draft PR opened)
7189
+ 2 verify failed (no PR opened \u2014 branch pushed for inspection)
6966
7190
  3 no commits to ship
6967
7191
  4 PR creation failed
6968
7192
  5 uncommitted changes on target branch
@@ -7024,9 +7248,9 @@ function resolveAuthToken(env = process.env) {
7024
7248
  return token;
7025
7249
  }
7026
7250
  function detectPackageManager2(cwd) {
7027
- if (fs25.existsSync(path22.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
7028
- if (fs25.existsSync(path22.join(cwd, "yarn.lock"))) return "yarn";
7029
- 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";
7030
7254
  return "npm";
7031
7255
  }
7032
7256
  function shellOut(cmd, args, cwd, stream = true) {
@@ -7106,11 +7330,11 @@ function configureGitIdentity(cwd) {
7106
7330
  }
7107
7331
  function postFailureTail(issueNumber, cwd, reason) {
7108
7332
  if (!issueNumber) return;
7109
- const logPath = path22.join(cwd, ".kody", "last-run.jsonl");
7333
+ const logPath = path23.join(cwd, ".kody", "last-run.jsonl");
7110
7334
  let tail = "";
7111
7335
  try {
7112
- if (fs25.existsSync(logPath)) {
7113
- const content = fs25.readFileSync(logPath, "utf-8");
7336
+ if (fs26.existsSync(logPath)) {
7337
+ const content = fs26.readFileSync(logPath, "utf-8");
7114
7338
  tail = content.slice(-3e3);
7115
7339
  }
7116
7340
  } catch {
@@ -7135,7 +7359,7 @@ async function runCi(argv) {
7135
7359
  return 0;
7136
7360
  }
7137
7361
  const args = parseCiArgs(argv);
7138
- const cwd = args.cwd ? path22.resolve(args.cwd) : process.cwd();
7362
+ const cwd = args.cwd ? path23.resolve(args.cwd) : process.cwd();
7139
7363
  let earlyConfig;
7140
7364
  try {
7141
7365
  earlyConfig = loadConfig(cwd);
@@ -7145,9 +7369,9 @@ async function runCi(argv) {
7145
7369
  const eventName = process.env.GITHUB_EVENT_NAME;
7146
7370
  const dispatchEventPath = process.env.GITHUB_EVENT_PATH;
7147
7371
  let manualWorkflowDispatch = false;
7148
- if (!args.issueNumber && !autoFallback && eventName === "workflow_dispatch" && dispatchEventPath && fs25.existsSync(dispatchEventPath)) {
7372
+ if (!args.issueNumber && !autoFallback && eventName === "workflow_dispatch" && dispatchEventPath && fs26.existsSync(dispatchEventPath)) {
7149
7373
  try {
7150
- const evt = JSON.parse(fs25.readFileSync(dispatchEventPath, "utf-8"));
7374
+ const evt = JSON.parse(fs26.readFileSync(dispatchEventPath, "utf-8"));
7151
7375
  const issueInput = parseInt(String(evt?.inputs?.issue_number ?? ""), 10);
7152
7376
  const sessionInput = String(evt?.inputs?.sessionId ?? "");
7153
7377
  manualWorkflowDispatch = !sessionInput && !(Number.isFinite(issueInput) && issueInput > 0);
@@ -7362,9 +7586,9 @@ function parseChatArgs(argv, env = process.env) {
7362
7586
  return result;
7363
7587
  }
7364
7588
  function commitChatFiles(cwd, sessionId, verbose) {
7365
- const sessionFile = path23.relative(cwd, sessionFilePath(cwd, sessionId));
7366
- const eventsFile = path23.relative(cwd, eventsFilePath(cwd, sessionId));
7367
- 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)));
7368
7592
  if (paths.length === 0) return;
7369
7593
  const opts = { cwd, stdio: verbose ? "inherit" : "pipe" };
7370
7594
  try {
@@ -7402,7 +7626,7 @@ async function runChat(argv) {
7402
7626
  ${CHAT_HELP}`);
7403
7627
  return 64;
7404
7628
  }
7405
- const cwd = args.cwd ? path23.resolve(args.cwd) : process.cwd();
7629
+ const cwd = args.cwd ? path24.resolve(args.cwd) : process.cwd();
7406
7630
  const sessionId = args.sessionId;
7407
7631
  const unpackedSecrets = unpackAllSecrets();
7408
7632
  if (unpackedSecrets > 0) {
@@ -7489,7 +7713,7 @@ directory to add a new command.
7489
7713
  Exit codes:
7490
7714
  0 success (PR opened, verify passed \u2014 or resolve produced a merge commit)
7491
7715
  1 agent reported FAILED (draft PR opened)
7492
- 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
7493
7717
  3 no commits to ship (also the resolve clean-merge short-circuit)
7494
7718
  4 PR creation failed
7495
7719
  5 uncommitted changes on target branch
@@ -58,7 +58,7 @@
58
58
  { "script": "checkCoverageWithRetry" },
59
59
  { "script": "abortUnfinishedGitOps" },
60
60
  { "script": "commitAndPush" },
61
- { "script": "ensurePr" },
61
+ { "script": "ensurePr", "runWhen": { "data.verifyOk": true } },
62
62
  { "script": "postIssueComment" },
63
63
  { "script": "writeRunSummary" },
64
64
  { "script": "saveTaskState" },
@@ -383,6 +383,19 @@
383
383
  },
384
384
  "additionalProperties": false
385
385
  },
386
+ "missions": {
387
+ "type": "object",
388
+ "description": "File-based mission configuration. Missions are long-running scheduled executables defined under .kody/missions/<slug>.md.",
389
+ "properties": {
390
+ "stateBackend": {
391
+ "type": "string",
392
+ "enum": ["contents-api", "local-file"],
393
+ "description": "Storage backend for mission state. \"contents-api\" (default) commits state to a tracked file via the GitHub Contents API — durable across runs but creates a commit per change. \"local-file\" stores state on disk and snapshots it to the GitHub Actions cache between workflow runs — no commit churn, but bound to cache eviction (7-day idle).",
394
+ "default": "contents-api"
395
+ }
396
+ },
397
+ "additionalProperties": false
398
+ },
386
399
  "devServer": {
387
400
  "type": "object",
388
401
  "description": "Dev server configuration for browser tool verification. Works with any provider (MCP or CLI-based).",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.3.58",
3
+ "version": "0.3.60",
4
4
  "description": "kody — autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "kody.config.schema.json"
14
14
  ],
15
15
  "dependencies": {
16
+ "@actions/cache": "^6.0.0",
16
17
  "@anthropic-ai/claude-agent-sdk": "0.2.119"
17
18
  },
18
19
  "devDependencies": {