@kody-ade/kody-engine 0.4.29 → 0.4.31

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.
@@ -42,9 +42,56 @@
42
42
  "outputArtifacts": [],
43
43
  "scripts": {
44
44
  "preflight": [
45
- { "shell": "tick.sh" },
45
+ { "script": "loadGoalState" },
46
+ {
47
+ "script": "handleAbandonedGoal",
48
+ "runWhen": { "data.goal.state": "abandoned" }
49
+ },
50
+ {
51
+ "script": "ensureLifecycleLabels",
52
+ "runWhen": { "data.goal.state": "active" }
53
+ },
54
+ {
55
+ "script": "ensureUmbrellaIssue",
56
+ "runWhen": { "data.goal.state": "active" }
57
+ },
58
+ {
59
+ "script": "ensureGoalPr",
60
+ "runWhen": { "data.goal.state": "active" }
61
+ },
62
+ {
63
+ "script": "mergeReadyTaskPRs",
64
+ "runWhen": { "data.goal.state": "active" }
65
+ },
66
+ {
67
+ "script": "ensureGoalPr",
68
+ "runWhen": { "data.goal.state": "active" }
69
+ },
70
+ {
71
+ "script": "closeMergedTaskIssues",
72
+ "runWhen": { "data.goal.state": "active" }
73
+ },
74
+ {
75
+ "script": "deriveGoalPhase",
76
+ "runWhen": { "data.goal.state": "active" }
77
+ },
78
+ {
79
+ "script": "finalizeGoal",
80
+ "runWhen": { "data.goal.phase": "all-done" }
81
+ },
82
+ {
83
+ "script": "ensureGoalBranch",
84
+ "runWhen": { "data.goal.phase": "ready-to-dispatch" }
85
+ },
86
+ {
87
+ "script": "dispatchNextTask",
88
+ "runWhen": { "data.goal.phase": "ready-to-dispatch" }
89
+ },
90
+ { "script": "saveGoalState" },
46
91
  { "script": "skipAgent" }
47
92
  ],
48
- "postflight": []
93
+ "postflight": [
94
+ { "script": "commitGoalState" }
95
+ ]
49
96
  }
50
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kody-ade/kody-engine",
3
- "version": "0.4.29",
3
+ "version": "0.4.31",
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",
@@ -1,596 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # goal-tick: one deterministic tick for one goal. No agent. Pure scripts +
4
- # `gh` against the connected repo.
5
- #
6
- # Inputs (env, set by the executor):
7
- # KODY_ARG_GOAL the goal id (directory name under .kody/goals/)
8
- # KODY_CFG_GIT_DEFAULTBRANCH the repo's default branch (usually main)
9
- #
10
- # What it does, per tick:
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).
26
- #
27
- # Stdout signals:
28
- # KODY_SKIP_AGENT=true — always; this is a no-agent flow.
29
- # KODY_REASON=<text> — failure context (rare; gh errors etc).
30
-
31
- set -euo pipefail
32
-
33
- goal_id="${KODY_ARG_GOAL:-}"
34
- # Default branch: prefer KODY_CFG_GIT_DEFAULTBRANCH (config), then ask the
35
- # repo via the GitHub API, finally fall back to "main". Past regression: a
36
- # hardcoded "main" fallback opened goal PRs against `main` for repos whose
37
- # real default is `dev`, which then needed manual retargeting.
38
- default_branch="${KODY_CFG_GIT_DEFAULTBRANCH:-}"
39
- if [ -z "$default_branch" ]; then
40
- default_branch=$(gh api "repos/{owner}/{repo}" --jq .default_branch 2>/dev/null || echo "")
41
- fi
42
- if [ -z "$default_branch" ]; then
43
- default_branch="main"
44
- fi
45
-
46
- if [ -z "$goal_id" ]; then
47
- echo "KODY_REASON=missing --goal"
48
- echo "KODY_SKIP_AGENT=true"
49
- exit 1
50
- fi
51
-
52
- # Defensive: refuse path traversal in goal id.
53
- if [[ "$goal_id" == *"/"* || "$goal_id" == *".."* ]]; then
54
- echo "KODY_REASON=invalid goal id (no slashes or '..' allowed)"
55
- echo "KODY_SKIP_AGENT=true"
56
- exit 1
57
- fi
58
-
59
- state_dir=".kody/goals/${goal_id}"
60
- state_file="${state_dir}/state.json"
61
- goal_branch="goal-${goal_id}"
62
- label="goal:${goal_id}"
63
- dispatched_label="goal-runner:dispatched"
64
- failed_label="goal-runner:failed"
65
-
66
- if [ ! -f "$state_file" ]; then
67
- echo "[goal-tick] no state file at $state_file — nothing to tick"
68
- echo "KODY_SKIP_AGENT=true"
69
- exit 0
70
- fi
71
-
72
- # Read current state.
73
- state=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('state',''))" "$state_file")
74
-
75
- # ── Helpers ───────────────────────────────────────────────────────────────────
76
-
77
- set_state_field() {
78
- # set_state_field <key> <value> — value is treated as a JSON string.
79
- python3 - "$state_file" "$1" "$2" <<'PY'
80
- import json, sys
81
- from datetime import datetime, timezone
82
- path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
83
- with open(path) as f:
84
- s = json.load(f)
85
- s[key] = value
86
- s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
87
- with open(path, "w") as f:
88
- json.dump(s, f, indent=2)
89
- f.write("\n")
90
- PY
91
- }
92
-
93
- bump_updated_at() {
94
- python3 - "$state_file" <<'PY'
95
- import json, sys
96
- from datetime import datetime, timezone
97
- path = sys.argv[1]
98
- with open(path) as f:
99
- s = json.load(f)
100
- s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
101
- with open(path, "w") as f:
102
- json.dump(s, f, indent=2)
103
- f.write("\n")
104
- PY
105
- }
106
-
107
- commit_state() {
108
- # commit_state <message> — best-effort commit + push.
109
- git add "$state_file"
110
- if ! git diff --cached --quiet; then
111
- git commit -m "$1" --quiet
112
- git push --quiet || echo "[goal-tick] push failed (will retry next tick)"
113
- fi
114
- }
115
-
116
- ensure_label() {
117
- # ensure_label <name> <color> <description> — best-effort, never fails the tick.
118
- gh label create "$1" --color "$2" --description "$3" --force >/dev/null 2>&1 || true
119
- }
120
-
121
- read_state_field() {
122
- # read_state_field <key> — prints the value or empty string. Never fails.
123
- python3 - "$state_file" "$1" <<'PY' 2>/dev/null || echo ""
124
- import json, sys
125
- path, key = sys.argv[1], sys.argv[2]
126
- try:
127
- with open(path) as f:
128
- s = json.load(f)
129
- v = s.get(key, "")
130
- print("" if v is None else v)
131
- except Exception:
132
- print("")
133
- PY
134
- }
135
-
136
- ensure_goal_issue() {
137
- # Create-or-adopt the umbrella goal issue (once), label it goal:<id> +
138
- # kody:building, and persist its number on state.json. The issue auto-closes
139
- # when the final goal PR merges, via the `Closes #N` line we add to that PR
140
- # body.
141
- #
142
- # Lookup order:
143
- # 1. state.json `goalIssueNumber` — fast path.
144
- # 2. Search GitHub for an existing umbrella by label `goal:<id>` + the
145
- # canonical title `goal: <goal_id>`. This is the recovery path when
146
- # state.json got wiped (e.g. dashboard pause/resume dropped the field
147
- # in older versions). Without this lookup we'd open a duplicate
148
- # umbrella every time goalIssueNumber goes missing.
149
- # 3. Create a fresh umbrella as a last resort.
150
- local existing
151
- existing=$(read_state_field "goalIssueNumber")
152
- if [ -n "$existing" ] && [ "$existing" != "0" ]; then
153
- return 0
154
- fi
155
-
156
- ensure_label "$label" "0e8a16" "kody goal task: belongs to goal ${goal_id}"
157
- ensure_label "kody:building" "1d76db" "kody: in-flight (work being assembled on a branch)"
158
-
159
- local title body num
160
- title="goal: ${goal_id}"
161
- body=$(printf "Umbrella issue for goal **%s**.\n\nClosed automatically when the goal PR (\`%s\` → \`%s\`) merges.\n" \
162
- "$goal_id" "$goal_branch" "$default_branch")
163
-
164
- # Recovery path: an umbrella may already exist from a prior run that lost
165
- # state. Match strictly by label + exact title to avoid grabbing a child
166
- # task issue. Prefer OPEN issues; fall back to closed ones (the umbrella
167
- # could have been closed by a prior goal PR merge that we're now re-driving).
168
- num=$(gh api \
169
- "repos/{owner}/{repo}/issues?labels=${label}&state=all&per_page=100" \
170
- --jq "[.[] | select(.pull_request == null) | select(.title == \"${title}\")] | (map(select(.state == \"open\")) + map(select(.state != \"open\")))[0].number // empty" \
171
- 2>/dev/null || echo "")
172
-
173
- if [ -n "$num" ] && [[ "$num" =~ ^[0-9]+$ ]]; then
174
- echo "[goal-tick] adopted existing umbrella issue #${num} for ${goal_id}"
175
- else
176
- # `gh issue create` prints the new issue's URL on stdout
177
- # (https://github.com/<owner>/<repo>/issues/<n>). It does NOT support
178
- # --json/--jq, so parse the trailing number off the URL.
179
- local url
180
- url=$(gh issue create \
181
- --title "$title" \
182
- --body "$body" \
183
- --label "$label" \
184
- --label "kody:building" 2>/dev/null || echo "")
185
-
186
- num="${url##*/}"
187
- if [ -z "$num" ] || ! [[ "$num" =~ ^[0-9]+$ ]]; then
188
- echo "[goal-tick] ensure_goal_issue: gh issue create failed (got '${url}') — continuing without umbrella issue"
189
- return 0
190
- fi
191
- echo "[goal-tick] opened umbrella issue #${num} for ${goal_id}"
192
- fi
193
-
194
- python3 - "$state_file" "$num" <<'PY'
195
- import json, sys
196
- from datetime import datetime, timezone
197
- path = sys.argv[1]
198
- n = int(sys.argv[2])
199
- with open(path) as f:
200
- s = json.load(f)
201
- s["goalIssueNumber"] = n
202
- s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
203
- with open(path, "w") as f:
204
- json.dump(s, f, indent=2)
205
- f.write("\n")
206
- PY
207
- }
208
-
209
- ensure_goal_pr() {
210
- # Open a draft goal PR (`goal-<id>` → default branch) early in the goal's
211
- # life so the dashboard has a single anchor that ties together the umbrella
212
- # issue, the goal branch, and the Vercel preview deploy. Without this PR,
213
- # the umbrella issue is just a label-tagged issue with no link to its
214
- # branch, so the dashboard can't surface preview/CI/branch on the umbrella
215
- # row.
216
- #
217
- # Lifecycle:
218
- # - Created here as DRAFT on every active tick once origin/<goal_branch>
219
- # exists. Body carries `Closes #<umbrellaNumber>` so the umbrella
220
- # auto-closes on merge.
221
- # - Promoted to ready-for-review by the finalize path when all child
222
- # tasks close (see below).
223
- #
224
- # Lookup order:
225
- # 1. state.json `goalPrUrl` — fast path; skip if already populated.
226
- # 2. `gh pr list --head <goal_branch>` — recovery path when state.json
227
- # lost the field (e.g. older goals from before this change).
228
- # 3. Create a fresh draft PR.
229
- local existing_url existing_num
230
- existing_url=$(read_state_field "goalPrUrl")
231
- if [ -n "$existing_url" ]; then
232
- return 0
233
- fi
234
-
235
- # Goal branch must exist on origin before we can open a PR.
236
- if ! git ls-remote --exit-code --heads origin "$goal_branch" >/dev/null 2>&1; then
237
- return 0
238
- fi
239
-
240
- # Recovery: PR may already exist from a prior tick that didn't persist the
241
- # URL. Match by head ref.
242
- existing_num=$(gh pr list --head "$goal_branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
243
- if [ -n "$existing_num" ] && [[ "$existing_num" =~ ^[0-9]+$ ]]; then
244
- existing_url=$(gh pr view "$existing_num" --json url --jq .url 2>/dev/null || echo "")
245
- else
246
- local title body goal_issue_number
247
- title="goal: ${goal_id}"
248
- goal_issue_number=$(read_state_field "goalIssueNumber")
249
- if [ -n "$goal_issue_number" ] && [ "$goal_issue_number" != "0" ]; then
250
- body=$(printf "Tracking integration PR for goal **%s**.\n\nChild task PRs merge into \`%s\`. This PR is held in **draft** until every task is complete, then promoted to ready-for-review by goal-tick.\n\nCloses #%s\n" "$goal_id" "$goal_branch" "$goal_issue_number")
251
- else
252
- body=$(printf "Tracking integration PR for goal **%s**.\n\nChild task PRs merge into \`%s\`. Held in **draft** until every task is complete.\n" "$goal_id" "$goal_branch")
253
- fi
254
- existing_url=$(gh pr create \
255
- --draft \
256
- --head "$goal_branch" \
257
- --base "$default_branch" \
258
- --title "$title" \
259
- --body "$body" 2>/dev/null || echo "")
260
- if [ -z "$existing_url" ]; then
261
- echo "[goal-tick] ensure_goal_pr: gh pr create failed (continuing without goal PR)"
262
- return 0
263
- fi
264
- echo "[goal-tick] opened draft goal PR ${existing_url} for ${goal_id}"
265
- fi
266
-
267
- # Persist URL into state.json so subsequent ticks skip the lookup.
268
- set_state_field "goalPrUrl" "$existing_url"
269
- }
270
-
271
- list_goal_issues() {
272
- # Up to 100 goal-labelled issues. PRs filtered out. Also filters out the
273
- # umbrella goal issue (if any) — it shares the `goal:<id>` label so the
274
- # dashboard groups it under the goal, but it must NOT count as a child
275
- # task: while the umbrella is open the "all child tasks closed" finalize
276
- # check would never fire (the umbrella only closes on goal-PR merge,
277
- # which only happens during finalize — deadlock).
278
- local exclude
279
- exclude=$(read_state_field "goalIssueNumber")
280
- gh api \
281
- "repos/{owner}/{repo}/issues?labels=${label}&state=all&per_page=100" \
282
- --jq '[.[] | select(.pull_request == null) | {number, state: (.state | ascii_upcase), labels: [.labels[].name]}]' \
283
- | EXCLUDE="$exclude" python3 -c "
284
- import json, os, sys
285
- data = json.load(sys.stdin)
286
- ex = os.environ.get('EXCLUDE', '')
287
- if ex:
288
- try:
289
- ex_num = int(ex)
290
- data = [i for i in data if i['number'] != ex_num]
291
- except ValueError:
292
- pass
293
- print(json.dumps(data))
294
- "
295
- }
296
-
297
- # ── Cleanup path: state == abandoned ──────────────────────────────────────────
298
-
299
- if [ "$state" = "abandoned" ]; then
300
- echo "[goal-tick] $goal_id is abandoned — running cleanup"
301
-
302
- # Close any open task issues with a brief note.
303
- issues_json=$(list_goal_issues)
304
- echo "$issues_json" | python3 -c "
305
- import json, sys
306
- data = json.load(sys.stdin)
307
- for i in data:
308
- if i['state'] == 'OPEN':
309
- print(i['number'])
310
- " | while read -r num; do
311
- [ -n "$num" ] || continue
312
- gh issue comment "$num" --body "_Goal abandoned — closing this task without dispatch._" >/dev/null 2>&1 || true
313
- gh issue close "$num" --reason "not planned" >/dev/null 2>&1 || true
314
- done
315
-
316
- # Close the goal PR if one is open.
317
- goal_pr=$(gh pr list --head "$goal_branch" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "")
318
- if [ -n "$goal_pr" ]; then
319
- gh pr close "$goal_pr" --comment "_Goal abandoned by operator — closing without merge._" >/dev/null 2>&1 || true
320
- fi
321
-
322
- set_state_field "state" "closed"
323
- commit_state "chore(goals): abandon ${goal_id} (cleanup complete)"
324
- echo "KODY_SKIP_AGENT=true"
325
- exit 0
326
- fi
327
-
328
- if [ "$state" != "active" ]; then
329
- echo "[goal-tick] $goal_id is '$state' — skipping"
330
- echo "KODY_SKIP_AGENT=true"
331
- exit 0
332
- fi
333
-
334
- # ── Active path ───────────────────────────────────────────────────────────────
335
-
336
- # Make sure the dedup labels exist before we read/write them.
337
- ensure_label "$dispatched_label" "ededed" "kody goal-runner: already dispatched this tick"
338
- ensure_label "$failed_label" "b60205" "kody goal-runner: task failed; needs human attention"
339
-
340
- # Open the umbrella goal issue on the first active tick (idempotent — no-op if
341
- # state.json already has goalIssueNumber). The dashboard renders this issue as
342
- # the goal's "row" with kody:building status; it auto-closes when the final
343
- # goal PR merges via the `Closes #N` we add to that PR's body. Doing this here
344
- # (before list_goal_issues) ensures the umbrella exists before we start
345
- # counting child tasks, so list_goal_issues can filter it out cleanly.
346
- ensure_goal_issue
347
-
348
- # Open the draft goal PR if the goal branch already exists. Must run BEFORE
349
- # any of the early exits below (in_flight check, no-undispatched-task idle,
350
- # etc.) — otherwise active goals that always have a task in flight would
351
- # never get past the in_flight gate to reach the late call site, leaving
352
- # the umbrella row without its branch + preview anchor in the dashboard.
353
- # `ensure_goal_pr` is a safe no-op when the branch hasn't been created yet
354
- # (the lazy-branch-creation block at the dispatch site handles that case;
355
- # the next tick picks up the PR creation here).
356
- ensure_goal_pr
357
-
358
- # Merge ready goal-task PRs into the goal branch. We own the merge here
359
- # instead of relying on GitHub's `--auto` flag (which requires the repo's
360
- # "Allow auto-merge" setting and silently no-ops when disabled). Only merge
361
- # non-draft PRs with mergeable=MERGEABLE and mergeStateStatus=CLEAN — i.e.
362
- # all required checks passed and there are no conflicts. Anything else
363
- # (BLOCKED, DIRTY, BEHIND, UNSTABLE, draft) is left for the operator.
364
- open_prs=$(gh pr list --base "$goal_branch" --state open --limit 50 \
365
- --json number,isDraft,mergeable,mergeStateStatus 2>/dev/null || echo "[]")
366
- echo "$open_prs" | python3 -c "
367
- import json, sys
368
- data = json.load(sys.stdin)
369
- for pr in data:
370
- if pr.get('isDraft'): continue
371
- if pr.get('mergeable') != 'MERGEABLE': continue
372
- if pr.get('mergeStateStatus') != 'CLEAN': continue
373
- print(pr['number'])
374
- " | while read -r pr_num; do
375
- [ -n "$pr_num" ] || continue
376
- echo "[goal-tick] merging PR #${pr_num} into ${goal_branch}"
377
- if ! gh pr merge "$pr_num" --squash --delete-branch >/dev/null 2>&1; then
378
- echo "[goal-tick] failed to merge PR #${pr_num} (continuing)"
379
- fi
380
- done
381
-
382
- # Retry ensure_goal_pr after the merge step. The early call at line ~356
383
- # fails when the goal branch has 0 commits ahead of default. If THIS tick
384
- # just merged a task PR, the branch now has a delta and `gh pr create`
385
- # will succeed. Idempotent — early-returns if the PR was already created.
386
- ensure_goal_pr
387
-
388
- # Close dispatched task issues whose PR has merged into the goal branch.
389
- # `Closes #N` in the PR body only auto-closes the issue when the PR merges
390
- # into the default branch — goal-task PRs target the goal branch, so we must
391
- # close the issues explicitly. Without this, in_flight stays > 0 forever and
392
- # the goal stalls after task 1. We accept the linkage from either:
393
- # - `Closes|Fixes|Resolves #N` in the PR body (authoritative), OR
394
- # - leading number on the head ref (kody convention: `<issue>-<slug>`).
395
- merged_prs=$(gh pr list --base "$goal_branch" --state merged --limit 50 --json number,headRefName,body 2>/dev/null || echo "[]")
396
- echo "$merged_prs" | python3 -c "
397
- import json, re, sys
398
- data = json.load(sys.stdin)
399
- seen = set()
400
- for pr in data:
401
- n = None
402
- body = pr.get('body') or ''
403
- m = re.search(r'(?i)\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b', body)
404
- if m:
405
- n = int(m.group(1))
406
- else:
407
- bm = re.match(r'^(\d+)-', pr.get('headRefName') or '')
408
- if bm:
409
- n = int(bm.group(1))
410
- if n and n not in seen:
411
- seen.add(n)
412
- print(n)
413
- " | while read -r issue_num; do
414
- [ -n "$issue_num" ] || continue
415
- state=$(gh issue view "$issue_num" --json state --jq .state 2>/dev/null || echo "")
416
- if [ "$state" = "OPEN" ]; then
417
- echo "[goal-tick] closing #${issue_num} (PR merged into ${goal_branch})"
418
- gh issue close "$issue_num" \
419
- --comment "_Closed by goal-tick: PR for this task merged into \`${goal_branch}\`._" \
420
- >/dev/null 2>&1 || echo "[goal-tick] failed to close #${issue_num} (continuing)"
421
- fi
422
- done
423
-
424
- issues_json=$(list_goal_issues)
425
- total=$(echo "$issues_json" | python3 -c "import json,sys; print(len(json.load(sys.stdin)))")
426
- if [ "$total" = "0" ]; then
427
- echo "[goal-tick] no issues with label '$label' — leaving state untouched"
428
- echo "KODY_SKIP_AGENT=true"
429
- exit 0
430
- fi
431
-
432
- # Counts.
433
- open_count=$(echo "$issues_json" | python3 -c "import json,sys; print(sum(1 for i in json.load(sys.stdin) if i['state']=='OPEN'))")
434
-
435
- # All tasks closed → goal is done. Open the final goal-<id> → default-branch PR.
436
- if [ "$open_count" = "0" ]; then
437
- echo "[goal-tick] all $total task(s) closed — finalising goal"
438
-
439
- # Promote (or open) the goal PR. The active path opens this PR as a draft
440
- # once the goal branch exists, so by finalize it almost always already
441
- # exists — we just mark it ready-for-review and refresh the body. Older
442
- # goals from before the early-PR change may still need first-time creation
443
- # here as a fallback.
444
- goal_pr_url=""
445
- if git ls-remote --exit-code --heads origin "$goal_branch" >/dev/null 2>&1; then
446
- existing_pr=$(gh pr list --head "$goal_branch" --state open --json number,url,isDraft --jq '.[0]' 2>/dev/null || echo "")
447
- title="goal: ${goal_id}"
448
- goal_issue_number=$(read_state_field "goalIssueNumber")
449
- # `Closes #N` auto-closes the umbrella goal issue on PR merge — that's
450
- # how the dashboard learns the goal is done. Skip the line gracefully
451
- # if no umbrella issue was ever opened (older goals from before this
452
- # change, or `gh issue create` failed silently during a tick).
453
- if [ -n "$goal_issue_number" ] && [ "$goal_issue_number" != "0" ]; then
454
- body=$(printf "Final integration PR for goal **%s**.\n\nAll task issues are closed and merged into \`%s\`. Ready for review.\n\nCloses #%s\n" "$goal_id" "$goal_branch" "$goal_issue_number")
455
- else
456
- 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")
457
- fi
458
- if [ -z "$existing_pr" ] || [ "$existing_pr" = "null" ]; then
459
- goal_pr_url=$(gh pr create \
460
- --head "$goal_branch" \
461
- --base "$default_branch" \
462
- --title "$title" \
463
- --body "$body" 2>/dev/null || echo "")
464
- else
465
- existing_num=$(echo "$existing_pr" | python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))")
466
- goal_pr_url=$(echo "$existing_pr" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url',''))")
467
- is_draft=$(echo "$existing_pr" | python3 -c "import json,sys; print('true' if json.load(sys.stdin).get('isDraft') else 'false')")
468
- # Refresh the body with the finalize copy so reviewers see the right
469
- # framing. Best-effort — failure is non-fatal.
470
- gh pr edit "$existing_num" --body "$body" >/dev/null 2>&1 || true
471
- if [ "$is_draft" = "true" ]; then
472
- echo "[goal-tick] promoting draft goal PR #${existing_num} to ready-for-review"
473
- gh pr ready "$existing_num" >/dev/null 2>&1 \
474
- || echo "[goal-tick] failed to mark PR #${existing_num} ready (continuing)"
475
- fi
476
- fi
477
- else
478
- echo "[goal-tick] goal branch ${goal_branch} not found on origin — skipping final PR"
479
- fi
480
-
481
- python3 - "$state_file" "$goal_pr_url" <<'PY'
482
- import json, sys
483
- from datetime import datetime, timezone
484
- path, pr_url = sys.argv[1], sys.argv[2]
485
- with open(path) as f:
486
- s = json.load(f)
487
- now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
488
- s["state"] = "done"
489
- s["completedAt"] = now
490
- s["updatedAt"] = now
491
- if pr_url:
492
- s["goalPrUrl"] = pr_url
493
- with open(path, "w") as f:
494
- json.dump(s, f, indent=2)
495
- f.write("\n")
496
- PY
497
- commit_state "chore(goals): mark $goal_id done"
498
- echo "KODY_SKIP_AGENT=true"
499
- exit 0
500
- fi
501
-
502
- # Failure gate: any task carrying the failed label means a human must intervene.
503
- failed_count=$(echo "$issues_json" | python3 -c "
504
- import json, sys
505
- data = json.load(sys.stdin)
506
- print(sum(1 for i in data if 'goal-runner:failed' in i['labels']))
507
- ")
508
- if [ "$failed_count" != "0" ]; then
509
- echo "[goal-tick] $failed_count failed task(s) — staying idle until cleared"
510
- bump_updated_at
511
- commit_state "chore(goals): tick $goal_id (blocked by failed task)"
512
- echo "KODY_SKIP_AGENT=true"
513
- exit 0
514
- fi
515
-
516
- # Serialize: if any dispatched task is still open, wait. The previous task is
517
- # still merging. We dispatch the next one only after closure (which happens on
518
- # PR merge into the goal branch via `Closes #N`).
519
- in_flight=$(echo "$issues_json" | python3 -c "
520
- import json, sys
521
- data = json.load(sys.stdin)
522
- print(sum(1 for i in data if i['state'] == 'OPEN' and 'goal-runner:dispatched' in i['labels']))
523
- ")
524
- if [ "$in_flight" != "0" ]; then
525
- echo "[goal-tick] $in_flight task(s) in flight — waiting for current task to merge into ${goal_branch}"
526
- bump_updated_at
527
- commit_state "chore(goals): tick $goal_id (waiting for in-flight task)"
528
- echo "KODY_SKIP_AGENT=true"
529
- exit 0
530
- fi
531
-
532
- # Pick the lowest-numbered open issue without the dispatched marker.
533
- next_issue=$(echo "$issues_json" | python3 -c "
534
- import json, sys
535
- data = json.load(sys.stdin)
536
- opens = [
537
- i for i in data
538
- if i['state'] == 'OPEN'
539
- and 'goal-runner:dispatched' not in i['labels']
540
- ]
541
- opens.sort(key=lambda x: x['number'])
542
- print(opens[0]['number'] if opens else '')
543
- ")
544
-
545
- if [ -z "$next_issue" ]; then
546
- echo "[goal-tick] no undispatched open task — idle"
547
- bump_updated_at
548
- commit_state "chore(goals): tick $goal_id (idle)"
549
- echo "KODY_SKIP_AGENT=true"
550
- exit 0
551
- fi
552
-
553
- # Lazy goal-branch creation: only spin up origin/goal-<id> at the moment we're
554
- # about to dispatch the first task. Goals whose ticks never dispatch (every
555
- # task closed as won't-fix, or every open task already has goal-runner:failed)
556
- # never produce an orphan branch on origin. Idempotent: if origin/goal-<id>
557
- # already exists we skip. Failure here is non-fatal — ensureFeatureBranch in
558
- # the dispatched run falls back to forking from defaultBranch.
559
- git fetch origin --quiet 2>/dev/null || true
560
- if git rev-parse --verify --quiet "refs/remotes/origin/${goal_branch}" >/dev/null 2>&1; then
561
- echo "[goal-tick] origin/${goal_branch} already exists — leaving as-is"
562
- else
563
- if ! git rev-parse --verify --quiet "refs/remotes/origin/${default_branch}" >/dev/null 2>&1; then
564
- echo "[goal-tick] cannot create goal branch: origin/${default_branch} missing"
565
- else
566
- echo "[goal-tick] creating origin/${goal_branch} from origin/${default_branch}"
567
- if ! git push origin "refs/remotes/origin/${default_branch}:refs/heads/${goal_branch}" --quiet 2>&1; then
568
- echo "[goal-tick] push of ${goal_branch} failed — task dispatch will fall back to defaultBranch"
569
- fi
570
- fi
571
- fi
572
- # (`ensure_goal_pr` runs at the top of the active path so it's reached even
573
- # when this tick exits early via the in_flight gate; not duplicated here.)
574
-
575
- echo "[goal-tick] dispatching @kody on task #$next_issue (--base $goal_branch)"
576
- gh issue comment "$next_issue" --body "@kody --base ${goal_branch}"
577
- gh issue edit "$next_issue" --add-label "$dispatched_label"
578
-
579
- # Bump updatedAt + record last dispatched issue.
580
- python3 - "$state_file" "$next_issue" <<'PY'
581
- import json, sys
582
- from datetime import datetime, timezone
583
- path = sys.argv[1]
584
- issue = int(sys.argv[2])
585
- with open(path) as f:
586
- s = json.load(f)
587
- s["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
588
- s["lastDispatchedIssue"] = issue
589
- with open(path, "w") as f:
590
- json.dump(s, f, indent=2)
591
- f.write("\n")
592
- PY
593
- commit_state "chore(goals): dispatched #${next_issue} for ${goal_id}"
594
-
595
- echo "KODY_SKIP_AGENT=true"
596
- exit 0