@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.
- package/dist/bin/kody.js +1213 -406
- package/dist/executables/goal-tick/profile.json +49 -2
- package/package.json +1 -1
- package/dist/executables/goal-tick/tick.sh +0 -596
|
@@ -42,9 +42,56 @@
|
|
|
42
42
|
"outputArtifacts": [],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"preflight": [
|
|
45
|
-
{ "
|
|
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.
|
|
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
|