@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.86",
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
- try {
3428
- git(["push", "-u", "origin", branch], cwd);
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", "--force-with-lease", "-u", "origin", branch], cwd);
3434
+ git(["push", "-u", "origin", branch], cwd);
3433
3435
  return { committed: true, pushed: true, sha, message };
3434
- } catch (secondErr) {
3435
- const tail = (secondErr instanceof Error ? secondErr.message : String(secondErr)).slice(-400);
3436
- const initial = firstErr instanceof Error ? firstErr.message : String(firstErr);
3437
- return {
3438
- committed: true,
3439
- pushed: false,
3440
- sha,
3441
- message,
3442
- pushError: `push failed: ${initial.slice(-200)} | force-with-lease failed: ${tail}`
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
- "[.[] | select(.pull_request == null) | {number, state: (.state | ascii_upcase)}]"
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 mergePrSquash(prNumber, cwd) {
5313
+ function editPrBase(prNumber, baseBranch, cwd) {
5306
5314
  try {
5307
- gh(["pr", "merge", String(prNumber), "--squash", "--delete-branch"], { cwd });
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 editPrBase(prNumber, baseBranch, cwd) {
5324
+ function branchContains(leafHead, candidateHead, cwd) {
5325
+ if (leafHead === candidateHead) return { ok: true, value: true };
5314
5326
  try {
5315
- gh(
5316
- ["api", "--method", "PATCH", `repos/{owner}/{repo}/pulls/${prNumber}`, "-f", `base=${baseBranch}`],
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 hasInFlight = snap.childTasks.some((t) => t.state === "OPEN" && t.prState === "draft");
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 (snap.childTasks.length === 0) return "idle";
5342
- const allDone = snap.childTasks.every((t) => t.state === "CLOSED" || t.prState === "ready");
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 finalising goal ${goal.id}
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 merge
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] squash-merging leaf PR #${leaf.number} \u2192 ${goal.defaultBranch} (cumulative goal diff)
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 merged = mergePrSquash(leaf.number, ctx.cwd);
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
- process.stdout.write(`[goal-tick] closing intermediate stacked PR #${pr.number} (subsumed by leaf merge)
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 included in the leaf squash to \`${goal.defaultBranch}\` (#${leaf.number})._`,
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
- process.stdout.write(`[goal-tick] closing task issue #${t.number} (goal finalized)
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 leaf PR #${leaf.number} squash-merged to \`${goal.defaultBranch}\` carries this task's changes._`,
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 — park the goal at awaiting-merge (the dashboard Merge button later flips it back to active+mergeApproved, which lets finalize squash-merge the leaf). No agent.",
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.86",
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.