@kody-ade/kody-engine 0.3.69 → 0.3.70-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/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.69",
6
+ version: "0.3.70-beta.2",
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",
@@ -1122,7 +1122,8 @@ import * as fs8 from "fs";
1122
1122
  import * as path7 from "path";
1123
1123
  var VALID_INPUT_TYPES = /* @__PURE__ */ new Set(["int", "string", "bool", "enum"]);
1124
1124
  var VALID_PERMISSION_MODES = /* @__PURE__ */ new Set(["default", "acceptEdits", "plan", "bypassPermissions"]);
1125
- var VALID_ROLES = /* @__PURE__ */ new Set(["primitive", "orchestrator", "watch", "utility"]);
1125
+ var VALID_ROLES = /* @__PURE__ */ new Set(["primitive", "orchestrator", "container", "watch", "utility"]);
1126
+ var VALID_CONTAINER_CHILD_TARGETS = /* @__PURE__ */ new Set(["issue", "pr"]);
1126
1127
  var VALID_PHASES = /* @__PURE__ */ new Set(["research", "planning", "implementing", "reviewing", "shipped", "failed", "idle"]);
1127
1128
  var ProfileError = class extends Error {
1128
1129
  constructor(profilePath, message) {
@@ -1162,6 +1163,7 @@ function loadProfile(profilePath) {
1162
1163
  }
1163
1164
  phase = r.phase;
1164
1165
  }
1166
+ const children = parseContainerChildren(profilePath, role, r.children);
1165
1167
  const profile = {
1166
1168
  name: requireString(profilePath, r, "name"),
1167
1169
  describe: typeof r.describe === "string" ? r.describe : "",
@@ -1176,6 +1178,7 @@ function loadProfile(profilePath) {
1176
1178
  outputContract: r.outputContract,
1177
1179
  inputArtifacts: parseInputArtifacts(profilePath, r.input),
1178
1180
  outputArtifacts: parseOutputArtifacts(profilePath, r.output),
1181
+ children,
1179
1182
  dir: path7.dirname(profilePath)
1180
1183
  };
1181
1184
  return profile;
@@ -1336,6 +1339,45 @@ function parseOutputArtifacts(p, raw) {
1336
1339
  }
1337
1340
  return out;
1338
1341
  }
1342
+ function parseContainerChildren(p, role, raw) {
1343
+ const isContainer = role === "container";
1344
+ const present = raw !== void 0 && raw !== null;
1345
+ if (!isContainer) {
1346
+ if (!present) return void 0;
1347
+ if (Array.isArray(raw) && raw.length === 0) return void 0;
1348
+ throw new ProfileError(p, `"children" is only allowed when role === "container"`);
1349
+ }
1350
+ if (!present) {
1351
+ throw new ProfileError(p, `role: "container" requires a non-empty "children" array`);
1352
+ }
1353
+ if (!Array.isArray(raw) || raw.length === 0) {
1354
+ throw new ProfileError(p, `role: "container" requires a non-empty "children" array`);
1355
+ }
1356
+ const out = [];
1357
+ for (const [i, item] of raw.entries()) {
1358
+ if (!item || typeof item !== "object") {
1359
+ throw new ProfileError(p, `children[${i}] must be an object { exec, target, next }`);
1360
+ }
1361
+ const r = item;
1362
+ const exec = requireString(p, r, "exec");
1363
+ const target = requireString(p, r, "target");
1364
+ if (!VALID_CONTAINER_CHILD_TARGETS.has(target)) {
1365
+ throw new ProfileError(p, `children[${i}].target must be "issue" or "pr"`);
1366
+ }
1367
+ if (!r.next || typeof r.next !== "object" || Array.isArray(r.next)) {
1368
+ throw new ProfileError(p, `children[${i}].next must be an object mapping action types to next step`);
1369
+ }
1370
+ const next = {};
1371
+ for (const [k, v] of Object.entries(r.next)) {
1372
+ if (typeof v !== "string" || v.length === 0) {
1373
+ throw new ProfileError(p, `children[${i}].next["${k}"] must be a non-empty string`);
1374
+ }
1375
+ next[k] = v;
1376
+ }
1377
+ out.push({ exec, target, next });
1378
+ }
1379
+ return out;
1380
+ }
1339
1381
  function parseScriptList(p, key, raw) {
1340
1382
  if (!Array.isArray(raw)) {
1341
1383
  throw new ProfileError(p, `scripts.${key} must be an array`);
@@ -6878,6 +6920,7 @@ function runShell(cmd, cwd, timeoutMs = 3e4) {
6878
6920
  }
6879
6921
 
6880
6922
  // src/executor.ts
6923
+ var CONTAINER_MAX_ITERATIONS = 50;
6881
6924
  async function runExecutable(profileName, input) {
6882
6925
  const profilePath = resolveProfilePath(profileName);
6883
6926
  const profile = loadProfile(profilePath);
@@ -6977,7 +7020,10 @@ async function runExecutable(profileName, input) {
6977
7020
  }
6978
7021
  }
6979
7022
  let agentResult = null;
6980
- if (!ctx.skipAgent) {
7023
+ if (profile.role === "container") {
7024
+ ctx.skipAgent = true;
7025
+ await runContainerLoop(profile, ctx, input);
7026
+ } else if (!ctx.skipAgent) {
6981
7027
  const prompt = ctx.data.prompt;
6982
7028
  if (!prompt) {
6983
7029
  return finish({ exitCode: 99, reason: "composePrompt did not produce a prompt (ctx.data.prompt missing)" });
@@ -7254,6 +7300,157 @@ async function runShellEntry(entry, ctx, profile) {
7254
7300
  function envKey(name) {
7255
7301
  return name.toUpperCase().replace(/-/g, "_");
7256
7302
  }
7303
+ async function runContainerLoop(profile, ctx, input) {
7304
+ const children = profile.children;
7305
+ if (!children || children.length === 0) {
7306
+ process.stderr.write(`[kody container] profile "${profile.name}" has no children \u2014 nothing to run
7307
+ `);
7308
+ ctx.output.exitCode = 0;
7309
+ ctx.output.reason = "container has no children";
7310
+ return;
7311
+ }
7312
+ const runChild = input.__runChild ?? ((name, opts) => runExecutable(name, opts));
7313
+ const reader = input.__readTaskState ?? readTaskState;
7314
+ const issueNumber = ctx.args.issue;
7315
+ let currentIdx = 0;
7316
+ let iteration = 0;
7317
+ while (currentIdx >= 0 && currentIdx < children.length) {
7318
+ iteration++;
7319
+ if (iteration > CONTAINER_MAX_ITERATIONS) {
7320
+ const reason = `container exceeded ${CONTAINER_MAX_ITERATIONS} iterations \u2014 possible routing loop`;
7321
+ process.stderr.write(`[kody container] aborting: ${reason}
7322
+ `);
7323
+ ctx.output.exitCode = 1;
7324
+ ctx.output.reason = reason;
7325
+ return;
7326
+ }
7327
+ const child = children[currentIdx];
7328
+ process.stderr.write(`[kody container] step ${iteration}: invoking ${child.exec}
7329
+ `);
7330
+ const priorState = readContainerState(ctx, child, reader);
7331
+ const priorAction = priorState.executables?.[child.exec]?.lastAction;
7332
+ let actionType;
7333
+ if (priorAction && /_COMPLETED$/i.test(priorAction.type)) {
7334
+ process.stderr.write(`[kody container] skipping ${child.exec}: already completed (${priorAction.type})
7335
+ `);
7336
+ actionType = priorAction.type;
7337
+ } else {
7338
+ let cliArgs;
7339
+ if (child.target === "pr") {
7340
+ const prUrl = priorState.core?.prUrl;
7341
+ const prNumber = prUrl ? parsePrNumber4(prUrl) : null;
7342
+ if (!prNumber) {
7343
+ const reason = `container child "${child.exec}" needs --pr but state.core.prUrl is unset`;
7344
+ process.stderr.write(`[kody container] aborting: ${reason}
7345
+ `);
7346
+ ctx.output.exitCode = 1;
7347
+ ctx.output.reason = reason;
7348
+ const action = {
7349
+ type: "AGENT_NOT_RUN",
7350
+ payload: { reason, dispatchTarget: "pr", child: child.exec },
7351
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
7352
+ };
7353
+ ctx.data.action = action;
7354
+ return;
7355
+ }
7356
+ cliArgs = { pr: prNumber };
7357
+ } else {
7358
+ if (issueNumber === void 0) {
7359
+ const reason = `container child "${child.exec}" needs --issue but ctx.args.issue is unset`;
7360
+ process.stderr.write(`[kody container] aborting: ${reason}
7361
+ `);
7362
+ ctx.output.exitCode = 1;
7363
+ ctx.output.reason = reason;
7364
+ return;
7365
+ }
7366
+ cliArgs = { issue: issueNumber };
7367
+ }
7368
+ let childOut;
7369
+ try {
7370
+ childOut = await runChild(child.exec, {
7371
+ cliArgs,
7372
+ cwd: input.cwd,
7373
+ config: input.config,
7374
+ skipConfig: input.skipConfig,
7375
+ verbose: input.verbose,
7376
+ quiet: input.quiet
7377
+ });
7378
+ } catch (err) {
7379
+ const msg = err instanceof Error ? err.message : String(err);
7380
+ process.stderr.write(`[kody container] child "${child.exec}" crashed: ${msg}
7381
+ `);
7382
+ ctx.output.exitCode = 1;
7383
+ ctx.output.reason = `child "${child.exec}" crashed: ${msg}`;
7384
+ return;
7385
+ }
7386
+ const next = readContainerState(ctx, child, reader);
7387
+ ctx.data.taskState = next;
7388
+ const actionFromState = next.core?.lastOutcome?.type;
7389
+ actionType = actionFromState ?? (childOut.exitCode === 0 ? "RUN_COMPLETED" : "RUN_FAILED");
7390
+ }
7391
+ const route = child.next[actionType] ?? child.next["*"];
7392
+ if (!route) {
7393
+ const reason = `no route for action "${actionType}" from child "${child.exec}"`;
7394
+ process.stderr.write(`[kody container] aborting: ${reason}
7395
+ `);
7396
+ ctx.output.exitCode = 1;
7397
+ ctx.output.reason = reason;
7398
+ return;
7399
+ }
7400
+ process.stderr.write(`[kody container] outcome ${actionType}: dispatching to ${route}
7401
+ `);
7402
+ if (route === "done") {
7403
+ ctx.output.exitCode = 0;
7404
+ return;
7405
+ }
7406
+ if (route === "abort") {
7407
+ ctx.output.exitCode = 1;
7408
+ ctx.output.reason = `container aborted by route from "${child.exec}" on ${actionType}`;
7409
+ return;
7410
+ }
7411
+ const nextIdx = children.findIndex((c) => c.exec === route);
7412
+ if (nextIdx < 0) {
7413
+ const reason = `container route "${route}" does not match any declared child exec name`;
7414
+ process.stderr.write(`[kody container] aborting: ${reason}
7415
+ `);
7416
+ ctx.output.exitCode = 1;
7417
+ ctx.output.reason = reason;
7418
+ return;
7419
+ }
7420
+ currentIdx = nextIdx;
7421
+ }
7422
+ }
7423
+ function readContainerState(ctx, _child, reader) {
7424
+ const issueNumber = ctx.args.issue;
7425
+ if (issueNumber !== void 0) {
7426
+ try {
7427
+ return reader("issue", issueNumber, ctx.cwd);
7428
+ } catch {
7429
+ }
7430
+ }
7431
+ if (ctx.data.taskState && typeof ctx.data.taskState === "object") {
7432
+ return ctx.data.taskState;
7433
+ }
7434
+ return {
7435
+ schemaVersion: 1,
7436
+ core: {
7437
+ phase: "idle",
7438
+ status: "pending",
7439
+ currentExecutable: null,
7440
+ lastOutcome: null,
7441
+ attempts: {}
7442
+ },
7443
+ executables: {},
7444
+ artifacts: {},
7445
+ history: []
7446
+ };
7447
+ }
7448
+ function parsePrNumber4(url) {
7449
+ const m = url.match(/\/pull\/(\d+)(?:[/?#]|$)/);
7450
+ if (!m) return null;
7451
+ const n = parseInt(m[1], 10);
7452
+ return Number.isFinite(n) ? n : null;
7453
+ }
7257
7454
  function flattenConfig(obj, prefix = "") {
7258
7455
  const out = [];
7259
7456
  for (const [k, v] of Object.entries(obj)) {
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # release/deploy.sh — function library for the deploy phase.
4
+ #
5
+ # Functions:
6
+ # read_changelog_section <branch> <ver> -> prints the body of the matching ## header block
7
+ # build_pr_body <ver> <changelog> <default_branch> <release_branch> <issue>
8
+ # -> echoes the deploy PR body
9
+ # open_deploy_pr <new_version> <issue> -> echoes deploy PR URL (or empty if no-op)
10
+
11
+ # shellcheck disable=SC2148
12
+
13
+ # Extract the section for $ver from CHANGELOG.md fetched from origin/$branch.
14
+ # Handles three header shapes:
15
+ # "## [0.25.0] - 2026-04-15" (bracketed, dash separator)
16
+ # "## 0.22.0 (2026-03-25)" (bare, parenthesized date)
17
+ # "## v0.25.5 — 2026-05-05" (v-prefixed, em-dash, kody style)
18
+ read_changelog_section() {
19
+ local branch="$1" ver="$2" raw=""
20
+ if ! raw=$(git show "origin/${branch}:CHANGELOG.md" 2>/dev/null); then
21
+ return 0
22
+ fi
23
+ printf '%s' "$raw" | awk -v ver="$ver" '
24
+ BEGIN { capture = 0 }
25
+ /^##[[:space:]]/ {
26
+ if (capture) { exit }
27
+ header = $0
28
+ sub(/^##[[:space:]]+/, "", header)
29
+ sub(/^\[/, "", header); sub(/\].*/, "", header)
30
+ sub(/[[:space:]].*/, "", header)
31
+ sub(/[(].*/, "", header)
32
+ sub(/^v/, "", header)
33
+ if (header == ver) { capture = 1; next }
34
+ }
35
+ capture { print }
36
+ ' | awk '
37
+ NF { if (!started) started = 1; out[++n] = $0; last = n; next }
38
+ started { out[++n] = $0 }
39
+ END { for (i = 1; i <= last; i++) print out[i] }
40
+ '
41
+ }
42
+
43
+ build_pr_body() {
44
+ local ver="$1" changelog="$2" default_branch="$3" release_branch="$4" issue="$5"
45
+ printf 'Automated deploy PR opened by kody — promotes `%s` to `%s` for release **v%s**.\n\n' \
46
+ "$default_branch" "$release_branch" "$ver"
47
+ if [[ -n "$changelog" ]]; then
48
+ printf '<!-- kody-changelog-start -->\n## What'\''s changing in v%s\n\n%s\n<!-- kody-changelog-end -->\n\n' \
49
+ "$ver" "$changelog"
50
+ fi
51
+ printf 'Merge this PR to deploy v%s to `%s`.' "$ver" "$release_branch"
52
+ if [[ "$issue" =~ ^[0-9]+$ && "$issue" != "0" ]]; then
53
+ printf '\n\nTracking-Issue: #%s\n' "$issue"
54
+ else
55
+ printf '\n'
56
+ fi
57
+ }
58
+
59
+ # Open or reuse the deploy PR (default_branch → release_branch).
60
+ # Returns the PR URL via stdout. Empty stdout = single-branch repo, no-op.
61
+ # Refreshes existing PR's body via gh pr edit (idempotent).
62
+ open_deploy_pr() {
63
+ local new_version="$1"
64
+ local issue_arg="$2"
65
+ local default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
66
+ local release_branch="${KODY_CFG_RELEASE_RELEASEBRANCH:-}"
67
+
68
+ # Single-branch repos: nothing to deploy.
69
+ if [[ -z "$release_branch" || "$release_branch" == "$default_branch" ]]; then
70
+ echo "[deploy] no releaseBranch configured (or equals defaultBranch) — skipping deploy PR" >&2
71
+ echo ""
72
+ return 0
73
+ fi
74
+
75
+ # Read the section from the integration branch (where release-prepare just merged).
76
+ local changelog_section
77
+ changelog_section=$(read_changelog_section "$default_branch" "$new_version" || true)
78
+ if [[ -z "$changelog_section" ]]; then
79
+ echo "[deploy] no CHANGELOG section for v${new_version} on origin/${default_branch} — minimal body" >&2
80
+ fi
81
+
82
+ local body
83
+ body=$(build_pr_body "$new_version" "$changelog_section" "$default_branch" "$release_branch" "$issue_arg")
84
+
85
+ # Idempotency: reuse an open PR for this branch pair if one exists.
86
+ local existing pr_url
87
+ existing=$(gh pr list --head "$default_branch" --base "$release_branch" --state open --json url --limit 1 2>/dev/null \
88
+ | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data[0]["url"] if data else "")' 2>/dev/null \
89
+ || echo "")
90
+
91
+ if [[ -n "$existing" ]]; then
92
+ echo " reusing existing deploy PR: ${existing}" >&2
93
+ pr_url="$existing"
94
+ if ! printf '%s' "$body" | gh pr edit "$pr_url" --body-file - >/dev/null 2>&1; then
95
+ echo "[deploy] WARN: failed to refresh deploy PR body" >&2
96
+ fi
97
+ else
98
+ if ! pr_url=$(printf '%s' "$body" | gh pr create --head "$default_branch" --base "$release_branch" --title "deploy: ${default_branch} → ${release_branch} (v${new_version})" --body-file -); then
99
+ echo "[deploy] gh pr create failed" >&2
100
+ return 1
101
+ fi
102
+ fi
103
+
104
+ if [[ -z "$pr_url" ]]; then
105
+ echo "[deploy] empty PR URL after gh pr create" >&2
106
+ return 1
107
+ fi
108
+
109
+ # Persist the deploy-PR marker on the originating issue body, replacing
110
+ # any prepare-PR marker so the dashboard pivots to the deploy PR.
111
+ if [[ "$issue_arg" =~ ^[0-9]+$ && "$issue_arg" != "0" ]]; then
112
+ local pr_number="${pr_url##*/}"
113
+ if [[ "$pr_number" =~ ^[0-9]+$ ]]; then
114
+ local cur_body cleaned_body
115
+ cur_body=$(gh issue view "$issue_arg" --json body -q .body 2>/dev/null || echo "")
116
+ cleaned_body=$(printf '%s' "$cur_body" | sed -E '/<!-- kody-release-pr:[^>]*-->/d')
117
+ {
118
+ printf '%s' "$cleaned_body"
119
+ printf '\n\n<!-- kody-release-pr: #%s -->\n' "$pr_number"
120
+ } | gh issue edit "$issue_arg" --body-file - >/dev/null 2>&1 || \
121
+ echo "[deploy] WARN: failed to write kody-release-pr marker to issue #${issue_arg}" >&2
122
+ fi
123
+ fi
124
+
125
+ echo "$pr_url"
126
+ return 0
127
+ }
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # release/prepare.sh — function library for the prepare phase.
4
+ #
5
+ # Functions exported:
6
+ # read_pkg_version
7
+ # bump_version <cur> <patch|minor|major>
8
+ # write_pkg_version <file> <new>
9
+ # resolve_version_files -> prints \n-separated paths
10
+ # generate_changelog -> prints raw `subject||sha` lines
11
+ # format_changelog <new_version> -> reads stdin, prints markdown entry
12
+ # prepend_changelog <entry>
13
+ # remote_branch_exists <branch>
14
+ # find_open_pr <branch> -> prints url or empty
15
+ # open_prepare_pr <new_version> <issue_number> <prefer>
16
+ # -> echoes PR url; sets globals
17
+ # set_kody_release_pr_marker <issue_number> <pr_url>
18
+ #
19
+ # Side-effects: bumps version files, generates CHANGELOG.md, commits +
20
+ # pushes a release branch, opens the prepare PR.
21
+
22
+ # shellcheck disable=SC2148
23
+
24
+ read_pkg_version() {
25
+ python3 -c "import json,sys; print(json.load(open('package.json'))['version'])"
26
+ }
27
+
28
+ bump_version() {
29
+ local cur="$1" kind="$2"
30
+ local core="${cur%%-*}"
31
+ if ! [[ "$core" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
32
+ echo "[prepare] cannot parse version '$cur'" >&2
33
+ return 1
34
+ fi
35
+ local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" pat="${BASH_REMATCH[3]}"
36
+ case "$kind" in
37
+ major) maj=$((maj + 1)); min=0; pat=0 ;;
38
+ minor) min=$((min + 1)); pat=0 ;;
39
+ patch|*) pat=$((pat + 1)) ;;
40
+ esac
41
+ echo "${maj}.${min}.${pat}"
42
+ }
43
+
44
+ write_pkg_version() {
45
+ local file="$1" new="$2"
46
+ python3 - "$file" "$new" <<'PY'
47
+ import json, sys
48
+ path, new = sys.argv[1], sys.argv[2]
49
+ try:
50
+ with open(path) as f:
51
+ text = f.read()
52
+ except FileNotFoundError:
53
+ print("MISSING")
54
+ sys.exit(0)
55
+ try:
56
+ data = json.loads(text)
57
+ except Exception:
58
+ print("UNCHANGED")
59
+ sys.exit(0)
60
+ if data.get("version") == new:
61
+ print("UNCHANGED")
62
+ sys.exit(0)
63
+ data["version"] = new
64
+ with open(path, "w") as f:
65
+ f.write(json.dumps(data, indent=2) + "\n")
66
+ print("WROTE")
67
+ PY
68
+ }
69
+
70
+ resolve_version_files() {
71
+ local raw="${KODY_CFG_RELEASE_VERSIONFILES:-}"
72
+ if [[ -z "$raw" ]]; then
73
+ echo "package.json"
74
+ return
75
+ fi
76
+ python3 - <<PY
77
+ import json, os
78
+ raw = os.environ.get("KODY_CFG_RELEASE_VERSIONFILES", "")
79
+ try:
80
+ arr = json.loads(raw)
81
+ except Exception:
82
+ print("package.json"); raise SystemExit
83
+ if isinstance(arr, list) and arr:
84
+ for f in arr:
85
+ if isinstance(f, str) and f:
86
+ print(f)
87
+ else:
88
+ print("package.json")
89
+ PY
90
+ }
91
+
92
+ generate_changelog() {
93
+ local last_tag count
94
+ if ! last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null); then
95
+ git fetch --tags --quiet 2>/dev/null || true
96
+ last_tag=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)
97
+ fi
98
+ if [[ -n "$last_tag" ]]; then
99
+ count=$(git rev-list --count "${last_tag}..HEAD" --no-merges 2>/dev/null || echo "?")
100
+ echo " changelog: ${count} commits since ${last_tag}" >&2
101
+ git log "${last_tag}..HEAD" --pretty=format:'%s||%h' --no-merges 2>/dev/null || true
102
+ else
103
+ echo " changelog: no previous v* tag found — using last 100 commits" >&2
104
+ git log -n100 HEAD --pretty=format:'%s||%h' --no-merges 2>/dev/null || true
105
+ fi
106
+ }
107
+
108
+ format_changelog() {
109
+ local new_version="$1"
110
+ local date_str
111
+ date_str=$(date -u +%Y-%m-%d)
112
+ python3 - "$new_version" "$date_str" <<'PY'
113
+ import sys, re
114
+ new_version, date_str = sys.argv[1], sys.argv[2]
115
+ raw = sys.stdin.read()
116
+ buckets = {k: [] for k in ("feat", "fix", "perf", "refactor", "docs", "chore", "other")}
117
+ for line in raw.splitlines():
118
+ line = line.strip()
119
+ if not line or "||" not in line:
120
+ continue
121
+ subject, sha = line.split("||", 1)
122
+ if re.match(r"(?i)^chore:\s*release\s+v\d", subject):
123
+ continue
124
+ m = re.match(r"^(\w+)(?:\(.*?\))?\s*:\s*(.+)$", subject)
125
+ if m:
126
+ kind = m.group(1).lower()
127
+ msg = m.group(2)
128
+ else:
129
+ kind = "other"
130
+ msg = subject
131
+ buckets.setdefault(kind, buckets["other"]).append(f"- {msg} ({sha})")
132
+ labels = [
133
+ ("feat", "Features"),
134
+ ("fix", "Fixes"),
135
+ ("perf", "Performance"),
136
+ ("refactor", "Refactoring"),
137
+ ("docs", "Docs"),
138
+ ("chore", "Chores"),
139
+ ("other", "Other"),
140
+ ]
141
+ parts = [f"## v{new_version} — {date_str}", ""]
142
+ emitted = False
143
+ for key, label in labels:
144
+ items = buckets.get(key) or []
145
+ if not items:
146
+ continue
147
+ parts.append(f"### {label}")
148
+ parts.extend(items)
149
+ parts.append("")
150
+ emitted = True
151
+ if not emitted:
152
+ parts.append("_No notable commits since the last release._")
153
+ parts.append("")
154
+ sys.stdout.write("\n".join(parts))
155
+ PY
156
+ }
157
+
158
+ prepend_changelog() {
159
+ local entry="$1"
160
+ local header='# Changelog
161
+
162
+ All notable changes to this project will be documented in this file.
163
+
164
+ '
165
+ if [[ -f CHANGELOG.md ]]; then
166
+ if grep -qE '^#\s*Changelog\b' CHANGELOG.md; then
167
+ python3 - "$entry" <<'PY'
168
+ import sys
169
+ entry = sys.argv[1]
170
+ with open("CHANGELOG.md") as f:
171
+ prior = f.read()
172
+ idx = prior.index("\n", prior.index("# Changelog"))
173
+ new = prior[: idx + 1] + "\n" + entry + prior[idx + 1 :]
174
+ with open("CHANGELOG.md", "w") as f:
175
+ f.write(new)
176
+ PY
177
+ else
178
+ python3 - "$entry" <<PY
179
+ import sys
180
+ entry = sys.argv[1]
181
+ header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
182
+ with open("CHANGELOG.md") as f:
183
+ prior = f.read()
184
+ with open("CHANGELOG.md", "w") as f:
185
+ f.write(header + entry + prior)
186
+ PY
187
+ fi
188
+ else
189
+ python3 - "$entry" <<PY
190
+ import sys
191
+ entry = sys.argv[1]
192
+ header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
193
+ with open("CHANGELOG.md", "w") as f:
194
+ f.write(header + entry)
195
+ PY
196
+ fi
197
+ }
198
+
199
+ remote_branch_exists() {
200
+ local branch="$1"
201
+ git ls-remote --heads origin "$branch" 2>/dev/null | grep -q .
202
+ }
203
+
204
+ find_open_pr() {
205
+ local branch="$1"
206
+ gh pr list --head "$branch" --state open --json url --limit 1 2>/dev/null \
207
+ | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data[0]["url"] if data else "")' 2>/dev/null \
208
+ || echo ""
209
+ }
210
+
211
+ # Returns the PREPARE PR url on stdout. Bumps version files, generates
212
+ # CHANGELOG, commits on a release branch, pushes, opens (or reuses) the PR.
213
+ # Globals it sets via export so release.sh can pass them to deploy/publish:
214
+ # PREPARE_NEW_VERSION
215
+ # PREPARE_TAG
216
+ # PREPARE_RELEASE_BRANCH
217
+ # PREPARE_CHANGELOG_ENTRY
218
+ open_prepare_pr() {
219
+ local new_version="$1"
220
+ local issue_arg="$2"
221
+ local prefer="$3"
222
+ local default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
223
+
224
+ local tag="v${new_version}"
225
+ local release_branch="release/${tag}"
226
+
227
+ export PREPARE_NEW_VERSION="$new_version"
228
+ export PREPARE_TAG="$tag"
229
+ export PREPARE_RELEASE_BRANCH="$release_branch"
230
+
231
+ # Branch-collision gate.
232
+ local collides=false
233
+ if remote_branch_exists "$release_branch"; then
234
+ collides=true
235
+ case "$prefer" in
236
+ theirs)
237
+ local existing
238
+ existing=$(find_open_pr "$release_branch")
239
+ if [[ -n "$existing" ]]; then
240
+ echo " reusing existing PR (--prefer theirs): ${existing}" >&2
241
+ echo "$existing"
242
+ return 0
243
+ fi
244
+ echo "[prepare] --prefer theirs: ${release_branch} exists but no open PR" >&2
245
+ return 1
246
+ ;;
247
+ ours)
248
+ echo " branch ${release_branch} exists — will force-push (--prefer ours)" >&2
249
+ ;;
250
+ *)
251
+ echo "[prepare] branch ${release_branch} already exists. Use --prefer ours/theirs." >&2
252
+ return 1
253
+ ;;
254
+ esac
255
+ fi
256
+
257
+ # Bump version files.
258
+ local files
259
+ mapfile -t files < <(resolve_version_files)
260
+ local touched=()
261
+ for f in "${files[@]}"; do
262
+ local res
263
+ res=$(write_pkg_version "$f" "$new_version")
264
+ if [[ "$res" == "WROTE" ]]; then
265
+ touched+=("$f")
266
+ fi
267
+ done
268
+ if [[ ${#touched[@]} -eq 0 ]]; then
269
+ echo "[prepare] no version strings updated (files: ${files[*]})" >&2
270
+ return 1
271
+ fi
272
+ echo " wrote ${touched[*]}" >&2
273
+
274
+ # Changelog.
275
+ local raw_log entry
276
+ raw_log=$(generate_changelog) || raw_log=""
277
+ entry=$(printf '%s' "$raw_log" | format_changelog "$new_version")
278
+ prepend_changelog "$entry"
279
+ echo " wrote CHANGELOG.md" >&2
280
+ export PREPARE_CHANGELOG_ENTRY="$entry"
281
+
282
+ # Commit + push.
283
+ export HUSKY=0 SKIP_HOOKS=1
284
+ git checkout -b "$release_branch"
285
+ for f in "${touched[@]}" CHANGELOG.md; do
286
+ git add -- "$f"
287
+ done
288
+ git -c commit.gpgsign=false commit -m "chore: release ${tag}"
289
+ if [[ "$collides" == "true" && "$prefer" == "ours" ]]; then
290
+ git push -u --force-with-lease origin "$release_branch"
291
+ else
292
+ git push -u origin "$release_branch"
293
+ fi
294
+
295
+ # Open PR.
296
+ local pr_url=""
297
+ if [[ "$collides" == "true" && "$prefer" == "ours" ]]; then
298
+ pr_url=$(find_open_pr "$release_branch")
299
+ fi
300
+
301
+ if [[ -z "$pr_url" ]]; then
302
+ local body_max=60000 body_entry
303
+ if [[ ${#entry} -gt $body_max ]]; then
304
+ body_entry="${entry:0:$body_max}
305
+
306
+ _… truncated; see CHANGELOG.md_"
307
+ else
308
+ body_entry="$entry"
309
+ fi
310
+ local tracking_line=""
311
+ if [[ "$issue_arg" =~ ^[0-9]+$ && "$issue_arg" != "0" ]]; then
312
+ tracking_line=$'\n\nTracking-Issue: #'"${issue_arg}"
313
+ fi
314
+ local body
315
+ body=$'Automated release PR opened by kody.\n\n'"$body_entry"$'\n\nThe release flow will merge this into `'"${default_branch}"$'` and continue to publish + deploy.'"${tracking_line}"
316
+ pr_url=$(printf '%s' "$body" | gh pr create --head "$release_branch" --base "$default_branch" --title "chore: release ${tag}" --body-file -)
317
+ fi
318
+
319
+ if [[ -z "$pr_url" ]]; then
320
+ echo "[prepare] gh pr create returned empty URL" >&2
321
+ return 1
322
+ fi
323
+
324
+ echo "$pr_url"
325
+ return 0
326
+ }
327
+
328
+ set_kody_release_pr_marker() {
329
+ local issue_arg="$1" pr_url="$2"
330
+ if [[ ! "$issue_arg" =~ ^[0-9]+$ ]] || [[ "$issue_arg" == "0" ]]; then
331
+ return 0
332
+ fi
333
+ local pr_number="${pr_url##*/}"
334
+ if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then
335
+ return 0
336
+ fi
337
+ local cur_body cleaned_body
338
+ cur_body=$(gh issue view "$issue_arg" --json body -q .body 2>/dev/null || echo "")
339
+ cleaned_body=$(printf '%s' "$cur_body" | sed -E '/<!-- kody-release-pr:[^>]*-->/d')
340
+ {
341
+ printf '%s' "$cleaned_body"
342
+ printf '\n\n<!-- kody-release-pr: #%s -->\n' "$pr_number"
343
+ } | gh issue edit "$issue_arg" --body-file - >/dev/null 2>&1 || \
344
+ echo "[prepare] WARN: failed to write kody-release-pr marker to issue #${issue_arg}" >&2
345
+ }
@@ -1,14 +1,38 @@
1
1
  {
2
2
  "name": "release",
3
- "role": "orchestrator",
4
- "describe": "Release orchestrator: drives release-prepare → merge PR → release-publish → release-deploy. No agent — postflight entries ARE the transition table, evaluated top-to-bottom via runWhen.",
3
+ "role": "utility",
4
+ "phase": "shipped",
5
+ "describe": "Single-job release flow: prepare → wait CI → merge → publish → deploy → notify. No orchestrator chain — runs end-to-end inside one workflow run.",
5
6
  "inputs": [
6
7
  {
7
8
  "name": "issue",
8
9
  "flag": "--issue",
9
10
  "type": "int",
10
11
  "required": true,
11
- "describe": "GitHub issue number to drive the release flow on."
12
+ "describe": "GitHub issue number that triggered the release."
13
+ },
14
+ {
15
+ "name": "bump",
16
+ "flag": "--bump",
17
+ "type": "enum",
18
+ "values": ["patch", "minor", "major"],
19
+ "required": false,
20
+ "describe": "Version bump increment. Default patch."
21
+ },
22
+ {
23
+ "name": "dry-run",
24
+ "flag": "--dry-run",
25
+ "type": "bool",
26
+ "required": false,
27
+ "describe": "Print plan without writing files, committing, or opening a PR."
28
+ },
29
+ {
30
+ "name": "prefer",
31
+ "flag": "--prefer",
32
+ "type": "enum",
33
+ "values": ["ours", "theirs"],
34
+ "required": false,
35
+ "describe": "On release/vX.Y.Z branch collision: 'ours' force-pushes; 'theirs' reuses the existing PR."
12
36
  }
13
37
  ],
14
38
  "claudeCode": {
@@ -38,54 +62,34 @@
38
62
  "description": "kody flow: release"
39
63
  }
40
64
  },
41
- {
42
- "script": "setLifecycleLabel",
43
- "with": {
44
- "label": "kody:orchestrating",
45
- "color": "1d76db",
46
- "description": "kody: orchestrating a multi-stage flow"
47
- }
48
- },
49
65
  { "script": "loadIssueContext" },
50
66
  { "script": "loadTaskState" },
67
+ { "shell": "release.sh", "timeoutSec": 5400 },
51
68
  { "script": "skipAgent" }
52
69
  ],
53
70
  "postflight": [
54
- { "script": "startFlow", "with": { "entry": "release-prepare", "target": "issue" } },
55
-
56
- { "script": "waitForCi",
57
- "with": { "timeoutMinutes": 60, "pollSeconds": 30, "initialWaitSeconds": 15, "maxFixCiAttempts": 0 },
58
- "runWhen": { "data.taskState.core.lastOutcome.type": "RELEASE_PREPARE_COMPLETED" } },
59
-
60
- { "script": "mergeReleasePr",
61
- "runWhen": { "data.action.type": "CI_PASSED" } },
62
-
63
- { "script": "dispatch", "with": { "next": "release-publish", "target": "issue" },
64
- "runWhen": { "data.taskState.core.lastOutcome.type": "RELEASE_MERGE_COMPLETED" } },
65
-
66
- { "script": "dispatch", "with": { "next": "release-deploy", "target": "issue" },
67
- "runWhen": { "data.taskState.core.lastOutcome.type": "RELEASE_PUBLISH_COMPLETED" } },
68
-
69
- { "script": "waitForCi",
70
- "with": { "timeoutMinutes": 60, "pollSeconds": 30, "initialWaitSeconds": 15, "maxFixCiAttempts": 3 },
71
- "runWhen": { "data.taskState.core.lastOutcome.type": ["RELEASE_DEPLOY_COMPLETED", "FIX_CI_COMPLETED"] } },
72
-
73
- { "script": "dispatch", "with": { "next": "fix-ci", "target": "pr" },
74
- "runWhen": { "data.action.type": "CI_FAILED" } },
75
-
76
- { "script": "finishFlow",
77
- "with": { "reason": "release-completed", "label": "kody:done", "color": "0e8a16", "description": "kody: release complete" },
78
- "runWhen": { "data.action.type": "CI_PASSED" } },
79
-
80
- { "script": "finishFlow",
81
- "with": { "reason": "release-failed", "label": "kody:failed", "color": "e11d21", "description": "kody: release flow failed" },
82
- "runWhen": { "data.action.type": ["CI_GIVEUP", "CI_TIMEOUT"] } },
83
-
84
- { "script": "finishFlow",
85
- "with": { "reason": "release-failed", "label": "kody:failed", "color": "e11d21", "description": "kody: release flow failed" },
86
- "runWhen": { "data.taskState.core.lastOutcome.type": ["RELEASE_PREPARE_FAILED", "RELEASE_MERGE_FAILED", "RELEASE_PUBLISH_FAILED", "RELEASE_DEPLOY_FAILED", "FIX_CI_FAILED"] } },
87
-
88
- { "script": "persistFlowState" }
71
+ { "script": "recordOutcome" },
72
+ { "script": "saveTaskState" },
73
+ {
74
+ "script": "finishFlow",
75
+ "with": {
76
+ "reason": "release-completed",
77
+ "label": "kody:done",
78
+ "color": "0e8a16",
79
+ "description": "kody: release complete"
80
+ },
81
+ "runWhen": { "data.action.type": "RELEASE_COMPLETED" }
82
+ },
83
+ {
84
+ "script": "finishFlow",
85
+ "with": {
86
+ "reason": "release-failed",
87
+ "label": "kody:failed",
88
+ "color": "e11d21",
89
+ "description": "kody: release flow failed"
90
+ },
91
+ "runWhen": { "data.action.type": "RELEASE_FAILED" }
92
+ }
89
93
  ]
90
94
  }
91
95
  }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # release/publish.sh — function library for the publish phase.
4
+ #
5
+ # Functions:
6
+ # tag_and_publish <new_version> -> creates tag locally, pushes, runs publishCommand
7
+ # create_gh_release <tag> -> echoes release URL or empty
8
+
9
+ # shellcheck disable=SC2148
10
+
11
+ tag_and_publish() {
12
+ local new_version="$1"
13
+ local publish_cmd="${KODY_CFG_RELEASE_PUBLISHCOMMAND:-}"
14
+ local timeout_ms="${KODY_CFG_RELEASE_TIMEOUTMS:-600000}"
15
+ local timeout_s=$((timeout_ms / 1000))
16
+ local tag="v${new_version}"
17
+
18
+ # Refuse if the tag already exists locally (left over from a prior failed run).
19
+ if git rev-parse --verify "$tag" >/dev/null 2>&1; then
20
+ echo "[publish] tag ${tag} already exists locally" >&2
21
+ return 1
22
+ fi
23
+
24
+ git tag -a "$tag" -m "Release ${tag}"
25
+ git push origin "$tag"
26
+
27
+ # publishCommand (optional). Failure here is recorded but does not abort —
28
+ # we still want the GH release entry so the tag is discoverable.
29
+ local publish_status="skipped"
30
+ if [[ -n "$publish_cmd" ]]; then
31
+ local cmd="${publish_cmd//\$VERSION/$new_version}"
32
+ echo " publish: ${cmd}" >&2
33
+ if timeout "${timeout_s}" bash -c "$cmd"; then
34
+ publish_status="ok"
35
+ else
36
+ publish_status="failed"
37
+ echo "[publish] publishCommand failed (continuing to create GH release)" >&2
38
+ fi
39
+ fi
40
+
41
+ echo "$publish_status"
42
+ return 0
43
+ }
44
+
45
+ create_gh_release() {
46
+ local tag="$1"
47
+ local draft="${KODY_CFG_RELEASE_DRAFTRELEASE:-false}"
48
+ local draft_flag=""
49
+ [[ "$draft" == "true" ]] && draft_flag="--draft"
50
+
51
+ local release_url=""
52
+ if release_url=$(gh release create "$tag" --title "$tag" --notes "Release ${tag} — automated by kody." $draft_flag 2>&1); then
53
+ echo "$release_url"
54
+ return 0
55
+ else
56
+ echo "[publish] gh release create failed: $release_url" >&2
57
+ echo ""
58
+ return 1
59
+ fi
60
+ }
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # release/release.sh — single-job release driver.
4
+ #
5
+ # Replaces the orchestrator + 3-executable chain with one linear bash flow:
6
+ # prepare → wait CI → merge → publish → deploy → notify
7
+ #
8
+ # Inputs (env, set by the executor):
9
+ # KODY_ARG_ISSUE triggering issue number (required)
10
+ # KODY_ARG_BUMP patch|minor|major (default: patch)
11
+ # KODY_ARG_DRY_RUN true|false
12
+ # KODY_ARG_PREFER ours|theirs (optional; for branch collision)
13
+ #
14
+ # Config (env, flattened from kody.config.json):
15
+ # KODY_CFG_GIT_DEFAULTBRANCH e.g. dev
16
+ # KODY_CFG_RELEASE_RELEASEBRANCH e.g. main (unset → deploy is no-op)
17
+ # KODY_CFG_RELEASE_VERSIONFILES JSON array
18
+ # KODY_CFG_RELEASE_PUBLISHCOMMAND optional; $VERSION substituted
19
+ # KODY_CFG_RELEASE_NOTIFYCOMMAND optional; $VERSION + $DEPLOY_PR_URL substituted
20
+ # KODY_CFG_RELEASE_DRAFTRELEASE "true" → create as draft
21
+ # KODY_CFG_RELEASE_TIMEOUTMS per-command timeout in ms
22
+ #
23
+ # Stdout signals:
24
+ # KODY_PR_URL=<deploy PR url>
25
+ # KODY_REASON=<text>
26
+ # KODY_SKIP_AGENT=true
27
+ # RELEASE_COMPLETED=true | RELEASE_FAILED=true (consumed by recordOutcome → finishFlow)
28
+
29
+ set -euo pipefail
30
+
31
+ HERE="$(dirname "$0")"
32
+ # shellcheck source=prepare.sh
33
+ source "$HERE/prepare.sh"
34
+ # shellcheck source=wait.sh
35
+ source "$HERE/wait.sh"
36
+ # shellcheck source=publish.sh
37
+ source "$HERE/publish.sh"
38
+ # shellcheck source=deploy.sh
39
+ source "$HERE/deploy.sh"
40
+
41
+ issue="${KODY_ARG_ISSUE:?required}"
42
+ bump="${KODY_ARG_BUMP:-patch}"
43
+ dry_run="${KODY_ARG_DRY_RUN:-false}"
44
+ prefer="${KODY_ARG_PREFER:-}"
45
+
46
+ default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
47
+ notify_cmd="${KODY_CFG_RELEASE_NOTIFYCOMMAND:-}"
48
+ notify_timeout_s=$(( ${KODY_CFG_RELEASE_TIMEOUTMS:-600000} / 1000 ))
49
+
50
+ # Tracks where we were when an error fired, for clearer failure messages.
51
+ current_step="init"
52
+
53
+ on_error() {
54
+ local rc=$?
55
+ echo "[release] FAILED during step '${current_step}' (exit ${rc})" >&2
56
+ echo "KODY_REASON=release failed during ${current_step}"
57
+ echo "RELEASE_FAILED=true"
58
+ echo "KODY_SKIP_AGENT=true"
59
+ exit "$rc"
60
+ }
61
+ trap on_error ERR
62
+
63
+ if [[ ! -f package.json ]]; then
64
+ echo "[release] package.json not found in $(pwd)" >&2
65
+ echo "KODY_REASON=release: package.json not found"
66
+ echo "RELEASE_FAILED=true"
67
+ echo "KODY_SKIP_AGENT=true"
68
+ exit 1
69
+ fi
70
+
71
+ # ── 1. Prepare ────────────────────────────────────────────────────────────
72
+ current_step="prepare"
73
+ old_version=$(read_pkg_version)
74
+ new_version=$(bump_version "$old_version" "$bump")
75
+ tag="v${new_version}"
76
+ echo "→ release: issue=#${issue} bump=${bump} ${old_version} → ${new_version}"
77
+
78
+ if [[ "$dry_run" == "true" ]]; then
79
+ echo "RELEASE_PLAN=bump=${new_version} tag=${tag}"
80
+ echo "KODY_REASON=dry-run — would bump to ${new_version}${prefer:+ (--prefer ${prefer})}"
81
+ echo "RELEASE_COMPLETED=true"
82
+ echo "KODY_SKIP_AGENT=true"
83
+ exit 0
84
+ fi
85
+
86
+ prep_pr_url=$(open_prepare_pr "$new_version" "$issue" "$prefer")
87
+ if [[ -z "$prep_pr_url" ]]; then
88
+ echo "[release] prepare returned no PR URL" >&2
89
+ exit 1
90
+ fi
91
+ echo "✓ prepare: ${prep_pr_url}"
92
+ set_kody_release_pr_marker "$issue" "$prep_pr_url"
93
+
94
+ # ── 2. Wait for prepare PR CI ─────────────────────────────────────────────
95
+ current_step="wait_prepare_ci"
96
+ prep_pr_num="${prep_pr_url##*/}"
97
+ wait_for_ci "$prep_pr_num" 60 || {
98
+ echo "[release] CI failed/timeout on prepare PR #${prep_pr_num}" >&2
99
+ exit 1
100
+ }
101
+
102
+ # ── 3. Merge prepare PR ───────────────────────────────────────────────────
103
+ current_step="merge"
104
+ if gh pr merge "$prep_pr_num" --merge --admin 2>&1; then
105
+ echo "✓ merged: PR #${prep_pr_num}"
106
+ elif gh pr merge "$prep_pr_num" --merge 2>&1 | grep -qi "already merged"; then
107
+ echo " (already merged)"
108
+ else
109
+ echo "[release] gh pr merge failed for PR #${prep_pr_num}" >&2
110
+ exit 1
111
+ fi
112
+
113
+ # ── 4. Publish (tag + GH release) ─────────────────────────────────────────
114
+ current_step="publish"
115
+ git fetch origin "$default_branch" --tags
116
+ git checkout "$default_branch"
117
+ git reset --hard "origin/$default_branch"
118
+
119
+ # Sanity: confirm the bump landed on the integration branch.
120
+ landed_version=$(read_pkg_version)
121
+ if [[ "$landed_version" != "$new_version" ]]; then
122
+ echo "[release] WARN: package.json on ${default_branch} is ${landed_version}, expected ${new_version} after merge" >&2
123
+ fi
124
+
125
+ publish_status=$(tag_and_publish "$new_version")
126
+ release_url=$(create_gh_release "$tag" || echo "")
127
+ echo "✓ publish: tag=${tag} status=${publish_status} release_url=${release_url:-<none>}"
128
+
129
+ if [[ "$publish_status" == "failed" ]]; then
130
+ echo "[release] publishCommand failed but tag + GH release exist" >&2
131
+ echo "KODY_REASON=tag + GH release created, but publishCommand failed"
132
+ echo "RELEASE_FAILED=true"
133
+ echo "KODY_SKIP_AGENT=true"
134
+ exit 1
135
+ fi
136
+
137
+ # ── 5. Deploy PR (default → release branch) ───────────────────────────────
138
+ current_step="deploy"
139
+ deploy_pr_url=$(open_deploy_pr "$new_version" "$issue" || echo "")
140
+ if [[ -z "$deploy_pr_url" ]]; then
141
+ echo " (deploy: no-op — single-branch repo)"
142
+ else
143
+ echo "✓ deploy: ${deploy_pr_url}"
144
+ fi
145
+
146
+ # ── 6. Notify ─────────────────────────────────────────────────────────────
147
+ current_step="notify"
148
+ notify_status="skipped"
149
+ if [[ -n "$notify_cmd" ]]; then
150
+ cmd="${notify_cmd//\$VERSION/$new_version}"
151
+ cmd="${cmd//\$DEPLOY_PR_URL/${deploy_pr_url:-}}"
152
+ echo " notify: ${cmd}"
153
+ if timeout "$notify_timeout_s" bash -c "$cmd"; then
154
+ notify_status="ok"
155
+ else
156
+ notify_status="failed"
157
+ echo "[release] notifyCommand failed (non-fatal)" >&2
158
+ fi
159
+ fi
160
+
161
+ # ── 7. Done ───────────────────────────────────────────────────────────────
162
+ current_step="done"
163
+ [[ -n "$deploy_pr_url" ]] && echo "KODY_PR_URL=${deploy_pr_url}"
164
+ echo "RELEASE_TAG=${tag}"
165
+ [[ -n "$release_url" ]] && echo "RELEASE_URL=${release_url}"
166
+ [[ -n "$deploy_pr_url" ]] && echo "RELEASE_DEPLOY_PR=${deploy_pr_url}"
167
+ echo "KODY_REASON=release v${new_version} complete (notify=${notify_status})"
168
+ echo "RELEASE_COMPLETED=true"
169
+ echo "KODY_SKIP_AGENT=true"
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # release/wait.sh — wait_for_ci function: poll a PR's check rollup
4
+ # until all non-skipped checks pass, or timeout.
5
+ #
6
+ # Function: wait_for_ci <pr_number> <timeout_minutes> [poll_seconds] [initial_wait]
7
+ # Returns 0 on CI passed, 1 on CI failed/timeout.
8
+ #
9
+ # Special-case: if a PR has zero registered checks (no CI configured),
10
+ # treats that as PASSED after a short stabilization window — so a Tester
11
+ # repo without checks doesn't loop forever.
12
+
13
+ # shellcheck disable=SC2148
14
+
15
+ wait_for_ci() {
16
+ local pr_number="$1"
17
+ local timeout_minutes="${2:-60}"
18
+ local poll_seconds="${3:-30}"
19
+ local initial_wait="${4:-15}"
20
+
21
+ if [[ -z "$pr_number" || ! "$pr_number" =~ ^[0-9]+$ ]]; then
22
+ echo "[wait_for_ci] invalid pr_number: '$pr_number'" >&2
23
+ return 1
24
+ fi
25
+
26
+ local deadline=$(( $(date +%s) + timeout_minutes * 60 ))
27
+ echo "→ wait_for_ci: PR #${pr_number}, timeout=${timeout_minutes}m"
28
+
29
+ sleep "$initial_wait"
30
+
31
+ # Track consecutive "no checks" results so we don't bail prematurely
32
+ # on a PR that's just slow to register checks.
33
+ local empty_count=0
34
+ local empty_threshold=4 # ~2 minutes (4 * poll_seconds=30s) before treating as no-CI
35
+
36
+ while (( $(date +%s) < deadline )); do
37
+ local raw
38
+ if ! raw=$(gh pr checks "$pr_number" --json state 2>/dev/null); then
39
+ # gh exits non-zero when there are no checks — treat as no-CI.
40
+ empty_count=$((empty_count + 1))
41
+ echo " [wait_for_ci] gh pr checks returned non-zero (count=${empty_count})"
42
+ if (( empty_count >= empty_threshold )); then
43
+ echo "→ wait_for_ci: PR #${pr_number} has no CI checks configured — treating as passed"
44
+ return 0
45
+ fi
46
+ sleep "$poll_seconds"
47
+ continue
48
+ fi
49
+
50
+ # Empty array? Same path.
51
+ local total
52
+ total=$(printf '%s' "$raw" | jq 'length' 2>/dev/null || echo "0")
53
+ if [[ "$total" == "0" ]]; then
54
+ empty_count=$((empty_count + 1))
55
+ echo " [wait_for_ci] no checks registered yet (count=${empty_count})"
56
+ if (( empty_count >= empty_threshold )); then
57
+ echo "→ wait_for_ci: PR #${pr_number} has no CI checks configured — treating as passed"
58
+ return 0
59
+ fi
60
+ sleep "$poll_seconds"
61
+ continue
62
+ fi
63
+ empty_count=0
64
+
65
+ # Tally states via jq.
66
+ local pending failed passed failed_names
67
+ pending=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("PENDING","IN_PROGRESS","QUEUED",""))] | length')
68
+ failed=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE"))] | length')
69
+ passed=$(printf '%s' "$raw" | jq '[.[] | select((.state // "") | IN("SUCCESS","SKIPPED","NEUTRAL"))] | length')
70
+ failed_names=$(printf '%s' "$raw" | jq -r '[.[] | select((.state // "") | IN("FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE")) | .name] | join(",")')
71
+
72
+ echo " [wait_for_ci] pending=${pending} passed=${passed} failed=${failed} (total=${total})"
73
+
74
+ if [[ "$failed" -gt 0 ]]; then
75
+ echo "[wait_for_ci] CI failed on PR #${pr_number}: ${failed_names}" >&2
76
+ return 1
77
+ fi
78
+ if [[ "$pending" -eq 0 && "$passed" -gt 0 ]]; then
79
+ echo "→ wait_for_ci: all checks passed (${passed}) on PR #${pr_number}"
80
+ return 0
81
+ fi
82
+
83
+ sleep "$poll_seconds"
84
+ done
85
+
86
+ echo "[wait_for_ci] timeout after ${timeout_minutes}m on PR #${pr_number}" >&2
87
+ return 1
88
+ }
@@ -21,14 +21,18 @@ export interface Profile {
21
21
  /**
22
22
  * Semantic role — what this executable IS, not when it runs.
23
23
  * - primitive: single-step agent executor (flow → agent → verify → commit → PR).
24
- * - orchestrator: no-agent, drives primitives via a postflight transition table.
24
+ * - orchestrator: no-agent, drives primitives via a postflight transition table
25
+ * (comment-based, one GHA run per step).
26
+ * - container: no-agent, runs declared `children` sequentially in-process
27
+ * (one GHA run for the whole flow). Routing is done by per-child
28
+ * `next` maps over action types — no @kody comments dispatched.
25
29
  * - watch: scheduled observer that inspects repo state and may trigger other executables.
26
30
  * - utility: no-agent, one-off administrative work (scaffolding, release, etc.).
27
31
  *
28
32
  * Roles enforce shape at profile-load time and let help/dispatch treat
29
33
  * executables differently by category.
30
34
  */
31
- role: "primitive" | "orchestrator" | "watch" | "utility"
35
+ role: "primitive" | "orchestrator" | "container" | "watch" | "utility"
32
36
  /**
33
37
  * Execution model — orthogonal to `role`.
34
38
  * `oneshot` (default): single invocation on demand.
@@ -65,10 +69,44 @@ export interface Profile {
65
69
  * Artifact entry into the task-state comment's `artifacts` map.
66
70
  */
67
71
  outputArtifacts: OutputArtifactSpec[]
72
+ /**
73
+ * Container children — required when role === "container", forbidden otherwise.
74
+ * Defines the in-process step sequence and routing map. See ContainerChild.
75
+ */
76
+ children?: ContainerChild[]
68
77
  /** Absolute directory the profile was loaded from. Used to resolve prompt.md. */
69
78
  dir: string
70
79
  }
71
80
 
81
+ /**
82
+ * One step in a container's child sequence.
83
+ *
84
+ * The container executor runs the first child, reads the resulting action
85
+ * type from `state.core.lastOutcome`, then looks it up in `next`:
86
+ * - exact match → either the name of another child in this container, or
87
+ * the literal "done" / "abort"
88
+ * - "*" wildcard → fallback when no exact match
89
+ * - no match → container aborts
90
+ */
91
+ export interface ContainerChild {
92
+ /** Name of the executable to invoke (must resolve via the registry). */
93
+ exec: string
94
+ /**
95
+ * Where to source the target identifier from when invoking this child.
96
+ * - "issue": pass --issue <ctx.args.issue>
97
+ * - "pr": parse PR number from state.core.prUrl, pass --pr <N>.
98
+ * If state.core.prUrl is not set, the container aborts with
99
+ * an AGENT_NOT_RUN action.
100
+ */
101
+ target: "issue" | "pr"
102
+ /**
103
+ * Map from action.type → next step. Each value must be the name of another
104
+ * child in this container, "done" (exit 0), or "abort" (exit 1). Lookup is
105
+ * exact-match first, then "*" as a wildcard fallback.
106
+ */
107
+ next: Record<string, string>
108
+ }
109
+
72
110
  export interface InputArtifactSpec {
73
111
  /** Artifact name (the key in state.artifacts). */
74
112
  name: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.3.69",
3
+ "version": "0.3.70-beta.2",
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",