@kody-ade/kody-engine 0.4.86 → 0.4.87

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.87",
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,14 +5310,6 @@ function closePr(prNumber, comment, cwd) {
5302
5310
  return fail(err);
5303
5311
  }
5304
5312
  }
5305
- function mergePrSquash(prNumber, cwd) {
5306
- try {
5307
- gh(["pr", "merge", String(prNumber), "--squash", "--delete-branch"], { cwd });
5308
- return { ok: true };
5309
- } catch (err) {
5310
- return fail(err);
5311
- }
5312
- }
5313
5313
  function editPrBase(prNumber, baseBranch, cwd) {
5314
5314
  try {
5315
5315
  gh(
@@ -5336,17 +5336,19 @@ function derivePhase(snap) {
5336
5336
  if (snap.lifecycleState === "abandoned") return "abandoned";
5337
5337
  if (snap.lifecycleState === "closed" || snap.lifecycleState === "done") return "terminal";
5338
5338
  if (snap.lifecycleState === "awaiting-merge") return "awaiting-merge";
5339
- const hasInFlight = snap.childTasks.some((t) => t.state === "OPEN" && t.prState === "draft");
5339
+ const tasks = snap.childTasks.filter((t) => !t.isQaGate);
5340
+ const qaGateOpen = snap.childTasks.some((t) => t.isQaGate && t.state === "OPEN");
5341
+ const hasInFlight = tasks.some((t) => t.state === "OPEN" && t.prState === "draft");
5340
5342
  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");
5343
+ if (tasks.length === 0) return "idle";
5344
+ const dispatchable = tasks.some((t) => t.state === "OPEN" && t.prState === "absent");
5345
5345
  if (dispatchable) return "ready-to-dispatch";
5346
+ const allDone = tasks.every((t) => t.state === "CLOSED" || t.prState === "ready");
5347
+ if (allDone) return qaGateOpen ? "idle" : "all-done";
5346
5348
  return "idle";
5347
5349
  }
5348
5350
  function pickNextDispatchable(snap) {
5349
- return snap.childTasks.filter((t) => t.state === "OPEN" && t.prState === "absent").sort((a, b) => a.number - b.number)[0];
5351
+ return snap.childTasks.filter((t) => !t.isQaGate && t.state === "OPEN" && t.prState === "absent").sort((a, b) => a.number - b.number)[0];
5350
5352
  }
5351
5353
 
5352
5354
  // src/scripts/deriveGoalPhase.ts
@@ -6929,11 +6931,11 @@ function collectExpectedTests(raw) {
6929
6931
  var finalizeGoal = async (ctx) => {
6930
6932
  const goal = ctx.data.goal;
6931
6933
  if (!goal) return;
6932
- process.stdout.write(`[goal-tick] all task(s) done \u2014 finalising goal ${goal.id}
6934
+ process.stdout.write(`[goal-tick] all task(s) done \u2014 preparing deliverable PR for goal ${goal.id}
6933
6935
  `);
6934
6936
  const leaf = goal.leafPr;
6935
6937
  if (!leaf) {
6936
- process.stdout.write(`[goal-tick] no leaf PR \u2014 marking goal done without merge
6938
+ process.stdout.write(`[goal-tick] no leaf PR \u2014 marking goal done without a deliverable PR
6937
6939
  `);
6938
6940
  goal.state = "done";
6939
6941
  return;
@@ -6951,7 +6953,7 @@ var finalizeGoal = async (ctx) => {
6951
6953
  }
6952
6954
  }
6953
6955
  if (leaf.isDraft) {
6954
- process.stdout.write(`[goal-tick] promoting draft leaf PR #${leaf.number} \u2192 ready
6956
+ process.stdout.write(`[goal-tick] promoting draft leaf PR #${leaf.number} \u2192 ready-for-review
6955
6957
  `);
6956
6958
  const ready = markPrReady(leaf.number, ctx.cwd);
6957
6959
  if (!ready.ok) {
@@ -6961,22 +6963,16 @@ var finalizeGoal = async (ctx) => {
6961
6963
  }
6962
6964
  }
6963
6965
  process.stdout.write(
6964
- `[goal-tick] squash-merging leaf PR #${leaf.number} \u2192 ${goal.defaultBranch} (cumulative goal diff)
6966
+ `[goal-tick] leaf PR #${leaf.number} is the deliverable (cumulative goal diff vs ${goal.defaultBranch}) \u2014 left open for human merge
6965
6967
  `
6966
6968
  );
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
- }
6973
6969
  const others = (goal.openTaskPrs ?? []).filter((p) => p.number !== leaf.number);
6974
6970
  for (const pr of others) {
6975
- process.stdout.write(`[goal-tick] closing intermediate stacked PR #${pr.number} (subsumed by leaf merge)
6971
+ process.stdout.write(`[goal-tick] closing intermediate stacked PR #${pr.number} (carried by deliverable leaf)
6976
6972
  `);
6977
6973
  const closed = closePr(
6978
6974
  pr.number,
6979
- `_Stacked-PR finalize: this PR's content is included in the leaf squash to \`${goal.defaultBranch}\` (#${leaf.number})._`,
6975
+ `_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
6976
  ctx.cwd
6981
6977
  );
6982
6978
  if (!closed.ok) {
@@ -6986,12 +6982,12 @@ var finalizeGoal = async (ctx) => {
6986
6982
  }
6987
6983
  const openIssues = (goal.childTasks ?? []).filter((t) => t.state === "OPEN");
6988
6984
  for (const t of openIssues) {
6989
- process.stdout.write(`[goal-tick] closing task issue #${t.number} (goal finalized)
6985
+ process.stdout.write(`[goal-tick] closing task issue #${t.number} (goal finalized \u2014 carried by PR #${leaf.number})
6990
6986
  `);
6991
6987
  const closed = closeIssue(
6992
6988
  t.number,
6993
6989
  {
6994
- comment: `_Goal \`${goal.id}\` finalized \u2014 leaf PR #${leaf.number} squash-merged to \`${goal.defaultBranch}\` carries this task's changes._`,
6990
+ 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
6991
  reason: "completed"
6996
6992
  },
6997
6993
  ctx.cwd
@@ -8357,23 +8353,6 @@ QA_REPORT_POSTED=${created.url} (verdict: ${verdict})
8357
8353
  ctx.output.exitCode = verdict === "FAIL" ? 1 : 0;
8358
8354
  };
8359
8355
 
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
8356
  // src/scripts/parseAgentResult.ts
8378
8357
  init_prompt();
8379
8358
  var parseAgentResult2 = async (ctx, profile, agentResult) => {
@@ -10578,7 +10557,6 @@ var preflightScripts = {
10578
10557
  handleAbandonedGoal,
10579
10558
  deriveGoalPhase,
10580
10559
  dispatchNextTask,
10581
- parkGoalForMerge,
10582
10560
  finalizeGoal,
10583
10561
  saveGoalState
10584
10562
  };
@@ -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.87",
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.