@kody-ade/kody-engine 0.4.17 → 0.4.18

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.4.17",
6
+ version: "0.4.18",
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",
@@ -3897,6 +3897,83 @@ function failedAction3(reason) {
3897
3897
  import * as fs19 from "fs";
3898
3898
  import * as path18 from "path";
3899
3899
 
3900
+ // src/scripts/jobFrontmatter.ts
3901
+ var SCHEDULE_EVERY_VALUES = [
3902
+ "15m",
3903
+ "30m",
3904
+ "1h",
3905
+ "2h",
3906
+ "6h",
3907
+ "12h",
3908
+ "1d",
3909
+ "3d",
3910
+ "7d",
3911
+ "manual"
3912
+ ];
3913
+ var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
3914
+ function splitFrontmatter(raw) {
3915
+ const match = FRONTMATTER_RE.exec(raw);
3916
+ if (!match) return { frontmatter: {}, body: raw };
3917
+ const inner = match[1] ?? "";
3918
+ const body = raw.slice(match[0].length);
3919
+ return { frontmatter: parseFlatYaml(inner), body };
3920
+ }
3921
+ function isScheduleEvery(value) {
3922
+ return typeof value === "string" && SCHEDULE_EVERY_VALUES.includes(value);
3923
+ }
3924
+ function scheduleEveryToMs(every) {
3925
+ const MIN = 60 * 1e3;
3926
+ const HOUR = 60 * MIN;
3927
+ const DAY = 24 * HOUR;
3928
+ switch (every) {
3929
+ case "15m":
3930
+ return 15 * MIN;
3931
+ case "30m":
3932
+ return 30 * MIN;
3933
+ case "1h":
3934
+ return HOUR;
3935
+ case "2h":
3936
+ return 2 * HOUR;
3937
+ case "6h":
3938
+ return 6 * HOUR;
3939
+ case "12h":
3940
+ return 12 * HOUR;
3941
+ case "1d":
3942
+ return DAY;
3943
+ case "3d":
3944
+ return 3 * DAY;
3945
+ case "7d":
3946
+ return 7 * DAY;
3947
+ case "manual":
3948
+ return Number.POSITIVE_INFINITY;
3949
+ }
3950
+ }
3951
+ function parseFlatYaml(text) {
3952
+ const out = {};
3953
+ for (const rawLine of text.split(/\r?\n/)) {
3954
+ const line = rawLine.trim();
3955
+ if (!line || line.startsWith("#")) continue;
3956
+ const colon = line.indexOf(":");
3957
+ if (colon < 0) continue;
3958
+ const key = line.slice(0, colon).trim();
3959
+ const value = stripQuotes(line.slice(colon + 1).trim());
3960
+ if (key === "every" && isScheduleEvery(value)) {
3961
+ out.every = value;
3962
+ }
3963
+ }
3964
+ return out;
3965
+ }
3966
+ function stripQuotes(value) {
3967
+ if (value.length >= 2) {
3968
+ const first = value[0];
3969
+ const last = value[value.length - 1];
3970
+ if (first === '"' && last === '"' || first === "'" && last === "'") {
3971
+ return value.slice(1, -1);
3972
+ }
3973
+ }
3974
+ return value;
3975
+ }
3976
+
3900
3977
  // src/scripts/issueStateComment.ts
3901
3978
  function isStateEnvelope(x) {
3902
3979
  if (x === null || typeof x !== "object") return false;
@@ -4254,7 +4331,15 @@ var dispatchJobFileTicks = async (ctx, _profile, args) => {
4254
4331
  process.stdout.write(`[jobs] ticking ${slugs.length} job(s) via ${targetExecutable}
4255
4332
  `);
4256
4333
  const results = [];
4334
+ const now = Date.now();
4257
4335
  for (const slug of slugs) {
4336
+ const decision = await decideShouldFire(ctx.cwd, jobsDir, slug, backend, now);
4337
+ if (decision.skip) {
4338
+ process.stdout.write(`[jobs] \u23ED skip ${slug}: ${decision.reason}
4339
+ `);
4340
+ results.push({ slug, exitCode: 0, skipped: true, reason: decision.reason });
4341
+ continue;
4342
+ }
4258
4343
  process.stdout.write(`[jobs] \u2192 tick ${slug}
4259
4344
  `);
4260
4345
  try {
@@ -4291,6 +4376,53 @@ var dispatchJobFileTicks = async (ctx, _profile, args) => {
4291
4376
  }
4292
4377
  }
4293
4378
  };
4379
+ async function decideShouldFire(cwd, jobsDir, slug, backend, now) {
4380
+ let every;
4381
+ try {
4382
+ const raw = fs19.readFileSync(path18.join(cwd, jobsDir, `${slug}.md`), "utf-8");
4383
+ every = splitFrontmatter(raw).frontmatter.every;
4384
+ } catch {
4385
+ return { skip: false, reason: "frontmatter unreadable" };
4386
+ }
4387
+ if (!every) return { skip: false, reason: "no schedule (every cron tick)" };
4388
+ if (every === "manual") {
4389
+ return { skip: true, reason: "manual-only (no auto-fire; trigger via dashboard Run now)" };
4390
+ }
4391
+ let lastFiredAt = null;
4392
+ try {
4393
+ const loaded = await backend.load(slug);
4394
+ const raw = loaded.state.data?.lastFiredAt;
4395
+ if (typeof raw === "string") {
4396
+ const ms = Date.parse(raw);
4397
+ if (!Number.isNaN(ms)) lastFiredAt = ms;
4398
+ }
4399
+ } catch {
4400
+ return { skip: false, reason: "state unreadable; firing" };
4401
+ }
4402
+ if (lastFiredAt === null) {
4403
+ return { skip: false, reason: `first tick (every ${every})` };
4404
+ }
4405
+ const intervalMs = scheduleEveryToMs(every);
4406
+ const elapsedMs = now - lastFiredAt;
4407
+ if (elapsedMs >= intervalMs) {
4408
+ return { skip: false, reason: `due (every ${every}, last ${formatAgo(elapsedMs)} ago)` };
4409
+ }
4410
+ const remainingMs = intervalMs - elapsedMs;
4411
+ return {
4412
+ skip: true,
4413
+ reason: `every ${every}; ${formatAgo(elapsedMs)} since last tick, next in ${formatAgo(remainingMs)}`
4414
+ };
4415
+ }
4416
+ function formatAgo(ms) {
4417
+ const sec = Math.max(0, Math.round(ms / 1e3));
4418
+ if (sec < 60) return `${sec}s`;
4419
+ const min = Math.round(sec / 60);
4420
+ if (min < 60) return `${min}m`;
4421
+ const hr = Math.round(min / 60);
4422
+ if (hr < 48) return `${hr}h`;
4423
+ const day = Math.round(hr / 24);
4424
+ return `${day}d`;
4425
+ }
4294
4426
  function listJobSlugs(absDir) {
4295
4427
  if (!fs19.existsSync(absDir)) return [];
4296
4428
  let entries;
@@ -8123,9 +8255,16 @@ var writeJobStateFile = async (ctx, _profile, _agentResult, args) => {
8123
8255
  if (!loaded) {
8124
8256
  throw new Error("writeJobStateFile: ctx.data.jobState missing \u2014 preflight must run first");
8125
8257
  }
8258
+ const stamped = {
8259
+ ...next,
8260
+ data: {
8261
+ ...next.data,
8262
+ lastFiredAt: (/* @__PURE__ */ new Date()).toISOString()
8263
+ }
8264
+ };
8126
8265
  const jobsDir = String(args?.jobsDir ?? ".kody/jobs");
8127
8266
  const backend = resolveBackend({ config: ctx.config, cwd: ctx.cwd, jobsDir });
8128
- await backend.save(loaded, next);
8267
+ await backend.save(loaded, stamped);
8129
8268
  };
8130
8269
 
8131
8270
  // src/scripts/writeRunSummary.ts
File without changes
@@ -124,10 +124,19 @@ PY
124
124
  }
125
125
 
126
126
  ensure_goal_issue() {
127
- # Create the umbrella goal issue (once), label it goal:<id> + kody:building,
128
- # and persist its number on state.json. Idempotent: if state already has
129
- # goalIssueNumber, this is a no-op. The issue auto-closes when the final
130
- # goal PR merges, via the `Closes #N` line we add to that PR body.
127
+ # Create-or-adopt the umbrella goal issue (once), label it goal:<id> +
128
+ # kody:building, and persist its number on state.json. The issue auto-closes
129
+ # when the final goal PR merges, via the `Closes #N` line we add to that PR
130
+ # body.
131
+ #
132
+ # Lookup order:
133
+ # 1. state.json `goalIssueNumber` — fast path.
134
+ # 2. Search GitHub for an existing umbrella by label `goal:<id>` + the
135
+ # canonical title `goal: <goal_id>`. This is the recovery path when
136
+ # state.json got wiped (e.g. dashboard pause/resume dropped the field
137
+ # in older versions). Without this lookup we'd open a duplicate
138
+ # umbrella every time goalIssueNumber goes missing.
139
+ # 3. Create a fresh umbrella as a last resort.
131
140
  local existing
132
141
  existing=$(read_state_field "goalIssueNumber")
133
142
  if [ -n "$existing" ] && [ "$existing" != "0" ]; then
@@ -142,20 +151,34 @@ ensure_goal_issue() {
142
151
  body=$(printf "Umbrella issue for goal **%s**.\n\nClosed automatically when the goal PR (\`%s\` → \`%s\`) merges.\n" \
143
152
  "$goal_id" "$goal_branch" "$default_branch")
144
153
 
145
- # `gh issue create` prints the new issue's URL on stdout
146
- # (https://github.com/<owner>/<repo>/issues/<n>). It does NOT support
147
- # --json/--jq, so parse the trailing number off the URL.
148
- local url
149
- url=$(gh issue create \
150
- --title "$title" \
151
- --body "$body" \
152
- --label "$label" \
153
- --label "kody:building" 2>/dev/null || echo "")
154
-
155
- num="${url##*/}"
156
- if [ -z "$num" ] || ! [[ "$num" =~ ^[0-9]+$ ]]; then
157
- echo "[goal-tick] ensure_goal_issue: gh issue create failed (got '${url}') continuing without umbrella issue"
158
- return 0
154
+ # Recovery path: an umbrella may already exist from a prior run that lost
155
+ # state. Match strictly by label + exact title to avoid grabbing a child
156
+ # task issue. Prefer OPEN issues; fall back to closed ones (the umbrella
157
+ # could have been closed by a prior goal PR merge that we're now re-driving).
158
+ num=$(gh api \
159
+ "repos/{owner}/{repo}/issues?labels=${label}&state=all&per_page=100" \
160
+ --jq "[.[] | select(.pull_request == null) | select(.title == \"${title}\")] | (map(select(.state == \"open\")) + map(select(.state != \"open\")))[0].number // empty" \
161
+ 2>/dev/null || echo "")
162
+
163
+ if [ -n "$num" ] && [[ "$num" =~ ^[0-9]+$ ]]; then
164
+ echo "[goal-tick] adopted existing umbrella issue #${num} for ${goal_id}"
165
+ else
166
+ # `gh issue create` prints the new issue's URL on stdout
167
+ # (https://github.com/<owner>/<repo>/issues/<n>). It does NOT support
168
+ # --json/--jq, so parse the trailing number off the URL.
169
+ local url
170
+ url=$(gh issue create \
171
+ --title "$title" \
172
+ --body "$body" \
173
+ --label "$label" \
174
+ --label "kody:building" 2>/dev/null || echo "")
175
+
176
+ num="${url##*/}"
177
+ if [ -z "$num" ] || ! [[ "$num" =~ ^[0-9]+$ ]]; then
178
+ echo "[goal-tick] ensure_goal_issue: gh issue create failed (got '${url}') — continuing without umbrella issue"
179
+ return 0
180
+ fi
181
+ echo "[goal-tick] opened umbrella issue #${num} for ${goal_id}"
159
182
  fi
160
183
 
161
184
  python3 - "$state_file" "$num" <<'PY'
@@ -171,7 +194,6 @@ with open(path, "w") as f:
171
194
  json.dump(s, f, indent=2)
172
195
  f.write("\n")
173
196
  PY
174
- echo "[goal-tick] opened umbrella issue #${num} for ${goal_id}"
175
197
  }
176
198
 
177
199
  list_goal_issues() {
@@ -251,6 +273,66 @@ ensure_label "$failed_label" "b60205" "kody goal-runner: task failed; needs huma
251
273
  # counting child tasks, so list_goal_issues can filter it out cleanly.
252
274
  ensure_goal_issue
253
275
 
276
+ # Merge ready goal-task PRs into the goal branch. We own the merge here
277
+ # instead of relying on GitHub's `--auto` flag (which requires the repo's
278
+ # "Allow auto-merge" setting and silently no-ops when disabled). Only merge
279
+ # non-draft PRs with mergeable=MERGEABLE and mergeStateStatus=CLEAN — i.e.
280
+ # all required checks passed and there are no conflicts. Anything else
281
+ # (BLOCKED, DIRTY, BEHIND, UNSTABLE, draft) is left for the operator.
282
+ open_prs=$(gh pr list --base "$goal_branch" --state open --limit 50 \
283
+ --json number,isDraft,mergeable,mergeStateStatus 2>/dev/null || echo "[]")
284
+ echo "$open_prs" | python3 -c "
285
+ import json, sys
286
+ data = json.load(sys.stdin)
287
+ for pr in data:
288
+ if pr.get('isDraft'): continue
289
+ if pr.get('mergeable') != 'MERGEABLE': continue
290
+ if pr.get('mergeStateStatus') != 'CLEAN': continue
291
+ print(pr['number'])
292
+ " | while read -r pr_num; do
293
+ [ -n "$pr_num" ] || continue
294
+ echo "[goal-tick] merging PR #${pr_num} into ${goal_branch}"
295
+ if ! gh pr merge "$pr_num" --squash --delete-branch >/dev/null 2>&1; then
296
+ echo "[goal-tick] failed to merge PR #${pr_num} (continuing)"
297
+ fi
298
+ done
299
+
300
+ # Close dispatched task issues whose PR has merged into the goal branch.
301
+ # `Closes #N` in the PR body only auto-closes the issue when the PR merges
302
+ # into the default branch — goal-task PRs target the goal branch, so we must
303
+ # close the issues explicitly. Without this, in_flight stays > 0 forever and
304
+ # the goal stalls after task 1. We accept the linkage from either:
305
+ # - `Closes|Fixes|Resolves #N` in the PR body (authoritative), OR
306
+ # - leading number on the head ref (kody convention: `<issue>-<slug>`).
307
+ merged_prs=$(gh pr list --base "$goal_branch" --state merged --limit 50 --json number,headRefName,body 2>/dev/null || echo "[]")
308
+ echo "$merged_prs" | python3 -c "
309
+ import json, re, sys
310
+ data = json.load(sys.stdin)
311
+ seen = set()
312
+ for pr in data:
313
+ n = None
314
+ body = pr.get('body') or ''
315
+ m = re.search(r'(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b', body)
316
+ if m:
317
+ n = int(m.group(1))
318
+ else:
319
+ bm = re.match(r'^(\d+)-', pr.get('headRefName') or '')
320
+ if bm:
321
+ n = int(bm.group(1))
322
+ if n and n not in seen:
323
+ seen.add(n)
324
+ print(n)
325
+ " | while read -r issue_num; do
326
+ [ -n "$issue_num" ] || continue
327
+ state=$(gh issue view "$issue_num" --json state --jq .state 2>/dev/null || echo "")
328
+ if [ "$state" = "OPEN" ]; then
329
+ echo "[goal-tick] closing #${issue_num} (PR merged into ${goal_branch})"
330
+ gh issue close "$issue_num" \
331
+ --comment "_Closed by goal-tick: PR for this task merged into \`${goal_branch}\`._" \
332
+ >/dev/null 2>&1 || echo "[goal-tick] failed to close #${issue_num} (continuing)"
333
+ fi
334
+ done
335
+
254
336
  issues_json=$(list_goal_issues)
255
337
  total=$(echo "$issues_json" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
256
338
  if [ "$total" = "0" ]; then
@@ -10,6 +10,12 @@
10
10
  "type": "string",
11
11
  "required": true,
12
12
  "describe": "Job slug — basename (without .md) of the file under .kody/jobs/."
13
+ },
14
+ {
15
+ "name": "force",
16
+ "flag": "--force",
17
+ "type": "bool",
18
+ "describe": "When true, the agent ignores the job body's cadence guard and executes the work this tick. All other body rules (allowed commands, restrictions, state schema) still apply. Used for manual triggers from the dashboard's 'Run now' button."
13
19
  }
14
20
  ],
15
21
  "claudeCode": {
@@ -20,6 +20,8 @@ This is the state you wrote at the end of the previous tick (or `null` if this i
20
20
 
21
21
  ## What to do on this tick
22
22
 
23
+ `forceRun = {{args.force}}` — set to `true` when an operator clicked "Run now" on the dashboard. When `forceRun` is `true`, ignore the job body's `**Cadence guard.**` paragraph (or any equivalent "skip if last run was within X" rule) and execute the work as if the guard had passed. All other body rules — allowed commands, restrictions, state schema — still apply. Force only overrides cadence.
24
+
23
25
  1. **Check `done`.** If the prior state has `done: true`, emit the same state back unchanged and exit without any action.
24
26
  2. **Re-read the job body.** It may have changed since the last tick.
25
27
  3. **Execute exactly the work the body's `## Job` section describes**, subject to its `## Allowed Commands` and `## Restrictions`. Use the `## State` section to interpret and update `data`.
File without changes
File without changes
File without changes
File without changes
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
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",
@@ -12,18 +12,6 @@
12
12
  "templates",
13
13
  "kody.config.schema.json"
14
14
  ],
15
- "scripts": {
16
- "kody": "tsx bin/kody.ts",
17
- "build": "tsup && node scripts/copy-assets.cjs",
18
- "test": "vitest run tests/unit tests/int --no-coverage",
19
- "test:e2e": "vitest run tests/e2e --no-coverage",
20
- "test:all": "vitest run tests --no-coverage",
21
- "typecheck": "tsc --noEmit",
22
- "lint": "biome check",
23
- "lint:fix": "biome check --write",
24
- "format": "biome format --write",
25
- "prepublishOnly": "pnpm build"
26
- },
27
15
  "dependencies": {
28
16
  "@actions/cache": "^6.0.0",
29
17
  "@anthropic-ai/claude-agent-sdk": "0.2.119"
@@ -44,5 +32,16 @@
44
32
  "url": "git+https://github.com/aharonyaircohen/kody-engine.git"
45
33
  },
46
34
  "homepage": "https://github.com/aharonyaircohen/kody-engine",
47
- "bugs": "https://github.com/aharonyaircohen/kody-engine/issues"
48
- }
35
+ "bugs": "https://github.com/aharonyaircohen/kody-engine/issues",
36
+ "scripts": {
37
+ "kody": "tsx bin/kody.ts",
38
+ "build": "tsup && node scripts/copy-assets.cjs",
39
+ "test": "vitest run tests/unit tests/int --no-coverage",
40
+ "test:e2e": "vitest run tests/e2e --no-coverage",
41
+ "test:all": "vitest run tests --no-coverage",
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "biome check",
44
+ "lint:fix": "biome check --write",
45
+ "format": "biome format --write"
46
+ }
47
+ }