@kody-ade/kody-engine 0.4.6 → 0.4.8

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.6",
6
+ version: "0.4.8",
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",
@@ -3911,13 +3911,14 @@ function ensurePr(opts) {
3911
3911
  }
3912
3912
  return { url: existing.url, number: existing.number, draft: opts.draft, action: "updated" };
3913
3913
  }
3914
+ const base = opts.baseBranch && opts.baseBranch.length > 0 ? opts.baseBranch : opts.defaultBranch;
3914
3915
  const args = [
3915
3916
  "pr",
3916
3917
  "create",
3917
3918
  "--head",
3918
3919
  opts.branch,
3919
3920
  "--base",
3920
- opts.defaultBranch,
3921
+ base,
3921
3922
  "--title",
3922
3923
  title,
3923
3924
  "--body-file",
@@ -4045,6 +4046,7 @@ var ensurePr2 = async (ctx) => {
4045
4046
  const pr = ctx.data.pr;
4046
4047
  const targetNumber = Number(ctx.data.commentTargetNumber ?? 0);
4047
4048
  const title = issue?.title ?? pr?.title ?? `kody changes`;
4049
+ const baseBranch = ctx.data.baseBranch;
4048
4050
  try {
4049
4051
  const result = ensurePr({
4050
4052
  branch,
@@ -4055,6 +4057,7 @@ var ensurePr2 = async (ctx) => {
4055
4057
  failureReason: isFailure ? failureReason : void 0,
4056
4058
  changedFiles,
4057
4059
  agentSummary: ctx.data.prSummary,
4060
+ baseBranch,
4058
4061
  cwd: ctx.cwd
4059
4062
  });
4060
4063
  ctx.output.prUrl = result.url;
@@ -4332,7 +4335,7 @@ function mergeBase(baseBranch, cwd) {
4332
4335
  return "error";
4333
4336
  }
4334
4337
  }
4335
- function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
4338
+ function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd, baseBranch) {
4336
4339
  const branchName = deriveBranchName(issueNumber, title);
4337
4340
  const current = getCurrentBranch(cwd);
4338
4341
  if (current === branchName) {
@@ -4360,8 +4363,16 @@ function ensureFeatureBranch(issueNumber, title, defaultBranch, cwd) {
4360
4363
  return { branch: branchName, created: false };
4361
4364
  } catch {
4362
4365
  }
4366
+ let forkPoint = defaultBranch;
4367
+ if (baseBranch && baseBranch !== defaultBranch) {
4368
+ try {
4369
+ git2(["rev-parse", "--verify", `origin/${baseBranch}`], cwd);
4370
+ forkPoint = baseBranch;
4371
+ } catch {
4372
+ }
4373
+ }
4363
4374
  try {
4364
- git2(["checkout", "-b", branchName, `origin/${defaultBranch}`], cwd);
4375
+ git2(["checkout", "-b", branchName, `origin/${forkPoint}`], cwd);
4365
4376
  } catch {
4366
4377
  git2(["checkout", "-b", branchName], cwd);
4367
4378
  }
@@ -6507,8 +6518,17 @@ var runFlow = async (ctx) => {
6507
6518
  ctx.data.issue = issue;
6508
6519
  ctx.data.commentTargetType = "issue";
6509
6520
  ctx.data.commentTargetNumber = issueNumber;
6521
+ const baseRaw = ctx.args.base;
6522
+ const base = resolveBaseOverride(baseRaw);
6523
+ if (baseRaw && !base) {
6524
+ process.stderr.write(`[kody runFlow] ignoring --base "${baseRaw}" (must match /^goal-[a-z0-9-]+$/)
6525
+ `);
6526
+ }
6527
+ if (base) {
6528
+ ctx.data.baseBranch = base;
6529
+ }
6510
6530
  try {
6511
- const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd);
6531
+ const branchInfo = ensureFeatureBranch(issueNumber, issue.title, ctx.config.git.defaultBranch, ctx.cwd, base ?? void 0);
6512
6532
  ctx.data.branch = branchInfo.branch;
6513
6533
  } catch (err) {
6514
6534
  if (err instanceof UncommittedChangesError) {
@@ -6530,6 +6550,10 @@ function tryPost(issueNumber, body, cwd) {
6530
6550
  } catch {
6531
6551
  }
6532
6552
  }
6553
+ function resolveBaseOverride(value) {
6554
+ if (!value) return null;
6555
+ return /^goal-[a-z0-9-]+$/.test(value) ? value : null;
6556
+ }
6533
6557
 
6534
6558
  // src/scripts/saveTaskState.ts
6535
6559
  var saveTaskState = async (ctx, profile) => {
@@ -26,7 +26,7 @@ If a prior-art block is present above, scan it before editing — those are earl
26
26
  # Required steps
27
27
  1. **Extract** every actionable item from the feedback. A structured review uses headings like `### Concerns`, `### Suggestions`, and `### Bugs`; each bullet under those headings is a distinct item. `### Strengths`, `### Summary`, and `### Bottom line` are NOT items — skip them. If the feedback has no headings (plain inline feedback), treat the whole feedback as one item.
28
28
  2. **Number each item** internally (Item 1, Item 2, …). You will account for every one of them in your final message below.
29
- 3. **Research** — read only what's needed to act on the items. Make the minimum edits required to implement each one. If the feedback or PR body links to external URLs (reproduction sites, bug recordings, spec pages), use the **Playwright MCP** tools (`mcp__playwright__browser_navigate`, `mcp__playwright__browser_snapshot`) to load them — do not rely on your interpretation of the URL alone.
29
+ 3. **Research** — read only what's needed to act on the items. Make the minimum edits required to implement each one. If the feedback or PR body links to external **non-GitHub** URLs (reproduction sites, bug recordings, spec pages), use the **Playwright MCP** tools (`mcp__playwright__browser_navigate`, `mcp__playwright__browser_snapshot`) to load them — do not rely on your interpretation of the URL alone. Do NOT open GitHub URLs (issues, PRs, repo files, gists) in Playwright — the browser session is anonymous and will 404 on private repos. Issue/PR context is already in this prompt; for anything else on GitHub, use the `gh` CLI via Bash.
30
30
 
31
31
  **Research floor (MUST be met before any Edit/Write):**
32
32
  - Read the **full** contents of every file you intend to change.
@@ -41,6 +41,35 @@ for state_file in "${state_files[@]}"; do
41
41
  active=$((active + 1))
42
42
  echo "[goal-scheduler] → tick $goal_id"
43
43
 
44
+ # Ensure the shared goal branch exists on origin before we tick. This is
45
+ # the integration target for every task PR under this goal. Idempotent:
46
+ # if origin/goal-<id> already exists we skip. We deliberately create from
47
+ # the latest origin/<defaultBranch> so the goal branch starts from a
48
+ # known-clean base; subsequent task PRs build on top.
49
+ goal_branch="goal-${goal_id}"
50
+ default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
51
+
52
+ # Best-effort fetch so origin refs are fresh.
53
+ git fetch origin --quiet 2>/dev/null || true
54
+
55
+ if git rev-parse --verify --quiet "refs/remotes/origin/${goal_branch}" >/dev/null 2>&1; then
56
+ echo "[goal-scheduler] origin/${goal_branch} already exists — leaving as-is"
57
+ else
58
+ if ! git rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null 2>&1; then
59
+ echo "[goal-scheduler] cannot create goal branch: origin/${default_branch} missing"
60
+ else
61
+ echo "[goal-scheduler] creating origin/${goal_branch} from origin/${default_branch}"
62
+ # Push a new ref directly without checking it out — avoids touching the
63
+ # working tree, which other ticks/scripts in this same scheduler run
64
+ # may rely on. Failures here are logged and we proceed; goal-tick will
65
+ # still dispatch task issues, and ensureFeatureBranch will fall back to
66
+ # forking from defaultBranch when origin/goal-<id> is absent.
67
+ if ! git push origin "refs/remotes/origin/${default_branch}:refs/heads/${goal_branch}" --quiet 2>&1; then
68
+ echo "[goal-scheduler] push of ${goal_branch} failed (will retry next tick)"
69
+ fi
70
+ fi
71
+ fi
72
+
44
73
  # Run the tick. Top-level kody invocation is `kody <executable>` —
45
74
  # there's no `dispatch` subcommand. A non-zero exit logs and continues
46
75
  # so one stuck goal doesn't starve the rest of the schedule.
@@ -5,17 +5,24 @@
5
5
  #
6
6
  # Inputs (env, set by the executor):
7
7
  # KODY_ARG_GOAL the goal id (directory name under .kody/goals/)
8
+ # KODY_CFG_GIT_DEFAULTBRANCH the repo's default branch (usually main)
8
9
  #
9
10
  # What it does, per tick:
10
- # 1. Read .kody/goals/<id>/state.json. If missing or state != "active", exit.
11
- # 2. List issues with label `goal:<id>`.
12
- # 3. If every such issue is closed → set state=done, completedAt=now,
13
- # commit + push state.json, exit.
14
- # 4. Otherwise pick the lowest-numbered open issue without label
15
- # `goal-runner:dispatched`. Dispatch `@kody` on it (one new task per tick),
16
- # add the label so we don't re-dispatch on the next tick.
17
- # 5. Bump updatedAt and commit state.json (cheap, but lets the engine
18
- # track per-tick activity in the repo history).
11
+ # 1. Read .kody/goals/<id>/state.json. If missing or state == "abandoned",
12
+ # run cleanup (close goal PR, close open tasks) and set state=closed.
13
+ # 2. If state != "active", exit.
14
+ # 3. List issues with label `goal:<id>`.
15
+ # 4. If every such issue is closed → set state=done, open final goal-<id>
16
+ # default-branch PR (if not already open), commit + push state.json.
17
+ # 5. SERIALIZE: if any open issue still has the `goal-runner:dispatched`
18
+ # label, stay idle. We dispatch the next task only after the previous
19
+ # one has merged (issue closure follows PR merge via `Closes #N`).
20
+ # 6. If any issue carries `goal-runner:failed`, stay idle (a human must
21
+ # unblock by removing the label or closing the issue).
22
+ # 7. Otherwise pick the lowest-numbered open issue without
23
+ # `goal-runner:dispatched`. Comment `@kody --base goal-<id>` on it
24
+ # and add the label so we don't re-dispatch on the next tick.
25
+ # 8. Bump updatedAt and commit state.json (cheap audit trail).
19
26
  #
20
27
  # Stdout signals:
21
28
  # KODY_SKIP_AGENT=true — always; this is a no-agent flow.
@@ -24,6 +31,8 @@
24
31
  set -euo pipefail
25
32
 
26
33
  goal_id="${KODY_ARG_GOAL:-}"
34
+ default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-main}"
35
+
27
36
  if [ -z "$goal_id" ]; then
28
37
  echo "KODY_REASON=missing --goal"
29
38
  echo "KODY_SKIP_AGENT=true"
@@ -39,6 +48,10 @@ fi
39
48
 
40
49
  state_dir=".kody/goals/${goal_id}"
41
50
  state_file="${state_dir}/state.json"
51
+ goal_branch="goal-${goal_id}"
52
+ label="goal:${goal_id}"
53
+ dispatched_label="goal-runner:dispatched"
54
+ failed_label="goal-runner:failed"
42
55
 
43
56
  if [ ! -f "$state_file" ]; then
44
57
  echo "[goal-tick] no state file at $state_file — nothing to tick"
@@ -46,30 +59,106 @@ if [ ! -f "$state_file" ]; then
46
59
  exit 0
47
60
  fi
48
61
 
49
- # Read current state. python3 is already required by other shell executables
50
- # (e.g. release-prepare.sh), so we lean on it for JSON read/write.
62
+ # Read current state.
51
63
  state=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('state',''))" "$state_file")
52
64
 
65
+ # ── Helpers ───────────────────────────────────────────────────────────────────
66
+
67
+ set_state_field() {
68
+ # set_state_field <key> <value> — value is treated as a JSON string.
69
+ python3 - "$state_file" "$1" "$2" <<'PY'
70
+ import json, sys
71
+ from datetime import datetime, timezone
72
+ path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
73
+ with open(path) as f:
74
+ s = json.load(f)
75
+ s[key] = value
76
+ s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
77
+ with open(path, "w") as f:
78
+ json.dump(s, f, indent=2)
79
+ f.write("\n")
80
+ PY
81
+ }
82
+
83
+ bump_updated_at() {
84
+ python3 - "$state_file" <<'PY'
85
+ import json, sys
86
+ from datetime import datetime, timezone
87
+ path = sys.argv[1]
88
+ with open(path) as f:
89
+ s = json.load(f)
90
+ s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
91
+ with open(path, "w") as f:
92
+ json.dump(s, f, indent=2)
93
+ f.write("\n")
94
+ PY
95
+ }
96
+
97
+ commit_state() {
98
+ # commit_state <message> — best-effort commit + push.
99
+ git add "$state_file"
100
+ if ! git diff --cached --quiet; then
101
+ git commit -m "$1" --quiet
102
+ git push --quiet || echo "[goal-tick] push failed (will retry next tick)"
103
+ fi
104
+ }
105
+
106
+ ensure_label() {
107
+ # ensure_label <name> <color> <description> — best-effort, never fails the tick.
108
+ gh label create "$1" --color "$2" --description "$3" --force >/dev/null 2>&1 || true
109
+ }
110
+
111
+ list_goal_issues() {
112
+ # Up to 100 goal-labelled issues. PRs filtered out.
113
+ gh api \
114
+ "repos/{owner}/{repo}/issues?labels=${label}&state=all&per_page=100" \
115
+ --jq '[.[] | select(.pull_request == null) | {number, state: (.state | ascii_upcase), labels: [.labels[].name]}]'
116
+ }
117
+
118
+ # ── Cleanup path: state == abandoned ──────────────────────────────────────────
119
+
120
+ if [ "$state" = "abandoned" ]; then
121
+ echo "[goal-tick] $goal_id is abandoned — running cleanup"
122
+
123
+ # Close any open task issues with a brief note.
124
+ issues_json=$(list_goal_issues)
125
+ echo "$issues_json" | python3 -c "
126
+ import json, sys
127
+ data = json.load(sys.stdin)
128
+ for i in data:
129
+ if i['state'] == 'OPEN':
130
+ print(i['number'])
131
+ " | while read -r num; do
132
+ [ -n "$num" ] || continue
133
+ gh issue comment "$num" --body "_Goal abandoned — closing this task without dispatch._" >/dev/null 2>&1 || true
134
+ gh issue close "$num" --reason "not planned" >/dev/null 2>&1 || true
135
+ done
136
+
137
+ # Close the goal PR if one is open.
138
+ goal_pr=$(gh pr list --head "$goal_branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
139
+ if [ -n "$goal_pr" ]; then
140
+ gh pr close "$goal_pr" --comment "_Goal abandoned by operator — closing without merge._" >/dev/null 2>&1 || true
141
+ fi
142
+
143
+ set_state_field "state" "closed"
144
+ commit_state "chore(goals): abandon ${goal_id} (cleanup complete)"
145
+ echo "KODY_SKIP_AGENT=true"
146
+ exit 0
147
+ fi
148
+
53
149
  if [ "$state" != "active" ]; then
54
150
  echo "[goal-tick] $goal_id is '$state' — skipping"
55
151
  echo "KODY_SKIP_AGENT=true"
56
152
  exit 0
57
153
  fi
58
154
 
59
- label="goal:${goal_id}"
60
- dispatched_label="goal-runner:dispatched"
155
+ # ── Active path ───────────────────────────────────────────────────────────────
61
156
 
62
- # Fetch up to 100 goal-labelled issues. 100 is a soft ceiling — if a goal
63
- # grows past that, we'll need pagination.
64
- #
65
- # Use `gh api` (not `gh issue list --label`) because the latter chokes on
66
- # colons in label names, which is exactly the convention used here
67
- # (`goal:<id>` etc). `gh api` also lets us filter PRs out cleanly via the
68
- # `pull_request` field GitHub attaches to issue payloads.
69
- issues_json=$(gh api \
70
- "repos/{owner}/{repo}/issues?labels=${label}&state=all&per_page=100" \
71
- --jq '[.[] | select(.pull_request == null) | {number, state: (.state | ascii_upcase), labels: .labels}]')
157
+ # Make sure the dedup labels exist before we read/write them.
158
+ ensure_label "$dispatched_label" "ededed" "kody goal-runner: already dispatched this tick"
159
+ ensure_label "$failed_label" "b60205" "kody goal-runner: task failed; needs human attention"
72
160
 
161
+ issues_json=$(list_goal_issues)
73
162
  total=$(echo "$issues_json" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
74
163
  if [ "$total" = "0" ]; then
75
164
  echo "[goal-tick] no issues with label '$label' — leaving state untouched"
@@ -77,30 +166,81 @@ if [ "$total" = "0" ]; then
77
166
  exit 0
78
167
  fi
79
168
 
169
+ # Counts.
80
170
  open_count=$(echo "$issues_json" | python3 -c "import json,sys; print(sum(1 for i in json.load(sys.stdin) if i['state']=='OPEN'))")
81
171
 
82
- # All done? mark goal done and commit.
172
+ # All tasks closed goal is done. Open the final goal-<id> → default-branch PR.
83
173
  if [ "$open_count" = "0" ]; then
84
- echo "[goal-tick] all $total task(s) closed — marking goal done"
85
- python3 - "$state_file" <<'PY'
174
+ echo "[goal-tick] all $total task(s) closed — finalising goal"
175
+
176
+ # Open the goal PR if it doesn't already exist. We only care about origin/<goal_branch>:
177
+ # the goal branch should exist (created by goal-scheduler). If not, log and skip
178
+ # PR creation but still mark state=done so the goal moves out of `active`.
179
+ goal_pr_url=""
180
+ if git ls-remote --exit-code --heads origin "$goal_branch" >/dev/null 2>&1; then
181
+ existing_pr=$(gh pr list --head "$goal_branch" --state open --json number,url --jq '.[0]' 2>/dev/null || echo "")
182
+ if [ -z "$existing_pr" ] || [ "$existing_pr" = "null" ]; then
183
+ title="goal: ${goal_id}"
184
+ body=$(printf "Final integration PR for goal **%s**.\n\nAll task issues are closed and merged into \`%s\`. Ready for review.\n" "$goal_id" "$goal_branch")
185
+ goal_pr_url=$(gh pr create \
186
+ --head "$goal_branch" \
187
+ --base "$default_branch" \
188
+ --title "$title" \
189
+ --body "$body" 2>/dev/null || echo "")
190
+ else
191
+ goal_pr_url=$(echo "$existing_pr" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url',''))")
192
+ fi
193
+ else
194
+ echo "[goal-tick] goal branch ${goal_branch} not found on origin — skipping final PR"
195
+ fi
196
+
197
+ python3 - "$state_file" "$goal_pr_url" <<'PY'
86
198
  import json, sys
87
199
  from datetime import datetime, timezone
88
- path = sys.argv[1]
200
+ path, pr_url = sys.argv[1], sys.argv[2]
89
201
  with open(path) as f:
90
202
  s = json.load(f)
91
203
  now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
92
204
  s["state"] = "done"
93
205
  s["completedAt"] = now
94
206
  s["updatedAt"] = now
207
+ if pr_url:
208
+ s["goalPrUrl"] = pr_url
95
209
  with open(path, "w") as f:
96
210
  json.dump(s, f, indent=2)
97
211
  f.write("\n")
98
212
  PY
99
- git add "$state_file"
100
- if ! git diff --cached --quiet; then
101
- git commit -m "chore(goals): mark $goal_id done" --quiet
102
- git push --quiet || echo "[goal-tick] push failed (will retry next tick)"
103
- fi
213
+ commit_state "chore(goals): mark $goal_id done"
214
+ echo "KODY_SKIP_AGENT=true"
215
+ exit 0
216
+ fi
217
+
218
+ # Failure gate: any task carrying the failed label means a human must intervene.
219
+ failed_count=$(echo "$issues_json" | python3 -c "
220
+ import json, sys
221
+ data = json.load(sys.stdin)
222
+ print(sum(1 for i in data if 'goal-runner:failed' in i['labels']))
223
+ ")
224
+ if [ "$failed_count" != "0" ]; then
225
+ echo "[goal-tick] $failed_count failed task(s) — staying idle until cleared"
226
+ bump_updated_at
227
+ commit_state "chore(goals): tick $goal_id (blocked by failed task)"
228
+ echo "KODY_SKIP_AGENT=true"
229
+ exit 0
230
+ fi
231
+
232
+ # Serialize: if any dispatched task is still open, wait. The previous task is
233
+ # still merging. We dispatch the next one only after closure (which happens on
234
+ # PR merge into the goal branch via `Closes #N`).
235
+ in_flight=$(echo "$issues_json" | python3 -c "
236
+ import json, sys
237
+ data = json.load(sys.stdin)
238
+ print(sum(1 for i in data if i['state'] == 'OPEN' and 'goal-runner:dispatched' in i['labels']))
239
+ ")
240
+ if [ "$in_flight" != "0" ]; then
241
+ echo "[goal-tick] $in_flight task(s) in flight — waiting for current task to merge into ${goal_branch}"
242
+ bump_updated_at
243
+ commit_state "chore(goals): tick $goal_id (waiting for in-flight task)"
104
244
  echo "KODY_SKIP_AGENT=true"
105
245
  exit 0
106
246
  fi
@@ -112,44 +252,25 @@ data = json.load(sys.stdin)
112
252
  opens = [
113
253
  i for i in data
114
254
  if i['state'] == 'OPEN'
115
- and 'goal-runner:dispatched' not in [l['name'] for l in i.get('labels', [])]
255
+ and 'goal-runner:dispatched' not in i['labels']
116
256
  ]
117
257
  opens.sort(key=lambda x: x['number'])
118
258
  print(opens[0]['number'] if opens else '')
119
259
  ")
120
260
 
121
261
  if [ -z "$next_issue" ]; then
122
- echo "[goal-tick] all open tasks already dispatched waiting for them to complete"
123
- # Bump updatedAt so the dashboard can show "last ticked at" without parsing logs.
124
- python3 - "$state_file" <<'PY'
125
- import json, sys
126
- from datetime import datetime, timezone
127
- path = sys.argv[1]
128
- with open(path) as f:
129
- s = json.load(f)
130
- s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
131
- with open(path, "w") as f:
132
- json.dump(s, f, indent=2)
133
- f.write("\n")
134
- PY
135
- git add "$state_file"
136
- if ! git diff --cached --quiet; then
137
- git commit -m "chore(goals): tick $goal_id (idle)" --quiet
138
- git push --quiet || echo "[goal-tick] push failed (will retry next tick)"
139
- fi
262
+ echo "[goal-tick] no undispatched open taskidle"
263
+ bump_updated_at
264
+ commit_state "chore(goals): tick $goal_id (idle)"
140
265
  echo "KODY_SKIP_AGENT=true"
141
266
  exit 0
142
267
  fi
143
268
 
144
- echo "[goal-tick] dispatching @kody on task #$next_issue"
145
- gh issue comment "$next_issue" --body "@kody"
146
- # Ensure the dedup label exists in the repo. It lives outside the `kody:`
147
- # namespace so `ensureLabels` doesn't create it at init time, and a missing
148
- # label silently swallowed here would re-dispatch the same issue every tick.
149
- gh label create "$dispatched_label" --color ededed --description "kody goal-runner: already dispatched this tick" --force >/dev/null 2>&1 || true
269
+ echo "[goal-tick] dispatching @kody on task #$next_issue (--base $goal_branch)"
270
+ gh issue comment "$next_issue" --body "@kody --base ${goal_branch}"
150
271
  gh issue edit "$next_issue" --add-label "$dispatched_label"
151
272
 
152
- # Bump updatedAt so the file changes (cheap audit trail in git log).
273
+ # Bump updatedAt + record last dispatched issue.
153
274
  python3 - "$state_file" "$next_issue" <<'PY'
154
275
  import json, sys
155
276
  from datetime import datetime, timezone
@@ -163,11 +284,7 @@ with open(path, "w") as f:
163
284
  json.dump(s, f, indent=2)
164
285
  f.write("\n")
165
286
  PY
166
- git add "$state_file"
167
- if ! git diff --cached --quiet; then
168
- git commit -m "chore(goals): dispatched #${next_issue} for ${goal_id}" --quiet
169
- git push --quiet || echo "[goal-tick] push failed (will retry next tick)"
170
- fi
287
+ commit_state "chore(goals): dispatched #${next_issue} for ${goal_id}"
171
288
 
172
289
  echo "KODY_SKIP_AGENT=true"
173
290
  exit 0
@@ -9,6 +9,13 @@
9
9
  "type": "int",
10
10
  "required": true,
11
11
  "describe": "GitHub issue number to implement."
12
+ },
13
+ {
14
+ "name": "base",
15
+ "flag": "--base",
16
+ "type": "string",
17
+ "required": false,
18
+ "describe": "Optional base branch override. Must match /^goal-[a-z0-9-]+$/; used by goal-tick so the feature branch forks from the shared goal branch and the PR targets it."
12
19
  }
13
20
  ],
14
21
  "claudeCode": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
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",