@kody-ade/kody-engine 0.4.86 → 0.4.88
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
|
@@ -877,7 +877,7 @@ var init_loadPriorArt = __esm({
|
|
|
877
877
|
// package.json
|
|
878
878
|
var package_default = {
|
|
879
879
|
name: "@kody-ade/kody-engine",
|
|
880
|
-
version: "0.4.
|
|
880
|
+
version: "0.4.88",
|
|
881
881
|
description: "kody \u2014 autonomous development engine. Single-session Claude Code agent behind a generic executor + declarative executable profiles.",
|
|
882
882
|
license: "MIT",
|
|
883
883
|
type: "module",
|
|
@@ -3307,6 +3307,10 @@ var CONVENTIONAL_PREFIXES = [
|
|
|
3307
3307
|
"build:",
|
|
3308
3308
|
"revert:"
|
|
3309
3309
|
];
|
|
3310
|
+
var PUSH_RETRY_DELAYS_MS = [2e3, 4e3, 8e3];
|
|
3311
|
+
function sleepSync(ms) {
|
|
3312
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
3313
|
+
}
|
|
3310
3314
|
function git(args, cwd) {
|
|
3311
3315
|
try {
|
|
3312
3316
|
return execFileSync5("git", args, {
|
|
@@ -3424,25 +3428,28 @@ function commitAndPush(branch, agentMessage, cwd) {
|
|
|
3424
3428
|
throw err;
|
|
3425
3429
|
}
|
|
3426
3430
|
const sha = git(["rev-parse", "HEAD"], cwd).slice(0, 7);
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
return { committed: true, pushed: true, sha, message };
|
|
3430
|
-
} catch (firstErr) {
|
|
3431
|
+
let pushError = "push failed (no error detail)";
|
|
3432
|
+
for (let attempt = 0; attempt <= PUSH_RETRY_DELAYS_MS.length; attempt++) {
|
|
3431
3433
|
try {
|
|
3432
|
-
git(["push", "
|
|
3434
|
+
git(["push", "-u", "origin", branch], cwd);
|
|
3433
3435
|
return { committed: true, pushed: true, sha, message };
|
|
3434
|
-
} catch (
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3436
|
+
} catch (firstErr) {
|
|
3437
|
+
try {
|
|
3438
|
+
git(["push", "--force-with-lease", "-u", "origin", branch], cwd);
|
|
3439
|
+
return { committed: true, pushed: true, sha, message };
|
|
3440
|
+
} catch (secondErr) {
|
|
3441
|
+
const tail = (secondErr instanceof Error ? secondErr.message : String(secondErr)).slice(-400);
|
|
3442
|
+
const initial = firstErr instanceof Error ? firstErr.message : String(firstErr);
|
|
3443
|
+
pushError = `push failed: ${initial.slice(-200)} | force-with-lease failed: ${tail}`;
|
|
3444
|
+
const delay = PUSH_RETRY_DELAYS_MS[attempt];
|
|
3445
|
+
if (delay === void 0) break;
|
|
3446
|
+
process.stderr.write(`[kody:commit] push failed (attempt ${attempt + 1}); retrying in ${delay}ms
|
|
3447
|
+
`);
|
|
3448
|
+
sleepSync(delay);
|
|
3449
|
+
}
|
|
3444
3450
|
}
|
|
3445
3451
|
}
|
|
3452
|
+
return { committed: true, pushed: false, sha, message, pushError };
|
|
3446
3453
|
}
|
|
3447
3454
|
function hasCommitsAhead(branch, defaultBranch2, cwd) {
|
|
3448
3455
|
try {
|
|
@@ -5190,6 +5197,7 @@ init_issue();
|
|
|
5190
5197
|
function goalLabel(goalId) {
|
|
5191
5198
|
return `goal:${goalId}`;
|
|
5192
5199
|
}
|
|
5200
|
+
var QA_GATE_LABEL = "kody:qa-gate";
|
|
5193
5201
|
|
|
5194
5202
|
// src/goal/operations.ts
|
|
5195
5203
|
function fail(err) {
|
|
@@ -5206,7 +5214,7 @@ function listGoalIssues(goalId, cwd) {
|
|
|
5206
5214
|
"api",
|
|
5207
5215
|
`repos/{owner}/{repo}/issues?labels=${goalLabel(goalId)}&state=all&per_page=100`,
|
|
5208
5216
|
"--jq",
|
|
5209
|
-
|
|
5217
|
+
`[.[] | select(.pull_request == null) | {number, state: (.state | ascii_upcase), isQaGate: ([.labels[].name] | any(. == "${QA_GATE_LABEL}"))}]`
|
|
5210
5218
|
],
|
|
5211
5219
|
{ cwd }
|
|
5212
5220
|
);
|
|
@@ -5242,7 +5250,7 @@ function pairIssuesWithPrs(issues, openPrs) {
|
|
|
5242
5250
|
const pr = prByIssue.get(i.number);
|
|
5243
5251
|
let prState = "absent";
|
|
5244
5252
|
if (pr) prState = pr.isDraft ? "draft" : "ready";
|
|
5245
|
-
return { number: i.number, state: i.state, prState };
|
|
5253
|
+
return { number: i.number, state: i.state, prState, isQaGate: i.isQaGate };
|
|
5246
5254
|
});
|
|
5247
5255
|
}
|
|
5248
5256
|
function claimPrForIssue(pr, prByIssue) {
|
|
@@ -5302,21 +5310,30 @@ function closePr(prNumber, comment, cwd) {
|
|
|
5302
5310
|
return fail(err);
|
|
5303
5311
|
}
|
|
5304
5312
|
}
|
|
5305
|
-
function
|
|
5313
|
+
function editPrBase(prNumber, baseBranch, cwd) {
|
|
5306
5314
|
try {
|
|
5307
|
-
gh(
|
|
5315
|
+
gh(
|
|
5316
|
+
["api", "--method", "PATCH", `repos/{owner}/{repo}/pulls/${prNumber}`, "-f", `base=${baseBranch}`],
|
|
5317
|
+
{ cwd }
|
|
5318
|
+
);
|
|
5308
5319
|
return { ok: true };
|
|
5309
5320
|
} catch (err) {
|
|
5310
5321
|
return fail(err);
|
|
5311
5322
|
}
|
|
5312
5323
|
}
|
|
5313
|
-
function
|
|
5324
|
+
function branchContains(leafHead, candidateHead, cwd) {
|
|
5325
|
+
if (leafHead === candidateHead) return { ok: true, value: true };
|
|
5314
5326
|
try {
|
|
5315
|
-
gh(
|
|
5316
|
-
[
|
|
5327
|
+
const out = gh(
|
|
5328
|
+
[
|
|
5329
|
+
"api",
|
|
5330
|
+
`repos/{owner}/{repo}/compare/${encodeURIComponent(leafHead)}...${encodeURIComponent(candidateHead)}`,
|
|
5331
|
+
"--jq",
|
|
5332
|
+
".ahead_by"
|
|
5333
|
+
],
|
|
5317
5334
|
{ cwd }
|
|
5318
5335
|
);
|
|
5319
|
-
return { ok: true };
|
|
5336
|
+
return { ok: true, value: Number.parseInt(out.trim(), 10) === 0 };
|
|
5320
5337
|
} catch (err) {
|
|
5321
5338
|
return fail(err);
|
|
5322
5339
|
}
|
|
@@ -5336,17 +5353,19 @@ function derivePhase(snap) {
|
|
|
5336
5353
|
if (snap.lifecycleState === "abandoned") return "abandoned";
|
|
5337
5354
|
if (snap.lifecycleState === "closed" || snap.lifecycleState === "done") return "terminal";
|
|
5338
5355
|
if (snap.lifecycleState === "awaiting-merge") return "awaiting-merge";
|
|
5339
|
-
const
|
|
5356
|
+
const tasks = snap.childTasks.filter((t) => !t.isQaGate);
|
|
5357
|
+
const qaGateOpen = snap.childTasks.some((t) => t.isQaGate && t.state === "OPEN");
|
|
5358
|
+
const hasInFlight = tasks.some((t) => t.state === "OPEN" && t.prState === "draft");
|
|
5340
5359
|
if (hasInFlight) return "in-flight";
|
|
5341
|
-
if (
|
|
5342
|
-
const
|
|
5343
|
-
if (allDone) return "all-done";
|
|
5344
|
-
const dispatchable = snap.childTasks.some((t) => t.state === "OPEN" && t.prState === "absent");
|
|
5360
|
+
if (tasks.length === 0) return "idle";
|
|
5361
|
+
const dispatchable = tasks.some((t) => t.state === "OPEN" && t.prState === "absent");
|
|
5345
5362
|
if (dispatchable) return "ready-to-dispatch";
|
|
5363
|
+
const allDone = tasks.every((t) => t.state === "CLOSED" || t.prState === "ready");
|
|
5364
|
+
if (allDone) return qaGateOpen ? "idle" : "all-done";
|
|
5346
5365
|
return "idle";
|
|
5347
5366
|
}
|
|
5348
5367
|
function pickNextDispatchable(snap) {
|
|
5349
|
-
return snap.childTasks.filter((t) => t.state === "OPEN" && t.prState === "absent").sort((a, b) => a.number - b.number)[0];
|
|
5368
|
+
return snap.childTasks.filter((t) => !t.isQaGate && t.state === "OPEN" && t.prState === "absent").sort((a, b) => a.number - b.number)[0];
|
|
5350
5369
|
}
|
|
5351
5370
|
|
|
5352
5371
|
// src/scripts/deriveGoalPhase.ts
|
|
@@ -6926,14 +6945,23 @@ function collectExpectedTests(raw) {
|
|
|
6926
6945
|
}
|
|
6927
6946
|
|
|
6928
6947
|
// src/scripts/finalizeGoal.ts
|
|
6948
|
+
function prIssueNumbers(pr) {
|
|
6949
|
+
const nums = new Set(extractClosesIssues(pr.body));
|
|
6950
|
+
const headMatch = pr.headRefName.match(/^(\d+)-/);
|
|
6951
|
+
if (headMatch) {
|
|
6952
|
+
const n = Number.parseInt(headMatch[1], 10);
|
|
6953
|
+
if (Number.isFinite(n)) nums.add(n);
|
|
6954
|
+
}
|
|
6955
|
+
return [...nums];
|
|
6956
|
+
}
|
|
6929
6957
|
var finalizeGoal = async (ctx) => {
|
|
6930
6958
|
const goal = ctx.data.goal;
|
|
6931
6959
|
if (!goal) return;
|
|
6932
|
-
process.stdout.write(`[goal-tick] all task(s) done \u2014
|
|
6960
|
+
process.stdout.write(`[goal-tick] all task(s) done \u2014 preparing deliverable PR for goal ${goal.id}
|
|
6933
6961
|
`);
|
|
6934
6962
|
const leaf = goal.leafPr;
|
|
6935
6963
|
if (!leaf) {
|
|
6936
|
-
process.stdout.write(`[goal-tick] no leaf PR \u2014 marking goal done without
|
|
6964
|
+
process.stdout.write(`[goal-tick] no leaf PR \u2014 marking goal done without a deliverable PR
|
|
6937
6965
|
`);
|
|
6938
6966
|
goal.state = "done";
|
|
6939
6967
|
return;
|
|
@@ -6951,7 +6979,7 @@ var finalizeGoal = async (ctx) => {
|
|
|
6951
6979
|
}
|
|
6952
6980
|
}
|
|
6953
6981
|
if (leaf.isDraft) {
|
|
6954
|
-
process.stdout.write(`[goal-tick] promoting draft leaf PR #${leaf.number} \u2192 ready
|
|
6982
|
+
process.stdout.write(`[goal-tick] promoting draft leaf PR #${leaf.number} \u2192 ready-for-review
|
|
6955
6983
|
`);
|
|
6956
6984
|
const ready = markPrReady(leaf.number, ctx.cwd);
|
|
6957
6985
|
if (!ready.ok) {
|
|
@@ -6961,22 +6989,32 @@ var finalizeGoal = async (ctx) => {
|
|
|
6961
6989
|
}
|
|
6962
6990
|
}
|
|
6963
6991
|
process.stdout.write(
|
|
6964
|
-
`[goal-tick]
|
|
6992
|
+
`[goal-tick] leaf PR #${leaf.number} is the deliverable (cumulative goal diff vs ${goal.defaultBranch}) \u2014 left open for human merge
|
|
6965
6993
|
`
|
|
6966
6994
|
);
|
|
6967
|
-
const
|
|
6968
|
-
if (!merged.ok) {
|
|
6969
|
-
process.stderr.write(`[goal-tick] finalizeGoal: mergePrSquash #${leaf.number} failed: ${merged.error}
|
|
6970
|
-
`);
|
|
6971
|
-
return;
|
|
6972
|
-
}
|
|
6995
|
+
const uncarriedIssues = /* @__PURE__ */ new Set();
|
|
6973
6996
|
const others = (goal.openTaskPrs ?? []).filter((p) => p.number !== leaf.number);
|
|
6974
6997
|
for (const pr of others) {
|
|
6975
|
-
|
|
6998
|
+
const contained = branchContains(leaf.headRefName, pr.headRefName, ctx.cwd);
|
|
6999
|
+
if (!contained.ok || contained.value !== true) {
|
|
7000
|
+
const why = contained.ok ? `commits on \`${pr.headRefName}\` are NOT reachable from the deliverable leaf \`${leaf.headRefName}\`` : `could not verify containment (${contained.error})`;
|
|
7001
|
+
process.stderr.write(
|
|
7002
|
+
`[goal-tick] finalizeGoal: NOT closing PR #${pr.number} \u2014 ${why}; leaving it open (broken stack)
|
|
7003
|
+
`
|
|
7004
|
+
);
|
|
7005
|
+
for (const n of prIssueNumbers(pr)) uncarriedIssues.add(n);
|
|
7006
|
+
commentOnIssue(
|
|
7007
|
+
pr.number,
|
|
7008
|
+
`\u26A0\uFE0F _Stacked-PR finalize: this PR's commits are **not** carried by the goal's deliverable PR #${leaf.number} (the stack chain was broken). Leaving this PR open so its work isn't lost \u2014 review and land it manually._`,
|
|
7009
|
+
ctx.cwd
|
|
7010
|
+
);
|
|
7011
|
+
continue;
|
|
7012
|
+
}
|
|
7013
|
+
process.stdout.write(`[goal-tick] closing intermediate stacked PR #${pr.number} (carried by deliverable leaf)
|
|
6976
7014
|
`);
|
|
6977
7015
|
const closed = closePr(
|
|
6978
7016
|
pr.number,
|
|
6979
|
-
`_Stacked-PR finalize: this PR's content is
|
|
7017
|
+
`_Stacked-PR finalize: this PR's content is carried by the goal's single deliverable PR #${leaf.number} (open against \`${goal.defaultBranch}\`, awaiting human merge)._`,
|
|
6980
7018
|
ctx.cwd
|
|
6981
7019
|
);
|
|
6982
7020
|
if (!closed.ok) {
|
|
@@ -6986,12 +7024,19 @@ var finalizeGoal = async (ctx) => {
|
|
|
6986
7024
|
}
|
|
6987
7025
|
const openIssues = (goal.childTasks ?? []).filter((t) => t.state === "OPEN");
|
|
6988
7026
|
for (const t of openIssues) {
|
|
6989
|
-
|
|
7027
|
+
if (uncarriedIssues.has(t.number)) {
|
|
7028
|
+
process.stderr.write(
|
|
7029
|
+
`[goal-tick] finalizeGoal: NOT closing task issue #${t.number} \u2014 its PR's work is not carried by the deliverable (broken stack)
|
|
7030
|
+
`
|
|
7031
|
+
);
|
|
7032
|
+
continue;
|
|
7033
|
+
}
|
|
7034
|
+
process.stdout.write(`[goal-tick] closing task issue #${t.number} (goal finalized \u2014 carried by PR #${leaf.number})
|
|
6990
7035
|
`);
|
|
6991
7036
|
const closed = closeIssue(
|
|
6992
7037
|
t.number,
|
|
6993
7038
|
{
|
|
6994
|
-
comment: `_Goal \`${goal.id}\` finalized \u2014
|
|
7039
|
+
comment: `_Goal \`${goal.id}\` finalized \u2014 its single deliverable PR #${leaf.number} (open against \`${goal.defaultBranch}\`) carries this task's changes. Merge that PR to ship._`,
|
|
6995
7040
|
reason: "completed"
|
|
6996
7041
|
},
|
|
6997
7042
|
ctx.cwd
|
|
@@ -8357,23 +8402,6 @@ QA_REPORT_POSTED=${created.url} (verdict: ${verdict})
|
|
|
8357
8402
|
ctx.output.exitCode = verdict === "FAIL" ? 1 : 0;
|
|
8358
8403
|
};
|
|
8359
8404
|
|
|
8360
|
-
// src/scripts/parkGoalForMerge.ts
|
|
8361
|
-
var parkGoalForMerge = async (ctx) => {
|
|
8362
|
-
const goal = ctx.data.goal;
|
|
8363
|
-
if (!goal) return;
|
|
8364
|
-
const approved = goal.raw?.mergeApproved === true;
|
|
8365
|
-
if (approved) {
|
|
8366
|
-
process.stdout.write(`[goal-tick] goal ${goal.id}: merge approved \u2014 running finalize (one-shot)
|
|
8367
|
-
`);
|
|
8368
|
-
if (goal.raw) goal.raw.mergeApproved = false;
|
|
8369
|
-
return;
|
|
8370
|
-
}
|
|
8371
|
-
process.stdout.write(`[goal-tick] all task(s) done \u2014 parking goal ${goal.id} for manual merge (no auto-merge)
|
|
8372
|
-
`);
|
|
8373
|
-
goal.state = "awaiting-merge";
|
|
8374
|
-
goal.phase = "awaiting-merge";
|
|
8375
|
-
};
|
|
8376
|
-
|
|
8377
8405
|
// src/scripts/parseAgentResult.ts
|
|
8378
8406
|
init_prompt();
|
|
8379
8407
|
var parseAgentResult2 = async (ctx, profile, agentResult) => {
|
|
@@ -10578,7 +10606,6 @@ var preflightScripts = {
|
|
|
10578
10606
|
handleAbandonedGoal,
|
|
10579
10607
|
deriveGoalPhase,
|
|
10580
10608
|
dispatchNextTask,
|
|
10581
|
-
parkGoalForMerge,
|
|
10582
10609
|
finalizeGoal,
|
|
10583
10610
|
saveGoalState
|
|
10584
10611
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "goal-tick",
|
|
3
3
|
"role": "primitive",
|
|
4
|
-
"describe": "One deterministic tick for one goal in the stacked-PR model: read .kody/goals/<id>/state.json, dispatch @kody on the next ready task (stacked on the leaf PR), or — when all tasks are done —
|
|
4
|
+
"describe": "One deterministic tick for one goal in the stacked-PR model: read .kody/goals/<id>/state.json, dispatch @kody on the next ready task (stacked on the leaf PR), or — when all tasks are done — finalize by preparing the leaf as a single review-ready PR (retarget to default branch, mark ready, close intermediate PRs/issues) and leave it OPEN for a human to merge. The engine never auto-merges. No agent.",
|
|
5
5
|
"kind": "oneshot",
|
|
6
6
|
"inputs": [
|
|
7
7
|
{
|
|
@@ -51,10 +51,6 @@
|
|
|
51
51
|
"script": "deriveGoalPhase",
|
|
52
52
|
"runWhen": { "data.goal.state": "active" }
|
|
53
53
|
},
|
|
54
|
-
{
|
|
55
|
-
"script": "parkGoalForMerge",
|
|
56
|
-
"runWhen": { "data.goal.phase": "all-done" }
|
|
57
|
-
},
|
|
58
54
|
{
|
|
59
55
|
"script": "finalizeGoal",
|
|
60
56
|
"runWhen": { "data.goal.phase": "all-done" }
|
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.88",
|
|
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",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Goal Manager
|
|
2
|
+
|
|
3
|
+
Autonomous manager for **manager-driven goals**. One worker, every goal:
|
|
4
|
+
each tick it picks up every goal flagged `managed`, breaks it into tasks,
|
|
5
|
+
lets the deterministic `goal-tick` execute them, verifies the
|
|
6
|
+
end-to-end user journey with `qa-engineer`, recovers stalled tasks, and
|
|
7
|
+
stops a goal only when its single deliverable PR is open and the journey
|
|
8
|
+
passes. It never edits code and never merges anything.
|
|
9
|
+
|
|
10
|
+
> Drop this file at `.kody/workers/goal-manager.md` in a consumer repo.
|
|
11
|
+
> The `worker-scheduler` cron invokes `worker-tick` on it automatically.
|
|
12
|
+
|
|
13
|
+
## Worker
|
|
14
|
+
|
|
15
|
+
You coordinate goals **only** through `gh`. You do not write code, open
|
|
16
|
+
PRs, or merge. Everything below is per-goal; you handle all goals in one
|
|
17
|
+
tick.
|
|
18
|
+
|
|
19
|
+
### 1. Discover managed goals
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
ls .kody/goals/*/state.json 2>/dev/null
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For each, read it (`cat`). A goal is **in scope** when BOTH:
|
|
26
|
+
|
|
27
|
+
- `.state == "active"`, and
|
|
28
|
+
- `.managed == true` (the dashboard's "Let Kody manage this goal" toggle
|
|
29
|
+
writes this; absent/false → ignore the goal entirely).
|
|
30
|
+
|
|
31
|
+
For an in-scope goal, the goal **id** is the directory name
|
|
32
|
+
(`.kody/goals/<id>/state.json`). Its definition (intent + the ordered
|
|
33
|
+
end-to-end user journey that defines "done") is, in priority order:
|
|
34
|
+
|
|
35
|
+
1. `.journey` / `.description` / `.intent` / `.title` fields in
|
|
36
|
+
`state.json` if present, else
|
|
37
|
+
2. the goal's GitHub Discussion body — if `state.json` has a
|
|
38
|
+
`discussionNumber`, fetch it:
|
|
39
|
+
`gh api graphql -f query='{repository(owner:"OWNER",name:"REPO"){discussion(number:N){body title}}}'`
|
|
40
|
+
(resolve OWNER/REPO from `gh repo view --json owner,name`).
|
|
41
|
+
|
|
42
|
+
The **journey** is the acceptance test. If you cannot find any journey
|
|
43
|
+
text, do NOT guess — escalate the goal (see §5) with reason
|
|
44
|
+
`no-journey-defined` and skip it.
|
|
45
|
+
|
|
46
|
+
### 2. Per-goal cursor
|
|
47
|
+
|
|
48
|
+
Keep all per-goal progress under `data.goals[<id>]`. Each entry:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
{ "cursor": "...", "tasks": [<issue numbers you created>],
|
|
52
|
+
"gate": <qa-gate issue number|null>, "qaRound": <int>,
|
|
53
|
+
"stall": { "<issueNumber>": { "ticks": <int>, "attempts": <int> } },
|
|
54
|
+
"lastQaTick": "<ISO>" }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`cursor` ∈ `new | building | qa | finalizing | done | escalated`.
|
|
58
|
+
Missing entry ⇒ treat as `new`.
|
|
59
|
+
|
|
60
|
+
### 3. Decompose (`cursor: new`)
|
|
61
|
+
|
|
62
|
+
One-time setup for the goal:
|
|
63
|
+
|
|
64
|
+
1. Ensure labels exist (idempotent — ignore "already exists"):
|
|
65
|
+
`gh label create "goal:<id>" --color 5319e7 2>/dev/null || true`
|
|
66
|
+
`gh label create "kody:qa-gate" --color b60205 2>/dev/null || true`
|
|
67
|
+
`gh label create "kody:needs-human" --color d93f0b 2>/dev/null || true`
|
|
68
|
+
2. Break the goal into the **smallest correct set of sequential tasks**.
|
|
69
|
+
For each, create an issue:
|
|
70
|
+
`gh issue create --title "<task>" --label "goal:<id>" --body "<precise spec, acceptance criteria>"`
|
|
71
|
+
Record the returned issue numbers in `tasks`. Order matters — the
|
|
72
|
+
stacked-PR model builds them lowest-number first.
|
|
73
|
+
3. Create the **QA gate** issue (exactly one per goal):
|
|
74
|
+
`gh issue create --title "QA gate: <id> — end-to-end journey" --label "goal:<id>" --label "kody:qa-gate" --body "<the full ordered journey, step 1 → success state>"`
|
|
75
|
+
Record its number in `gate`.
|
|
76
|
+
4. Set `cursor: building`. **Do not** dispatch anything yourself —
|
|
77
|
+
`goal-tick` (its own cron) sees the new `goal:<id>` issues and drives
|
|
78
|
+
them. The open `kody:qa-gate` issue keeps the goal short of
|
|
79
|
+
`all-done`, so nothing finalizes until you close it in §4.
|
|
80
|
+
|
|
81
|
+
### 4. Drive + recover (`cursor: building`)
|
|
82
|
+
|
|
83
|
+
Each tick, for the goal:
|
|
84
|
+
|
|
85
|
+
- Enumerate tasks:
|
|
86
|
+
`gh issue list --label "goal:<id>" --state all --json number,state,title`
|
|
87
|
+
and the PRs:
|
|
88
|
+
`gh pr list --state open --json number,headRefName,body,isDraft`.
|
|
89
|
+
A task is **done** when its issue is CLOSED or it has a ready
|
|
90
|
+
(non-draft) open PR (`Closes #<n>` in body, or head ref `^<n>-`).
|
|
91
|
+
- **Stall detection** (per non-gate, non-done task):
|
|
92
|
+
- Increment `stall[n].ticks`.
|
|
93
|
+
- If the task has **no open PR** and `stall[n].ticks >= 3`: it was
|
|
94
|
+
never picked up — re-nudge: `gh issue comment <n> --body "@kody"`,
|
|
95
|
+
`stall[n].attempts += 1`, reset `stall[n].ticks = 0`.
|
|
96
|
+
- If the task has a **draft PR with no new commits** for
|
|
97
|
+
`stall[n].ticks >= 4`: comment `@kody continue` on the PR, bump
|
|
98
|
+
attempts, reset ticks.
|
|
99
|
+
- When `stall[n].attempts >= 3` for any task → escalate (§5) and set
|
|
100
|
+
`cursor: escalated`.
|
|
101
|
+
- Clear `stall[n]` once the task is done.
|
|
102
|
+
- When **every non-gate task is done** → `cursor: qa`.
|
|
103
|
+
|
|
104
|
+
### 4b. Verify the journey (`cursor: qa`)
|
|
105
|
+
|
|
106
|
+
- Throttle: skip if `lastQaTick` is < 20 min ago (avoid stacking QA
|
|
107
|
+
runs); otherwise:
|
|
108
|
+
- Trigger QA against the journey by commenting on the **gate issue**:
|
|
109
|
+
`gh issue comment <gate> --body "@kody qa-engineer --goal <id> --scope \"<the full journey text>\""`
|
|
110
|
+
Set `lastQaTick` = now, `qaRound += 1`.
|
|
111
|
+
- `qa-engineer` browses the running app and files **each failure as a
|
|
112
|
+
new `goal:<id>` task issue** automatically. So on the **next** tick:
|
|
113
|
+
- If new `goal:<id>` task issues appeared after this `qaRound` (issues
|
|
114
|
+
you didn't create / not in `tasks`) → add them to `tasks`, set
|
|
115
|
+
`cursor: building` (the loop fixes them, then QA re-runs).
|
|
116
|
+
- If no new task issues and the latest `qa-engineer` report comment on
|
|
117
|
+
the gate issue is a **PASS** (read it:
|
|
118
|
+
`gh issue view <gate> --json comments`) → the journey works:
|
|
119
|
+
close the gate `gh issue close <gate> --comment "QA passed — journey verified."`,
|
|
120
|
+
set `cursor: finalizing`.
|
|
121
|
+
- If the report is ambiguous/unreachable and `qaRound >= 4` →
|
|
122
|
+
escalate (§5).
|
|
123
|
+
|
|
124
|
+
### 4c. Finish (`cursor: finalizing`)
|
|
125
|
+
|
|
126
|
+
With the gate closed, `goal-tick` observes `all-done`, prepares the
|
|
127
|
+
**single open deliverable PR** (cumulative diff vs the default branch,
|
|
128
|
+
review-ready), closes the task issues, and sets `state: "done"` in
|
|
129
|
+
`state.json`. You do **not** merge — a human merges that PR.
|
|
130
|
+
|
|
131
|
+
- When `state.json.state == "done"` (or the deliverable PR is open and
|
|
132
|
+
task issues are closed): post one summary comment on the gate issue or
|
|
133
|
+
the discussion linking the deliverable PR, set `cursor: done`.
|
|
134
|
+
|
|
135
|
+
### 5. Escalation
|
|
136
|
+
|
|
137
|
+
Escalate by: commenting the reason on the goal's most relevant artifact
|
|
138
|
+
(gate issue if it exists, else the highest-numbered task issue), adding
|
|
139
|
+
the `kody:needs-human` label to that issue, and setting
|
|
140
|
+
`cursor: escalated`. On later ticks, re-check an escalated goal: if a
|
|
141
|
+
human removed `kody:needs-human` or the blocking condition cleared,
|
|
142
|
+
resume at the appropriate cursor.
|
|
143
|
+
|
|
144
|
+
### 6. `cursor: done | escalated`
|
|
145
|
+
|
|
146
|
+
No action. Keep the entry so you don't reprocess it. (These are per-goal;
|
|
147
|
+
the **worker** is evergreen — never emit worker-level `done: true`.)
|
|
148
|
+
|
|
149
|
+
## Allowed Commands
|
|
150
|
+
|
|
151
|
+
`gh` only: `gh issue ...`, `gh pr ...`, `gh label ...`, `gh api ...`,
|
|
152
|
+
`gh repo view`. Read-only shell (`ls`, `cat`) on the checked-out tree to
|
|
153
|
+
read `.kody/goals/*/state.json`.
|
|
154
|
+
|
|
155
|
+
## Restrictions
|
|
156
|
+
|
|
157
|
+
- Never edit, create, or delete files in the working tree (no code, no
|
|
158
|
+
`state.json` writes — the gate is GitHub-side via the qa-gate issue).
|
|
159
|
+
- Never run `git`. Never merge or close a PR. Never mark a PR ready.
|
|
160
|
+
- Never `@kody` a `kody:qa-gate` issue except the one `qa-engineer`
|
|
161
|
+
trigger in §4b.
|
|
162
|
+
- One QA trigger per `qaRound`; honour the 20-min throttle.
|
|
163
|
+
- Do not dispatch implementation tasks yourself — only create the issues;
|
|
164
|
+
`goal-tick` dispatches. Your only `@kody` comments are stall re-nudges
|
|
165
|
+
(§4) and the `qa-engineer` trigger (§4b).
|
|
166
|
+
|
|
167
|
+
## State
|
|
168
|
+
|
|
169
|
+
`cursor` (worker-level): always `"managing"` (evergreen). All real
|
|
170
|
+
progress lives in `data.goals[<id>]` as described in §2. `done`: always
|
|
171
|
+
`false`.
|
|
172
|
+
|
|
173
|
+
**Cadence guard.** If `forceRun` is false and your last tick was < 4 min
|
|
174
|
+
ago (compare a `data.lastTick` ISO you maintain), emit the prior state
|
|
175
|
+
unchanged and exit. Always update `data.lastTick` when you do run.
|