@gethmy/agent 1.27.0 → 1.28.0

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.
Files changed (3) hide show
  1. package/dist/cli.js +1896 -567
  2. package/dist/index.js +1889 -563
  3. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -17,6 +17,104 @@ var __export = (target, all) => {
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
19
19
 
20
+ // src/base-branch.ts
21
+ import { execFileSync } from "node:child_process";
22
+ function resolveBaseBranch(baseBranch, remote, probe) {
23
+ const ref = `${remote}/${baseBranch}`;
24
+ if (probe.hasRef(ref))
25
+ return { ok: true, fetched: false };
26
+ const remotes = probe.remotes();
27
+ if (!remotes.includes(remote)) {
28
+ return {
29
+ ok: false,
30
+ message: remotes.length ? `This checkout has no "${remote}" remote — it has ${remotes.map((r) => `"${r}"`).join(", ")}. Every worktree branches from "${ref}", so the daemon needs that remote. Add it, or run the daemon from a clone that has it.` : `This checkout has no git remote, so "${ref}" can never resolve. Every worktree branches from the remote base branch. Add a remote, or run the daemon from a clone of the repository.`
31
+ };
32
+ }
33
+ const fetchError = probe.fetch(remote, baseBranch);
34
+ if (fetchError === null && probe.hasRef(ref)) {
35
+ return { ok: true, fetched: true };
36
+ }
37
+ const upstream = probe.remoteHasBranch(remote, baseBranch);
38
+ if (upstream === false) {
39
+ const actual = probe.remoteDefaultBranch(remote);
40
+ const suggestion = actual && actual !== baseBranch ? ` "${remote}" reports "${actual}" as its default branch.` : "";
41
+ return {
42
+ ok: false,
43
+ message: `Branch "${baseBranch}" does not exist on "${remote}".${suggestion} ${BASE_BRANCH_CONFIG_HINT}`
44
+ };
45
+ }
46
+ const detail = fetchError ? `: ${fetchError}` : " for an unknown reason";
47
+ if (upstream === null) {
48
+ return {
49
+ ok: false,
50
+ message: `Could not reach "${remote}" to resolve "${ref}"${detail}. Fix the git access from this checkout, then start the daemon again.`
51
+ };
52
+ }
53
+ return {
54
+ ok: false,
55
+ message: `"${baseBranch}" exists on "${remote}", but fetching it into "${ref}" failed${detail}. Fix the git access from this checkout, then start the daemon again.`
56
+ };
57
+ }
58
+ function createGitProbe(cwd) {
59
+ const git = (args) => execFileSync("git", args, {
60
+ cwd,
61
+ encoding: "utf-8",
62
+ stdio: "pipe"
63
+ }).trim();
64
+ return {
65
+ remotes() {
66
+ try {
67
+ return git(["remote"]).split(`
68
+ `).filter(Boolean);
69
+ } catch {
70
+ return [];
71
+ }
72
+ },
73
+ hasRef(ref) {
74
+ try {
75
+ git(["rev-parse", "--verify", ref]);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ },
81
+ fetch(remote, branch) {
82
+ try {
83
+ git([
84
+ "fetch",
85
+ remote,
86
+ `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`
87
+ ]);
88
+ return null;
89
+ } catch (err) {
90
+ const e = err;
91
+ const text = String(e?.stderr ?? e?.message ?? "").trim();
92
+ return text.split(`
93
+ `).filter(Boolean).slice(-1)[0] ?? "git fetch failed";
94
+ }
95
+ },
96
+ remoteHasBranch(remote, branch) {
97
+ try {
98
+ return git(["ls-remote", "--heads", remote, branch]).length > 0;
99
+ } catch {
100
+ return null;
101
+ }
102
+ },
103
+ remoteDefaultBranch(remote) {
104
+ try {
105
+ const line = git(["ls-remote", "--symref", remote, "HEAD"]).split(`
106
+ `).find((l) => l.startsWith("ref:"));
107
+ const match = line?.match(/refs\/heads\/(\S+)/);
108
+ return match?.[1] ?? null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ };
114
+ }
115
+ var BASE_BRANCH_CONFIG_HINT = "Set `agent.worktree.baseBranch` in ~/.harmony-mcp/config.json to the branch worktrees should branch from.";
116
+ var init_base_branch = () => {};
117
+
20
118
  // src/board-helpers.ts
21
119
  var exports_board_helpers = {};
22
120
  __export(exports_board_helpers, {
@@ -547,6 +645,16 @@ function hasUnsafeDaemonBranchLine(description) {
547
645
  }
548
646
  return false;
549
647
  }
648
+ function recordsPushedWorkOn(description, branchName) {
649
+ if (!description || !branchName)
650
+ return false;
651
+ for (const match of description.matchAll(DAEMON_BRANCH_LINE_PATTERN)) {
652
+ const ref = match[1];
653
+ if (ref && SAFE_GIT_REF_PATTERN.test(ref) && ref === branchName)
654
+ return true;
655
+ }
656
+ return false;
657
+ }
550
658
  var BRANCH_REF_PATTERN, DAEMON_BRANCH_LINE_PATTERN, SAFE_GIT_REF_PATTERN, PR_LINK_PATTERN;
551
659
  var init_branchRef = __esm(() => {
552
660
  BRANCH_REF_PATTERN = /Branch:\s*`([^`]+)`/g;
@@ -676,6 +784,12 @@ var init_constants = __esm(() => {
676
784
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
677
785
  };
678
786
  });
787
+ // ../harmony-shared/dist/gateConfigError.js
788
+ var GATE_CONFIG_ERROR_KEY = "configError", GATE_CONFIG_ERROR_MARK;
789
+ var init_gateConfigError = __esm(() => {
790
+ GATE_CONFIG_ERROR_MARK = Object.freeze({ [GATE_CONFIG_ERROR_KEY]: true });
791
+ });
792
+
679
793
  // ../harmony-shared/dist/gateEvaluate.js
680
794
  function isGateKind(value) {
681
795
  return typeof value === "string" && GATE_KINDS.includes(value);
@@ -1140,6 +1254,42 @@ function entryActionAllowlist(entryAction) {
1140
1254
  function stageDisallowedTools() {
1141
1255
  return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1142
1256
  }
1257
+ function customGateMetric(gate) {
1258
+ if (gate === null || typeof gate !== "object" || Array.isArray(gate)) {
1259
+ return null;
1260
+ }
1261
+ const record = gate;
1262
+ if (record.kind !== "custom")
1263
+ return null;
1264
+ if (record.pendingEngine === true)
1265
+ return null;
1266
+ const metric = typeof record.metric === "string" ? record.metric.trim() : "";
1267
+ return metric ? metric : null;
1268
+ }
1269
+ function referencedGateMetrics(def) {
1270
+ const out = [];
1271
+ for (const stage of readStageDefs(def)) {
1272
+ if (!stage || typeof stage !== "object")
1273
+ continue;
1274
+ const stageId = typeof stage.id === "string" ? stage.id : "";
1275
+ const stageName = typeof stage.name === "string" ? stage.name : stageId;
1276
+ const gateMetric = customGateMetric(stage.gate);
1277
+ if (gateMetric) {
1278
+ out.push({ stageId, stageName, metric: gateMetric, source: "gate" });
1279
+ }
1280
+ const loop = normalizeLoopDef(stage.loop);
1281
+ const loopMetric = loop?.exit_gate ? customGateMetric(loop.exit_gate) : null;
1282
+ if (loopMetric) {
1283
+ out.push({
1284
+ stageId,
1285
+ stageName,
1286
+ metric: loopMetric,
1287
+ source: "loop_exit_gate"
1288
+ });
1289
+ }
1290
+ }
1291
+ return out;
1292
+ }
1143
1293
  var DEFAULT_LOOP_MAX_ITERATIONS = 5, PLAYBOOK_STAGE_ROLES, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1144
1294
  var init_playbookStage = __esm(() => {
1145
1295
  PLAYBOOK_STAGE_ROLES = [
@@ -1430,6 +1580,7 @@ var init_dist = __esm(() => {
1430
1580
  init_columnSort();
1431
1581
  init_commentSerializer();
1432
1582
  init_constants();
1583
+ init_gateConfigError();
1433
1584
  init_gateEvaluate();
1434
1585
  init_logger();
1435
1586
  init_playbookAutoBind();
@@ -1775,6 +1926,7 @@ var init_types2 = __esm(() => {
1775
1926
  advanced: "claude-opus-5",
1776
1927
  research: "claude-fable-5"
1777
1928
  },
1929
+ sizingModel: "",
1778
1930
  reviewModel: "sonnet",
1779
1931
  maxTurns: 80,
1780
1932
  reviewMaxTurns: 60,
@@ -1828,7 +1980,8 @@ var init_types2 = __esm(() => {
1828
1980
  },
1829
1981
  budget: {
1830
1982
  maxAttemptsPerCard: 3,
1831
- dailyBudgetCents: 5000
1983
+ dailyBudgetCents: 5000,
1984
+ pause: { enabled: true, waitHours: 24, extraTurns: null }
1832
1985
  },
1833
1986
  http: {
1834
1987
  enabled: true,
@@ -2105,6 +2258,15 @@ var init_config_validation = __esm(() => {
2105
2258
  };
2106
2259
  });
2107
2260
 
2261
+ // src/declared-metrics.ts
2262
+ var exports_declared_metrics = {};
2263
+ __export(exports_declared_metrics, {
2264
+ declaredMetricNames: () => declaredMetricNames
2265
+ });
2266
+ function declaredMetricNames(metrics) {
2267
+ return Object.keys(metrics ?? {}).map((name) => name.trim()).filter((name) => name.length > 0).sort();
2268
+ }
2269
+
2108
2270
  // src/http-server.ts
2109
2271
  import {
2110
2272
  createServer
@@ -2231,7 +2393,7 @@ function isAddrInUse(err) {
2231
2393
  return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
2232
2394
  }
2233
2395
  function parseCommand(path) {
2234
- const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
2396
+ const match = path.match(/^\/(pause|resume|stop|continue)\/([^/]+)$/);
2235
2397
  if (!match)
2236
2398
  return null;
2237
2399
  return { command: match[1], cardId: decodeURIComponent(match[2]) };
@@ -2273,8 +2435,11 @@ function decideAutoMergeAction(input) {
2273
2435
  if (ciStatus !== "success")
2274
2436
  return "wait";
2275
2437
  }
2276
- if (config.reReviewOnBranchChange && reviewedSha && headSha && reviewedSha !== headSha) {
2277
- return "rereview";
2438
+ if (config.reReviewOnBranchChange) {
2439
+ if (!reviewedSha)
2440
+ return "wait";
2441
+ if (headSha && reviewedSha !== headSha)
2442
+ return "rereview";
2278
2443
  }
2279
2444
  return "merge";
2280
2445
  }
@@ -2310,7 +2475,11 @@ async function attemptAutoMerge(deps) {
2310
2475
  });
2311
2476
  switch (action) {
2312
2477
  case "wait":
2313
- log4.debug(TAG4, `#${card.short_id} waiting (ci=${ciStatus})`);
2478
+ if (autoMerge.reReviewOnBranchChange && !reviewedSha) {
2479
+ log4.info(TAG4, `#${card.short_id} holding — no Reviewed-SHA on the card, so nothing has reviewed this head. Merge it yourself, or let the review pipeline run.`);
2480
+ } else {
2481
+ log4.debug(TAG4, `#${card.short_id} waiting (ci=${ciStatus})`);
2482
+ }
2314
2483
  return;
2315
2484
  case "stamp-failure":
2316
2485
  log4.info(TAG4, `#${card.short_id} CI failed — flagging for human`);
@@ -2330,7 +2499,7 @@ var TAG4 = "auto-merge";
2330
2499
  var init_auto_merge = () => {};
2331
2500
 
2332
2501
  // src/review-worktree.ts
2333
- import { execFileSync, execSync as execSync2 } from "node:child_process";
2502
+ import { execFileSync as execFileSync2, execSync as execSync2 } from "node:child_process";
2334
2503
  import { existsSync } from "node:fs";
2335
2504
  import { resolve } from "node:path";
2336
2505
  import {
@@ -2353,7 +2522,7 @@ function gitErrorDetail(err) {
2353
2522
  return err instanceof Error ? err.message : String(err);
2354
2523
  }
2355
2524
  function checkoutExistingBranch(basePath, branchName) {
2356
- const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
2525
+ const repoRoot = execFileSync2("git", ["rev-parse", "--show-toplevel"], {
2357
2526
  encoding: "utf-8"
2358
2527
  }).trim();
2359
2528
  const worktreeDir = resolve(repoRoot, basePath, `review-${branchName}`);
@@ -2362,13 +2531,13 @@ function checkoutExistingBranch(basePath, branchName) {
2362
2531
  cleanupWorktree(worktreeDir);
2363
2532
  }
2364
2533
  try {
2365
- execFileSync("git", ["worktree", "prune", "--expire=now"], {
2534
+ execFileSync2("git", ["worktree", "prune", "--expire=now"], {
2366
2535
  cwd: repoRoot,
2367
2536
  stdio: "pipe"
2368
2537
  });
2369
2538
  } catch {}
2370
2539
  try {
2371
- execFileSync("git", ["fetch", "origin", branchName], {
2540
+ execFileSync2("git", ["fetch", "origin", branchName], {
2372
2541
  cwd: repoRoot,
2373
2542
  stdio: "pipe"
2374
2543
  });
@@ -2377,14 +2546,14 @@ function checkoutExistingBranch(basePath, branchName) {
2377
2546
  }
2378
2547
  removeWorktreeHoldingBranch(repoRoot, branchName, worktreeDir);
2379
2548
  try {
2380
- execFileSync("git", ["branch", "-D", branchName], {
2549
+ execFileSync2("git", ["branch", "-D", branchName], {
2381
2550
  cwd: repoRoot,
2382
2551
  stdio: "pipe"
2383
2552
  });
2384
2553
  } catch {}
2385
2554
  log5.info(TAG5, `Creating review worktree: ${worktreeDir} (branch: ${branchName})`);
2386
2555
  try {
2387
- execFileSync("git", [
2556
+ execFileSync2("git", [
2388
2557
  "worktree",
2389
2558
  "add",
2390
2559
  "--track",
@@ -2621,6 +2790,57 @@ var init_merge_monitor = __esm(() => {
2621
2790
  execFileAsync = promisify(execFile);
2622
2791
  });
2623
2792
 
2793
+ // src/metric-validation.ts
2794
+ function formatUndeclaredMetricWarning(finding) {
2795
+ const card = finding.cardShortId != null ? `#${finding.cardShortId} "${finding.cardTitle}"` : `"${finding.cardTitle}"`;
2796
+ return `Card ${card} is bound to a playbook whose stage "${finding.stageName}" ` + `gates on metric "${finding.metric}", which this daemon does not declare — ` + `its stage run would hold as misconfigured. Add \`agent.playbooks.metrics.${finding.metric}\` to permit it.`;
2797
+ }
2798
+ async function findUndeclaredGateMetrics(client, projectId, config) {
2799
+ if (!config.playbooks.enabled)
2800
+ return [];
2801
+ const declared = new Set(Object.keys(config.playbooks.metrics ?? {}));
2802
+ const board = await client.getFullBoard(projectId);
2803
+ const bound = board.cards.filter((card) => card && typeof card === "object" && card.playbook_id && card.playbook_version != null && card.current_stage);
2804
+ if (bound.length === 0)
2805
+ return [];
2806
+ const request = client.request?.bind(client);
2807
+ if (typeof request !== "function")
2808
+ return [];
2809
+ const versionCache = new Map;
2810
+ const findings = [];
2811
+ for (const card of bound) {
2812
+ const cacheKey = `${card.playbook_id}@${card.playbook_version}`;
2813
+ let def = versionCache.get(cacheKey);
2814
+ if (def === undefined) {
2815
+ try {
2816
+ const res = await request("GET", `/playbooks/${encodeURIComponent(String(card.playbook_id))}/versions/${card.playbook_version}`);
2817
+ def = res?.version ?? null;
2818
+ } catch {
2819
+ def = null;
2820
+ }
2821
+ versionCache.set(cacheKey, def);
2822
+ }
2823
+ if (!def)
2824
+ continue;
2825
+ const warned = new Set;
2826
+ for (const ref of referencedGateMetrics(def)) {
2827
+ if (declared.has(ref.metric) || warned.has(ref.metric))
2828
+ continue;
2829
+ warned.add(ref.metric);
2830
+ findings.push({
2831
+ cardShortId: typeof card.short_id === "number" ? card.short_id : null,
2832
+ cardTitle: card.title ?? "",
2833
+ stageName: ref.stageName || ref.stageId,
2834
+ metric: ref.metric
2835
+ });
2836
+ }
2837
+ }
2838
+ return findings;
2839
+ }
2840
+ var init_metric_validation = __esm(() => {
2841
+ init_dist();
2842
+ });
2843
+
2624
2844
  // src/pickup-router.ts
2625
2845
  function isStageCard(card, columnName, playbooks) {
2626
2846
  if (!playbooks.enabled)
@@ -2668,10 +2888,11 @@ class BudgetGuard {
2668
2888
  return { allow: true };
2669
2889
  }
2670
2890
  }
2671
- function buildGaveUpComment(maxAttempts, failures) {
2891
+ function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
2892
+ const wayBackIn = pauseEnabled ? "Continue to grant a fresh attempt, or reassign the card." : "Reassign the card to grant a fresh attempt.";
2672
2893
  const lines = [
2673
- "**Agent gave upneeds a human.**",
2674
- `Stopped after ${maxAttempts} failed attempt${maxAttempts === 1 ? "" : "s"}. Reassign the card to try again.`
2894
+ "**Out of attemptsover to you.**",
2895
+ `Stopped after ${maxAttempts} failed attempt${maxAttempts === 1 ? "" : "s"}. ${wayBackIn}`
2675
2896
  ];
2676
2897
  if (failures.length > 0) {
2677
2898
  lines.push("", "Recent failures:");
@@ -2689,6 +2910,89 @@ function buildGaveUpComment(maxAttempts, failures) {
2689
2910
  `);
2690
2911
  }
2691
2912
 
2913
+ // src/budget-pause.ts
2914
+ function classifyRunExit(x) {
2915
+ if (x.exitCode === 0)
2916
+ return null;
2917
+ if (x.timedOut)
2918
+ return "timeout";
2919
+ if (x.maxTurns <= 0)
2920
+ return null;
2921
+ if (x.stopReason === "error_max_turns")
2922
+ return "max_turns";
2923
+ if (x.numTurns >= x.maxTurns)
2924
+ return "max_turns";
2925
+ return null;
2926
+ }
2927
+ function computeDecisionDeadline(waitHours, now = Date.now()) {
2928
+ return now + waitHours * 60 * 60 * 1000;
2929
+ }
2930
+ function humanDuration(ms) {
2931
+ const total = Math.round(ms / 1000);
2932
+ const m = Math.floor(total / 60);
2933
+ const s = total % 60;
2934
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
2935
+ }
2936
+ function formatBudgetComment(i) {
2937
+ const lines = [HEADLINE[i.trigger](i)];
2938
+ if (i.trigger !== "max_attempts") {
2939
+ lines.push(`${i.toolCalls} tool calls · ${humanDuration(i.durationMs)} · $${i.costUsd.toFixed(2)}.`);
2940
+ if (i.lastAction)
2941
+ lines.push(`Last action: ${i.lastAction}.`);
2942
+ }
2943
+ lines.push(i.branchName ? `The work is parked, not lost: branch \`${i.branchName}\`, worktree kept.` : "The work is parked, not lost.");
2944
+ lines.push(i.trigger === "max_attempts" ? `Continue to start a fresh attempt, or stop and I'll hand the card back.` : `Continue to pick up from where I stopped with a fresh turn budget, or stop and I'll hand the card back.`);
2945
+ lines.push(`This decision expires in ${i.waitHours}h.`);
2946
+ return lines.join(`
2947
+ `);
2948
+ }
2949
+ function formatExpiredAttemptCapComment() {
2950
+ return [
2951
+ "The decision window closed with no answer.",
2952
+ "Nothing was running, so there is nothing to resume — reassign the card when you want me to try again."
2953
+ ].join(`
2954
+ `);
2955
+ }
2956
+ function formatExpiredParkComment(i) {
2957
+ const lines = ["The decision window closed with no answer."];
2958
+ lines.push(i.branchName ? `The work is not lost: branch \`${i.branchName}\` is on origin.` : "The work is not lost, though no branch was recorded for this run.");
2959
+ if (i.cliSessionId) {
2960
+ lines.push(`CLI session \`${i.cliSessionId}\` is still available for a manual \`claude --resume\`.`);
2961
+ }
2962
+ return lines.join(`
2963
+ `);
2964
+ }
2965
+ function formatResumeConflictComment(i) {
2966
+ const lines = [
2967
+ "I could not pick this back up: another driver holds the agent session on this card.",
2968
+ `The server said: ${i.holderMessage}`,
2969
+ "Nothing was thrown away — the run is still parked exactly where it stopped."
2970
+ ];
2971
+ lines.push(i.branchName ? `Branch \`${i.branchName}\` and its worktree are kept.` : "The worktree is kept, though no branch was recorded for this run.");
2972
+ if (i.cliSessionId) {
2973
+ lines.push(`CLI session \`${i.cliSessionId}\` is still resumable, here or by hand.`);
2974
+ }
2975
+ lines.push(`End the other session and press Continue again. I'll hold for ${i.waitHours}h, then hand the card back.`);
2976
+ return lines.join(`
2977
+ `);
2978
+ }
2979
+ var BudgetPauseError, MAX_GRANTED_TURNS = 1000, HEADLINE;
2980
+ var init_budget_pause = __esm(() => {
2981
+ BudgetPauseError = class BudgetPauseError extends Error {
2982
+ trigger;
2983
+ constructor(trigger) {
2984
+ super(`budget limit reached: ${trigger}`);
2985
+ this.trigger = trigger;
2986
+ this.name = "BudgetPauseError";
2987
+ }
2988
+ };
2989
+ HEADLINE = {
2990
+ max_turns: (i) => `Turn budget exhausted — ${i.maxTurns} of ${i.maxTurns} turns.`,
2991
+ timeout: (i) => `Wall-clock budget exhausted after ${humanDuration(i.durationMs)}.`,
2992
+ max_attempts: () => "Attempt budget exhausted — I have used every attempt on this card."
2993
+ };
2994
+ });
2995
+
2692
2996
  // src/queue.ts
2693
2997
  import { log as log7 } from "@gethmy/harness";
2694
2998
 
@@ -3078,7 +3382,7 @@ var init_episode_writer = __esm(() => {
3078
3382
  });
3079
3383
 
3080
3384
  // src/completion.ts
3081
- import { execFileSync as execFileSync2 } from "node:child_process";
3385
+ import { execFileSync as execFileSync3 } from "node:child_process";
3082
3386
  import {
3083
3387
  attemptAutoFix,
3084
3388
  captureDiffStat,
@@ -3106,6 +3410,9 @@ function describeNoCommitFailure(numTurns, maxTurns) {
3106
3410
  failureSummary: maxTurnsExhausted ? `Agent exhausted its ${maxTurns}-turn budget without committing any changes` : "Agent finished without making any changes to commit"
3107
3411
  };
3108
3412
  }
3413
+ function noCommitOutcome(maxTurnsExhausted, pauseEnabled) {
3414
+ return maxTurnsExhausted && pauseEnabled ? "park" : "fail";
3415
+ }
3109
3416
  function buildTokenPayload(stats) {
3110
3417
  if (!stats?.cost)
3111
3418
  return {};
@@ -3119,7 +3426,7 @@ function buildTokenPayload(stats) {
3119
3426
  numTurns: stats.cost.numTurns
3120
3427
  };
3121
3428
  }
3122
- async function runCompletion(client, card, branchName, worktreePath, config, workerId, sessionStats, workspaceId, agentSessionId, stateStore, onMovedToCompletion, onBeforeWorktreeCleanup) {
3429
+ async function runCompletion(client, card, branchName, worktreePath, config, workerId, sessionStats, workspaceId, agentSessionId, stateStore, onMovedToCompletion, onBeforeWorktreeCleanup, runBaselineSha, effectiveMaxTurns) {
3123
3430
  let verificationResult = {
3124
3431
  passed: true,
3125
3432
  buildErrors: [],
@@ -3132,9 +3439,13 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3132
3439
  runFormatFix(worktreePath, config.verification.timeout, workerId);
3133
3440
  }
3134
3441
  commitUncommittedChanges(worktreePath, card);
3135
- const hasCommits = checkHasCommits(worktreePath, config.worktree.baseBranch);
3442
+ const hasCommits = checkHasCommits(worktreePath, config.worktree.baseBranch, runBaselineSha);
3136
3443
  if (!hasCommits) {
3137
- const { maxTurnsExhausted, failureSummary } = describeNoCommitFailure(sessionStats?.cost?.numTurns ?? 0, config.claude.maxTurns);
3444
+ const { maxTurnsExhausted, failureSummary } = describeNoCommitFailure(sessionStats?.cost?.numTurns ?? 0, effectiveMaxTurns ?? config.claude.maxTurns);
3445
+ if (noCommitOutcome(maxTurnsExhausted, config.budget.pause.enabled) === "park") {
3446
+ log9.warn(TAG9, `No commits on branch ${branchName} — ${failureSummary}; parking for a decision`);
3447
+ return "park";
3448
+ }
3138
3449
  log9.warn(TAG9, `No commits on branch ${branchName} — ${failureSummary}; counting as a failed attempt`);
3139
3450
  await moveCardToColumn(client, card, config.pickupColumns[0] ?? "To Do");
3140
3451
  await client.endAgentSession(card.id, {
@@ -3320,7 +3631,7 @@ function buildVerificationFailureSummary(result, autoFixAttempts) {
3320
3631
  }
3321
3632
  function readHeadSha(worktreePath) {
3322
3633
  try {
3323
- return execFileSync2("git", ["rev-parse", "HEAD"], {
3634
+ return execFileSync3("git", ["rev-parse", "HEAD"], {
3324
3635
  cwd: worktreePath,
3325
3636
  encoding: "utf-8"
3326
3637
  }).trim();
@@ -3331,7 +3642,7 @@ function readHeadSha(worktreePath) {
3331
3642
  function commitUncommittedChanges(worktreePath, card) {
3332
3643
  let status = "";
3333
3644
  try {
3334
- status = execFileSync2("git", ["status", "--porcelain"], {
3645
+ status = execFileSync3("git", ["status", "--porcelain"], {
3335
3646
  cwd: worktreePath,
3336
3647
  encoding: "utf-8"
3337
3648
  }).trim();
@@ -3344,11 +3655,11 @@ function commitUncommittedChanges(worktreePath, card) {
3344
3655
  const title = card.title?.trim() || "agent changes";
3345
3656
  const message = `#${card.short_id} ${title}`;
3346
3657
  try {
3347
- execFileSync2("git", ["add", "-A"], {
3658
+ execFileSync3("git", ["add", "-A"], {
3348
3659
  cwd: worktreePath,
3349
3660
  encoding: "utf-8"
3350
3661
  });
3351
- execFileSync2("git", ["commit", "-m", message], {
3662
+ execFileSync3("git", ["commit", "-m", message], {
3352
3663
  cwd: worktreePath,
3353
3664
  encoding: "utf-8"
3354
3665
  });
@@ -3359,9 +3670,17 @@ function commitUncommittedChanges(worktreePath, card) {
3359
3670
  return false;
3360
3671
  }
3361
3672
  }
3362
- function checkHasCommits(worktreePath, baseBranch) {
3673
+ function checkHasCommits(worktreePath, baseBranch, baselineSha, gitImpl = (args, cwd) => execFileSync3("git", args, { cwd, encoding: "utf-8" })) {
3674
+ if (baselineSha) {
3675
+ try {
3676
+ gitImpl(["merge-base", "--is-ancestor", baselineSha, "HEAD"], worktreePath);
3677
+ } catch {
3678
+ return false;
3679
+ }
3680
+ }
3681
+ const range = baselineSha ? `${baselineSha}..HEAD` : `origin/${baseBranch}..HEAD`;
3363
3682
  try {
3364
- const count = execFileSync2("git", ["rev-list", "--count", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
3683
+ const count = gitImpl(["rev-list", "--count", range], worktreePath).trim();
3365
3684
  return parseInt(count, 10) > 0;
3366
3685
  } catch {
3367
3686
  return false;
@@ -3370,7 +3689,7 @@ function checkHasCommits(worktreePath, baseBranch) {
3370
3689
  async function postSummary(client, card, branchName, worktreePath, prUrl, baseBranch, sessionStats) {
3371
3690
  let commitLog = "";
3372
3691
  try {
3373
- commitLog = execFileSync2("git", ["log", "--oneline", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
3692
+ commitLog = execFileSync3("git", ["log", "--oneline", `origin/${baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8" }).trim();
3374
3693
  } catch {}
3375
3694
  const SUMMARY_MARKER = `---
3376
3695
  **Agent completed**`;
@@ -3531,6 +3850,9 @@ class ProgressTracker {
3531
3850
  this.heartbeatTimer = null;
3532
3851
  }
3533
3852
  }
3853
+ get isStopped() {
3854
+ return this.stopped;
3855
+ }
3534
3856
  get stats() {
3535
3857
  return {
3536
3858
  filesEdited: this.filesEdited.size,
@@ -3641,6 +3963,9 @@ class ProgressTracker {
3641
3963
  this.scheduleUpdate(this.currentTaskLabel());
3642
3964
  }
3643
3965
  }
3966
+ get lastActionSummary() {
3967
+ return this.lastAction || null;
3968
+ }
3644
3969
  currentTaskLabel() {
3645
3970
  if (this.lastAction)
3646
3971
  return this.lastAction;
@@ -3798,89 +4123,280 @@ var init_progress_tracker = __esm(() => {
3798
4123
  };
3799
4124
  });
3800
4125
 
3801
- // src/review-completion.ts
3802
- import { readFileSync as readFileSync2, statSync } from "node:fs";
3803
- import {
3804
- cleanupWorktree as cleanupWorktree2,
3805
- createPullRequest as createPullRequest2,
3806
- detectGitProvider as detectGitProvider4,
3807
- extractPrUrl as extractPrUrl2,
3808
- getBranchWebUrl as getBranchWebUrl2,
3809
- getHeadSha,
3810
- log as log11,
3811
- pushBranch as pushBranch2,
3812
- renameRemoteBranch,
3813
- upsertReviewedSha
3814
- } from "@gethmy/harness";
3815
- function clampSubtaskTitle(title) {
3816
- return title.length > MAX_SUBTASK_TITLE ? `${title.slice(0, MAX_SUBTASK_TITLE - 3)}...` : title;
4126
+ // src/prompt.ts
4127
+ import { log as log11 } from "@gethmy/harness";
4128
+ function buildSteeringPrompt(messages) {
4129
+ if (messages.length === 1)
4130
+ return messages[0];
4131
+ return messages.map((m, i) => `${i + 1}. ${m}`).join(`
4132
+ `);
3817
4133
  }
3818
- function renderFindingBlock(f) {
3819
- const locationLine = f.location ? `
3820
- Location: ${f.location}` : "";
3821
- return `**[${f.severity}] ${f.title}**
3822
- ${f.description}${locationLine}`;
4134
+ function renderPreviousAttemptsSection(failures) {
4135
+ if (failures.length === 0)
4136
+ return "";
4137
+ const lines = failures.map((f) => {
4138
+ const tag = f.reason ? `[${f.reason}] ` : "";
4139
+ return `- ${tag}${f.summary}`;
4140
+ });
4141
+ return [
4142
+ "## Previous attempt feedback",
4143
+ "This is a re-attempt on the branch your last run already pushed — build on that existing work and FIX the issues below. Do NOT reimplement from scratch or revert the prior commits.",
4144
+ ...lines
4145
+ ].join(`
4146
+ `);
3823
4147
  }
3824
- function buildFindingComments(findings) {
3825
- const header = `**Review findings — ${findings.length} blocking issue(s) to resolve.**`;
3826
- const sep = `
3827
-
3828
- `;
3829
- const bodies = [];
3830
- let current = header;
3831
- for (const f of findings) {
3832
- let block = renderFindingBlock(f);
3833
- const maxBlock = COMMENT_BODY_BUDGET - header.length - sep.length;
3834
- if (block.length > maxBlock) {
3835
- const suffix = `
3836
- …[truncated]`;
3837
- block = `${block.slice(0, Math.max(0, maxBlock - suffix.length))}${suffix}`;
3838
- }
3839
- if (current.length + sep.length + block.length > COMMENT_BODY_BUDGET) {
3840
- bodies.push(current);
3841
- current = `${header}${sep}${block}`;
3842
- } else {
3843
- current = `${current}${sep}${block}`;
3844
- }
4148
+ async function buildPrompt(enriched, branchName, worktreePath, client, workspaceId, projectId) {
4149
+ const { card } = enriched;
4150
+ const [pastEpisodesSection, referenceSection] = await Promise.all([
4151
+ renderPastEpisodesSection(client, card.title, card.description ?? "", workspaceId, projectId),
4152
+ renderReferenceSection(client, card.title, card.description ?? "", workspaceId, projectId)
4153
+ ]);
4154
+ try {
4155
+ const result = await client.generateCardPrompt({
4156
+ cardId: card.id,
4157
+ workspaceId,
4158
+ projectId,
4159
+ variant: "execute",
4160
+ customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
4161
+ Do NOT push to main. All your work stays on \`${branchName}\`.
4162
+ The daemon owns the run lifecycle: once your work is committed it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this work tells you to move the card or end the session as a final step, SKIP it — it is handled for you (those tools are disabled for this run). Finish the implementation, commit, and stop.`
4163
+ });
4164
+ log11.info(TAG11, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
4165
+ return result.prompt + pastEpisodesSection + referenceSection;
4166
+ } catch (err) {
4167
+ const msg = err instanceof Error ? err.message : String(err);
4168
+ log11.warn(TAG11, `Failed to generate prompt via API, using fallback: ${msg}`);
4169
+ const commentsSection = await renderCommentsSection(client, card.id);
4170
+ return buildFallbackPrompt(enriched, branchName, worktreePath) + commentsSection + pastEpisodesSection + referenceSection;
3845
4171
  }
3846
- if (current.length > header.length)
3847
- bodies.push(current);
3848
- return bodies;
3849
4172
  }
3850
- function acceptanceSummaryLine(checks) {
3851
- if (!checks || checks.length === 0)
4173
+ async function renderCommentsSection(client, cardId) {
4174
+ try {
4175
+ const { comments } = await client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc`);
4176
+ if (!Array.isArray(comments) || comments.length === 0)
4177
+ return "";
4178
+ const section = serializeCommentThread(comments, {
4179
+ heading: "Comments",
4180
+ maxComments: 40
4181
+ });
4182
+ return section ? `
4183
+
4184
+ ${section}` : "";
4185
+ } catch (err) {
4186
+ log11.warn(TAG11, "comment-thread fetch failed", {
4187
+ event: "comment_fetch_failed",
4188
+ error: err instanceof Error ? err.message : String(err)
4189
+ });
3852
4190
  return "";
3853
- const counts = { pass: 0, partial: 0, fail: 0, unverifiable: 0 };
3854
- for (const c of checks)
3855
- counts[c.status]++;
3856
- const flagged = [];
3857
- if (counts.fail)
3858
- flagged.push(`${counts.fail} fail`);
3859
- if (counts.partial)
3860
- flagged.push(`${counts.partial} partial`);
3861
- if (counts.unverifiable)
3862
- flagged.push(`${counts.unverifiable} unverifiable`);
3863
- const detail = flagged.length ? ` (${flagged.join(", ")})` : "";
3864
- return `Acceptance: ${counts.pass}/${checks.length} pass${detail}`;
3865
- }
3866
- async function persistReviewedSha(client, card, worktreePath) {
3867
- const headSha = getHeadSha(worktreePath);
3868
- if (!headSha)
3869
- return;
3870
- const { card: latest } = await client.getCard(card.id);
3871
- const desc = latest.description || "";
3872
- const next = upsertReviewedSha(desc, headSha);
3873
- if (next !== desc) {
3874
- await client.updateCard(card.id, { description: next });
3875
4191
  }
3876
4192
  }
3877
- function tailRunLog(path, bytes = RUN_LOG_TAIL_BYTES) {
4193
+ async function renderPastEpisodesSection(client, title, description, workspaceId, projectId) {
4194
+ if (!projectId)
4195
+ return "";
3878
4196
  try {
3879
- const size = statSync(path).size;
3880
- if (size === 0)
3881
- return null;
3882
- const start = Math.max(0, size - bytes);
3883
- const buf = readFileSync2(path);
4197
+ const query = `${title}
4198
+ ${description}`.trim();
4199
+ const { entities } = await client.harmonyRecall({
4200
+ workspaceId,
4201
+ projectId,
4202
+ query,
4203
+ type: ["solution", "error"],
4204
+ memory_tier: "episode",
4205
+ scope: "project",
4206
+ topK: 3,
4207
+ includeEpisodes: true,
4208
+ consumer: "agent-prompt"
4209
+ });
4210
+ if (entities.length === 0)
4211
+ return "";
4212
+ const bullets = entities.map((entity) => {
4213
+ const e = entity;
4214
+ const meta = e.metadata ?? {};
4215
+ const outcomeTag = meta.outcome ? `[${meta.outcome}]` : "[?]";
4216
+ const approach = meta.approach_summary ?? "";
4217
+ const lines = [
4218
+ `- ${outcomeTag} ${e.title ?? "(untitled episode)"}`,
4219
+ ` Approach: ${approach}`
4220
+ ];
4221
+ if (meta.key_insight)
4222
+ lines.push(` Key insight: ${meta.key_insight}`);
4223
+ if (meta.changed_files && meta.changed_files.length > 0) {
4224
+ const shown = meta.changed_files.slice(0, 8);
4225
+ const extra = meta.changed_files.length - shown.length;
4226
+ const suffix = extra > 0 ? ` (+${extra} more)` : "";
4227
+ lines.push(` Changed files: ${shown.join(", ")}${suffix}`);
4228
+ }
4229
+ return lines.join(`
4230
+ `);
4231
+ }).join(`
4232
+ `);
4233
+ return `
4234
+
4235
+ ## Similar past tasks
4236
+ ${bullets}`;
4237
+ } catch (err) {
4238
+ log11.warn(TAG11, "past-episodes recall failed", {
4239
+ event: "episode_recall_failed",
4240
+ error: err instanceof Error ? err.message : String(err)
4241
+ });
4242
+ return "";
4243
+ }
4244
+ }
4245
+ async function renderReferenceSection(client, title, description, workspaceId, projectId) {
4246
+ try {
4247
+ const query = `${title}
4248
+ ${description}`.trim();
4249
+ const { entities } = await client.harmonyRecall({
4250
+ workspaceId,
4251
+ projectId,
4252
+ query,
4253
+ memory_tier: "reference",
4254
+ topK: 5,
4255
+ consumer: "agent-prompt"
4256
+ });
4257
+ if (entities.length === 0)
4258
+ return "";
4259
+ const bullets = entities.map((entity) => {
4260
+ const e = entity;
4261
+ const content = (e.content ?? "").slice(0, 300);
4262
+ return `- ${e.title ?? "(untitled)"}
4263
+ ${content}`;
4264
+ }).join(`
4265
+ `);
4266
+ return `
4267
+
4268
+ ## How we work here
4269
+ ${bullets}`;
4270
+ } catch (err) {
4271
+ log11.warn(TAG11, "reference recall failed", {
4272
+ event: "reference_recall_failed",
4273
+ error: err instanceof Error ? err.message : String(err)
4274
+ });
4275
+ return "";
4276
+ }
4277
+ }
4278
+ function buildFallbackPrompt(enriched, branchName, worktreePath) {
4279
+ const { card, column, labels, subtasks } = enriched;
4280
+ const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
4281
+ const subtaskStr = subtasks.length > 0 ? subtasks.map((s) => `- [${s.completed ? "x" : " "}] ${s.title}`).join(`
4282
+ `) : "No subtasks defined.";
4283
+ const description = card.description?.trim() || "No description provided.";
4284
+ return `You are an AI agent working on a task from the Harmony project board.
4285
+
4286
+ ## Card: #${card.short_id} - ${card.title}
4287
+ **Labels**: ${labelStr}
4288
+ **Column**: ${column.name}
4289
+ **Priority**: ${card.priority}
4290
+
4291
+ ## Description
4292
+ ${description}
4293
+
4294
+ ## Subtasks
4295
+ ${subtaskStr}
4296
+
4297
+ ## Instructions
4298
+ 1. Read the codebase and understand the context needed for this task
4299
+ 2. Report progress via harmony_update_agent_progress at key milestones:
4300
+ - After reading codebase and forming a plan (~20%)
4301
+ - After each major implementation step (~30-60%)
4302
+ - After completing each subtask (also toggle via harmony_toggle_subtask)
4303
+ - Before committing (~65%)
4304
+ Include a brief currentTask description.
4305
+ 3. Implement the changes on branch \`${branchName}\`
4306
+ 4. Commit your work with clear, descriptive commit messages
4307
+ 5. When the work is committed, STOP. The daemon owns the run lifecycle: it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself — those tools are disabled for this run.
4308
+
4309
+ You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
4310
+ Do NOT push to main. All your work stays on \`${branchName}\`.`;
4311
+ }
4312
+ var TAG11 = "prompt";
4313
+ var init_prompt = __esm(() => {
4314
+ init_dist();
4315
+ });
4316
+
4317
+ // src/review-completion.ts
4318
+ import { readFileSync as readFileSync2, statSync } from "node:fs";
4319
+ import {
4320
+ cleanupWorktree as cleanupWorktree2,
4321
+ createPullRequest as createPullRequest2,
4322
+ detectGitProvider as detectGitProvider4,
4323
+ extractPrUrl as extractPrUrl2,
4324
+ getBranchWebUrl as getBranchWebUrl2,
4325
+ getHeadSha,
4326
+ log as log12,
4327
+ pushBranch as pushBranch2,
4328
+ renameRemoteBranch,
4329
+ upsertReviewedSha
4330
+ } from "@gethmy/harness";
4331
+ function clampSubtaskTitle(title) {
4332
+ return title.length > MAX_SUBTASK_TITLE ? `${title.slice(0, MAX_SUBTASK_TITLE - 3)}...` : title;
4333
+ }
4334
+ function renderFindingBlock(f) {
4335
+ const locationLine = f.location ? `
4336
+ Location: ${f.location}` : "";
4337
+ return `**[${f.severity}] ${f.title}**
4338
+ ${f.description}${locationLine}`;
4339
+ }
4340
+ function buildFindingComments(findings) {
4341
+ const header = `**Review findings — ${findings.length} blocking issue(s) to resolve.**`;
4342
+ const sep = `
4343
+
4344
+ `;
4345
+ const bodies = [];
4346
+ let current = header;
4347
+ for (const f of findings) {
4348
+ let block = renderFindingBlock(f);
4349
+ const maxBlock = COMMENT_BODY_BUDGET - header.length - sep.length;
4350
+ if (block.length > maxBlock) {
4351
+ const suffix = `
4352
+ …[truncated]`;
4353
+ block = `${block.slice(0, Math.max(0, maxBlock - suffix.length))}${suffix}`;
4354
+ }
4355
+ if (current.length + sep.length + block.length > COMMENT_BODY_BUDGET) {
4356
+ bodies.push(current);
4357
+ current = `${header}${sep}${block}`;
4358
+ } else {
4359
+ current = `${current}${sep}${block}`;
4360
+ }
4361
+ }
4362
+ if (current.length > header.length)
4363
+ bodies.push(current);
4364
+ return bodies;
4365
+ }
4366
+ function acceptanceSummaryLine(checks) {
4367
+ if (!checks || checks.length === 0)
4368
+ return "";
4369
+ const counts = { pass: 0, partial: 0, fail: 0, unverifiable: 0 };
4370
+ for (const c of checks)
4371
+ counts[c.status]++;
4372
+ const flagged = [];
4373
+ if (counts.fail)
4374
+ flagged.push(`${counts.fail} fail`);
4375
+ if (counts.partial)
4376
+ flagged.push(`${counts.partial} partial`);
4377
+ if (counts.unverifiable)
4378
+ flagged.push(`${counts.unverifiable} unverifiable`);
4379
+ const detail = flagged.length ? ` (${flagged.join(", ")})` : "";
4380
+ return `Acceptance: ${counts.pass}/${checks.length} pass${detail}`;
4381
+ }
4382
+ async function persistReviewedSha(client, card, worktreePath) {
4383
+ const headSha = getHeadSha(worktreePath);
4384
+ if (!headSha)
4385
+ return;
4386
+ const { card: latest } = await client.getCard(card.id);
4387
+ const desc = latest.description || "";
4388
+ const next = upsertReviewedSha(desc, headSha);
4389
+ if (next !== desc) {
4390
+ await client.updateCard(card.id, { description: next });
4391
+ }
4392
+ }
4393
+ function tailRunLog(path, bytes = RUN_LOG_TAIL_BYTES) {
4394
+ try {
4395
+ const size = statSync(path).size;
4396
+ if (size === 0)
4397
+ return null;
4398
+ const start = Math.max(0, size - bytes);
4399
+ const buf = readFileSync2(path);
3884
4400
  return buf.subarray(start).toString("utf-8");
3885
4401
  } catch {
3886
4402
  return null;
@@ -3932,7 +4448,7 @@ function parseReviewOutput(stdout) {
3932
4448
  try {
3933
4449
  const parsed = JSON.parse(raw);
3934
4450
  if (parsed && typeof parsed === "object" && "verdict" in parsed) {
3935
- log11.debug(TAG11, "Parsed review output from fenced JSON block");
4451
+ log12.debug(TAG12, "Parsed review output from fenced JSON block");
3936
4452
  return extractResult(parsed);
3937
4453
  }
3938
4454
  } catch {}
@@ -3958,21 +4474,21 @@ function parseReviewOutput(stdout) {
3958
4474
  try {
3959
4475
  const parsed = JSON.parse(candidates[i]);
3960
4476
  if (parsed && typeof parsed === "object" && "verdict" in parsed) {
3961
- log11.debug(TAG11, "Parsed review output from raw JSON object");
4477
+ log12.debug(TAG12, "Parsed review output from raw JSON object");
3962
4478
  return extractResult(parsed);
3963
4479
  }
3964
4480
  } catch {}
3965
4481
  }
3966
4482
  const verdictMatch = stdout.match(/"verdict"\s*:\s*"(approved|rejected)"/i);
3967
4483
  if (verdictMatch) {
3968
- log11.warn(TAG11, `Parsed verdict via regex fallback — findings lost (${verdictMatch[1]})`);
4484
+ log12.warn(TAG12, `Parsed verdict via regex fallback — findings lost (${verdictMatch[1]})`);
3969
4485
  return {
3970
4486
  verdict: verdictMatch[1].toLowerCase(),
3971
4487
  summary: "Parsed via regex fallback — original JSON was malformed. Check run log.",
3972
4488
  findings: []
3973
4489
  };
3974
4490
  }
3975
- log11.warn(TAG11, "Failed to parse review JSON output — returning error verdict (card stays in Review)");
4491
+ log12.warn(TAG12, "Failed to parse review JSON output — returning error verdict (card stays in Review)");
3976
4492
  return {
3977
4493
  verdict: "error",
3978
4494
  summary: stdout.slice(0, 500),
@@ -4005,7 +4521,7 @@ async function postReviewComment(client, card, commentType, body) {
4005
4521
  try {
4006
4522
  await client.addComment(card.id, body, { commentType });
4007
4523
  } catch (err) {
4008
- log11.error(TAG11, `Failed to post review comment to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4524
+ log12.error(TAG12, `Failed to post review comment to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4009
4525
  }
4010
4526
  }
4011
4527
  async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath, workspaceId, agentSessionId, stateStore, resolvedFromPrUrl) {
@@ -4019,11 +4535,11 @@ async function runReviewCompletion(client, card, result, config, worktreePath, b
4019
4535
  const currentCycle = getReviewCycle(freshDesc) + 1;
4020
4536
  const maxCycles = config.review.maxReviewCycles;
4021
4537
  if (result.verdict === "error") {
4022
- log11.warn(TAG11, `#${card.short_id} review output unparseable — labelling "${NEED_REVIEW_LABEL}" for manual inspection`);
4538
+ log12.warn(TAG12, `#${card.short_id} review output unparseable — labelling "${NEED_REVIEW_LABEL}" for manual inspection`);
4023
4539
  try {
4024
4540
  await addLabelByName(client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
4025
4541
  } catch (err) {
4026
- log11.warn(TAG11, `Failed to add "${NEED_REVIEW_LABEL}" label: ${err instanceof Error ? err.message : err}`);
4542
+ log12.warn(TAG12, `Failed to add "${NEED_REVIEW_LABEL}" label: ${err instanceof Error ? err.message : err}`);
4027
4543
  }
4028
4544
  if (config.review.postFindings) {
4029
4545
  const rawTail = runLogPath ? tailRunLog(runLogPath) : null;
@@ -4066,7 +4582,7 @@ ${runLogTail}
4066
4582
  renameRemoteBranch(branchName, newRef, worktreePath);
4067
4583
  approvedBranch = newRef;
4068
4584
  } catch (err) {
4069
- log11.warn(TAG11, `Branch rename failed (continuing on ${branchName}): ${err instanceof Error ? err.message : err}`);
4585
+ log12.warn(TAG12, `Branch rename failed (continuing on ${branchName}): ${err instanceof Error ? err.message : err}`);
4070
4586
  }
4071
4587
  }
4072
4588
  if (config.review.createPR && approvedBranch) {
@@ -4087,14 +4603,14 @@ ${runLogTail}
4087
4603
  });
4088
4604
  }
4089
4605
  } catch (err) {
4090
- log11.warn(TAG11, `Failed to persist PR URL to #${card.short_id} description: ${err instanceof Error ? err.message : err}`);
4606
+ log12.warn(TAG12, `Failed to persist PR URL to #${card.short_id} description: ${err instanceof Error ? err.message : err}`);
4091
4607
  }
4092
4608
  }
4093
4609
  if (branchName) {
4094
4610
  try {
4095
4611
  await persistReviewedSha(client, card, worktreePath);
4096
4612
  } catch (err) {
4097
- log11.warn(TAG11, `Failed to persist Reviewed-SHA to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4613
+ log12.warn(TAG12, `Failed to persist Reviewed-SHA to #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4098
4614
  }
4099
4615
  }
4100
4616
  if (config.review.postFindings) {
@@ -4116,7 +4632,7 @@ ${runLogTail}
4116
4632
  progressPercent: 100,
4117
4633
  ...buildTokenPayload(sessionStats)
4118
4634
  });
4119
- log11.info(TAG11, `#${card.short_id} approved${prUrl ? ` — PR: ${prUrl}` : ""} — labeled "${config.review.approvedLabel}"`);
4635
+ log12.info(TAG12, `#${card.short_id} approved${prUrl ? ` — PR: ${prUrl}` : ""} — labeled "${config.review.approvedLabel}"`);
4120
4636
  } else {
4121
4637
  const reworkFindings = result.findings.filter((f) => f.relatedToDiff !== false);
4122
4638
  const criticalFindings = reworkFindings.filter((f) => f.severity === "critical").slice(0, MAX_FINDINGS);
@@ -4124,7 +4640,7 @@ ${runLogTail}
4124
4640
  const linkedFindings = [...criticalFindings, ...majorFindings];
4125
4641
  const minorFindings = reworkFindings.filter((f) => f.severity === "minor").slice(0, MAX_FINDINGS);
4126
4642
  if (currentCycle >= maxCycles) {
4127
- log11.warn(TAG11, `#${card.short_id} reached max review cycles (${maxCycles}), moving to Done with note`);
4643
+ log12.warn(TAG12, `#${card.short_id} reached max review cycles (${maxCycles}), moving to Done with note`);
4128
4644
  await moveCardToColumn(client, card, config.review.moveToColumn);
4129
4645
  const body = [
4130
4646
  "**Review — needs human review.**",
@@ -4164,7 +4680,7 @@ ${runLogTail}
4164
4680
  try {
4165
4681
  await client.createSubtask(card.id, clampSubtaskTitle(`[${finding.severity}] ${finding.title}`));
4166
4682
  } catch (err) {
4167
- log11.error(TAG11, `Failed to create finding subtask: ${err instanceof Error ? err.message : err}`);
4683
+ log12.error(TAG12, `Failed to create finding subtask: ${err instanceof Error ? err.message : err}`);
4168
4684
  }
4169
4685
  }));
4170
4686
  if (linkedFindings.length > 0) {
@@ -4176,7 +4692,7 @@ ${runLogTail}
4176
4692
  try {
4177
4693
  await client.createSubtask(card.id, clampSubtaskTitle(finding.title));
4178
4694
  } catch (err) {
4179
- log11.error(TAG11, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
4695
+ log12.error(TAG12, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
4180
4696
  }
4181
4697
  }));
4182
4698
  const baseDesc = stripReviewSummary(freshDesc);
@@ -4184,7 +4700,7 @@ ${runLogTail}
4184
4700
  try {
4185
4701
  await client.updateCard(card.id, { description: updatedDesc });
4186
4702
  } catch (err) {
4187
- log11.error(TAG11, `Failed to update review cycle marker: ${err instanceof Error ? err.message : err}`);
4703
+ log12.error(TAG12, `Failed to update review cycle marker: ${err instanceof Error ? err.message : err}`);
4188
4704
  }
4189
4705
  const scopeLine = result.scopeCheck ? `Scope: ${result.scopeCheck.status}${result.scopeCheck.notes ? ` — ${result.scopeCheck.notes}` : ""}` : "";
4190
4706
  const body = [
@@ -4201,9 +4717,9 @@ ${runLogTail}
4201
4717
  if (config.planning.enabled && card.plan_id) {
4202
4718
  try {
4203
4719
  await client.updateCard(card.id, { needsPlanRefresh: true });
4204
- log11.info(TAG11, `#${card.short_id} flagged needs_plan_refresh after rejected review`);
4720
+ log12.info(TAG12, `#${card.short_id} flagged needs_plan_refresh after rejected review`);
4205
4721
  } catch (err) {
4206
- log11.warn(TAG11, `Failed to flag needs_plan_refresh for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4722
+ log12.warn(TAG12, `Failed to flag needs_plan_refresh for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
4207
4723
  }
4208
4724
  }
4209
4725
  await moveCardToColumn(client, card, config.review.failColumn);
@@ -4217,10 +4733,10 @@ ${runLogTail}
4217
4733
  recoveryBranch
4218
4734
  });
4219
4735
  } catch (err) {
4220
- log11.debug(TAG11, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
4736
+ log12.debug(TAG12, `recordFailureSummary failed: ${err instanceof Error ? err.message : err}`);
4221
4737
  }
4222
4738
  if (recoveryBranch) {
4223
- log11.info(TAG11, `#${card.short_id} recovery branch ${recoveryBranch}${recoveryUrl ? ` (${recoveryUrl})` : ""}`);
4739
+ log12.info(TAG12, `#${card.short_id} recovery branch ${recoveryBranch}${recoveryUrl ? ` (${recoveryUrl})` : ""}`);
4224
4740
  }
4225
4741
  await client.endAgentSession(card.id, {
4226
4742
  status: "failed",
@@ -4229,7 +4745,7 @@ ${runLogTail}
4229
4745
  recoveryBranch,
4230
4746
  ...buildTokenPayload(sessionStats)
4231
4747
  });
4232
- log11.info(TAG11, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
4748
+ log12.info(TAG12, `#${card.short_id} rejected (cycle ${currentCycle}/${maxCycles}) — moved to "${config.review.failColumn}"`);
4233
4749
  }
4234
4750
  if (workspaceId && (result.verdict === "approved" || result.verdict === "rejected")) {
4235
4751
  const originalEpisodeId = await findLatestImplementEpisode(client, workspaceId, card.project_id, card.short_id);
@@ -4251,7 +4767,7 @@ ${runLogTail}
4251
4767
  cleanupWorktree2(worktreePath, branchName);
4252
4768
  }
4253
4769
  }
4254
- var TAG11 = "review-completion", MAX_FINDINGS = 10, MAX_SUBTASK_TITLE = 120, COMMENT_BODY_BUDGET = 9500, REVIEW_MARKER = `---
4770
+ var TAG12 = "review-completion", MAX_FINDINGS = 10, MAX_SUBTASK_TITLE = 120, COMMENT_BODY_BUDGET = 9500, REVIEW_MARKER = `---
4255
4771
  **Review:`, RUN_LOG_TAIL_BYTES = 2048;
4256
4772
  var init_review_completion = __esm(() => {
4257
4773
  init_board_helpers();
@@ -4398,7 +4914,7 @@ var init_review_prompt = __esm(() => {
4398
4914
  import { createWriteStream, mkdirSync } from "node:fs";
4399
4915
  import { homedir as homedir2 } from "node:os";
4400
4916
  import { join as join2 } from "node:path";
4401
- import { log as log12 } from "@gethmy/harness";
4917
+ import { log as log13 } from "@gethmy/harness";
4402
4918
  function openRunLog(tag, runId, shortId) {
4403
4919
  if (!runId)
4404
4920
  return null;
@@ -4409,7 +4925,7 @@ function openRunLog(tag, runId, shortId) {
4409
4925
  const stream = createWriteStream(path, { flags: "a" });
4410
4926
  return { path, stream };
4411
4927
  } catch (err) {
4412
- log12.warn(tag, `Failed to open run log: ${err instanceof Error ? err.message : err}`);
4928
+ log13.warn(tag, `Failed to open run log: ${err instanceof Error ? err.message : err}`);
4413
4929
  return null;
4414
4930
  }
4415
4931
  }
@@ -4430,6 +4946,8 @@ function isSessionConflict(err) {
4430
4946
  var exports_state_store = {};
4431
4947
  __export(exports_state_store, {
4432
4948
  newRunId: () => newRunId,
4949
+ isParkedRun: () => isParkedRun,
4950
+ isBudgetHeldRun: () => isBudgetHeldRun,
4433
4951
  defaultStatePath: () => defaultStatePath,
4434
4952
  StateStore: () => StateStore
4435
4953
  });
@@ -4442,7 +4960,7 @@ import {
4442
4960
  } from "node:fs";
4443
4961
  import { homedir as homedir3 } from "node:os";
4444
4962
  import { dirname, join as join3 } from "node:path";
4445
- import { log as log13 } from "@gethmy/harness";
4963
+ import { log as log14 } from "@gethmy/harness";
4446
4964
  function emptyState() {
4447
4965
  return {
4448
4966
  version: SCHEMA_VERSION,
@@ -4457,6 +4975,16 @@ function emptyState() {
4457
4975
  function todayUtc() {
4458
4976
  return new Date().toISOString().slice(0, 10);
4459
4977
  }
4978
+ function isParkedRun(run) {
4979
+ return run.status === "parked" && run.endedAt === null;
4980
+ }
4981
+ function isBudgetHeldRun(run) {
4982
+ if (run.endedAt !== null)
4983
+ return false;
4984
+ if (run.status === "parked")
4985
+ return true;
4986
+ return run.grantedTurns != null;
4987
+ }
4460
4988
  function newRunId() {
4461
4989
  const ts = Date.now().toString(36);
4462
4990
  const rand = Math.random().toString(36).slice(2, 10);
@@ -4488,7 +5016,7 @@ class StateStore {
4488
5016
  const raw = readFileSync3(this.path, "utf-8");
4489
5017
  const parsed = JSON.parse(raw);
4490
5018
  if (parsed?.version !== SCHEMA_VERSION) {
4491
- log13.warn(TAG12, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — migrating (preserving card budget/attempts, dropping in-flight runs)`);
5019
+ log14.warn(TAG13, `state file has version ${parsed?.version}, expected ${SCHEMA_VERSION} — migrating (preserving card budget/attempts, dropping in-flight runs)`);
4492
5020
  return {
4493
5021
  version: SCHEMA_VERSION,
4494
5022
  daemonId: null,
@@ -4509,7 +5037,7 @@ class StateStore {
4509
5037
  daily: parsed.daily ?? []
4510
5038
  };
4511
5039
  } catch (err) {
4512
- log13.error(TAG12, `failed to read state file: ${err instanceof Error ? err.message : err}`);
5040
+ log14.error(TAG13, `failed to read state file: ${err instanceof Error ? err.message : err}`);
4513
5041
  return emptyState();
4514
5042
  }
4515
5043
  }
@@ -4568,12 +5096,40 @@ class StateStore {
4568
5096
  }
4569
5097
  return this.updateRun(runId, patch);
4570
5098
  }
5099
+ parkRun(runId, opts) {
5100
+ const patch = {
5101
+ status: "parked",
5102
+ pauseTrigger: opts.pauseTrigger,
5103
+ blockerCommentId: opts.blockerCommentId,
5104
+ awaitingDecisionUntil: opts.awaitingDecisionUntil,
5105
+ parkedAt: Date.now()
5106
+ };
5107
+ if (opts.costCents !== undefined && opts.costCents > 0) {
5108
+ patch.costCents = opts.costCents;
5109
+ }
5110
+ if (opts.numTurns !== undefined && opts.numTurns > 0) {
5111
+ patch.numTurns = opts.numTurns;
5112
+ }
5113
+ return this.updateRun(runId, patch);
5114
+ }
4571
5115
  getRun(runId) {
4572
5116
  return this.state.runs.find((r) => r.runId === runId) ?? null;
4573
5117
  }
4574
5118
  getActiveRuns() {
4575
5119
  return this.state.runs.filter((r) => r.endedAt === null);
4576
5120
  }
5121
+ getParkedRuns() {
5122
+ return this.state.runs.filter(isParkedRun);
5123
+ }
5124
+ getParkedRunForCard(cardId) {
5125
+ return this.state.runs.find((r) => r.cardId === cardId && isParkedRun(r)) ?? null;
5126
+ }
5127
+ getResumableRunForCard(cardId) {
5128
+ return this.state.runs.find((r) => r.cardId === cardId && r.status === "active" && r.endedAt === null && r.grantedTurns != null) ?? null;
5129
+ }
5130
+ getResumableRuns() {
5131
+ return this.state.runs.filter((r) => r.status === "active" && r.endedAt === null && r.grantedTurns != null);
5132
+ }
4577
5133
  getRunsForCard(cardId) {
4578
5134
  return this.state.runs.filter((r) => r.cardId === cardId);
4579
5135
  }
@@ -4652,6 +5208,22 @@ class StateStore {
4652
5208
  rec.loopIterations = 0;
4653
5209
  await this.persist();
4654
5210
  }
5211
+ async markAwaitingDecision(cardId, opts) {
5212
+ const rec = this.ensureCard(cardId);
5213
+ rec.awaitingDecisionUntil = opts.until;
5214
+ rec.blockerCommentId = opts.blockerCommentId;
5215
+ rec.awaitingDecisionAgentIdentifier = opts.agentIdentifier ?? null;
5216
+ await this.persist();
5217
+ }
5218
+ async clearAwaitingDecision(cardId) {
5219
+ const rec = this.getCard(cardId);
5220
+ if (!rec || rec.awaitingDecisionUntil == null)
5221
+ return;
5222
+ rec.awaitingDecisionUntil = null;
5223
+ rec.blockerCommentId = null;
5224
+ rec.awaitingDecisionAgentIdentifier = null;
5225
+ await this.persist();
5226
+ }
4655
5227
  async resetAttempts(cardId) {
4656
5228
  const rec = this.getCard(cardId);
4657
5229
  if (!rec || rec.attempts === 0)
@@ -4698,12 +5270,12 @@ class StateStore {
4698
5270
  return this.state.daily.find((d) => d.date === key)?.costCents ?? 0;
4699
5271
  }
4700
5272
  }
4701
- var TAG12 = "state-store", SCHEMA_VERSION = 1;
5273
+ var TAG13 = "state-store", SCHEMA_VERSION = 1;
4702
5274
  var init_state_store = () => {};
4703
5275
 
4704
5276
  // src/stream-parser.ts
4705
5277
  import { EventEmitter } from "node:events";
4706
- import { log as log14 } from "@gethmy/harness";
5278
+ import { log as log15 } from "@gethmy/harness";
4707
5279
  function normalizeToolResultContent(raw) {
4708
5280
  if (raw == null)
4709
5281
  return;
@@ -4724,7 +5296,7 @@ function normalizeToolResultContent(raw) {
4724
5296
  return String(raw);
4725
5297
  }
4726
5298
  }
4727
- var TAG13 = "stream-parser", StreamParser;
5299
+ var TAG14 = "stream-parser", StreamParser;
4728
5300
  var init_stream_parser = __esm(() => {
4729
5301
  StreamParser = class StreamParser extends EventEmitter {
4730
5302
  buffer = "";
@@ -4771,14 +5343,14 @@ var init_stream_parser = __esm(() => {
4771
5343
  try {
4772
5344
  msg = JSON.parse(line);
4773
5345
  } catch {
4774
- log14.debug(TAG13, `Non-JSON line: ${line.slice(0, 100)}`);
5346
+ log15.debug(TAG14, `Non-JSON line: ${line.slice(0, 100)}`);
4775
5347
  return;
4776
5348
  }
4777
5349
  try {
4778
5350
  this.handleMessage(msg);
4779
5351
  } catch (err) {
4780
5352
  const errMsg = err instanceof Error ? err.message : String(err);
4781
- log14.warn(TAG13, `Error handling stream event: ${errMsg}`);
5353
+ log15.warn(TAG14, `Error handling stream event: ${errMsg}`);
4782
5354
  this.emit("parse_error", errMsg);
4783
5355
  }
4784
5356
  }
@@ -4854,7 +5426,7 @@ var init_stream_parser = __esm(() => {
4854
5426
  });
4855
5427
 
4856
5428
  // src/transitions.ts
4857
- import { log as log15 } from "@gethmy/harness";
5429
+ import { log as log16 } from "@gethmy/harness";
4858
5430
  async function withRetry(step, cardShortId, op, attempts, backoffMs) {
4859
5431
  let lastErr;
4860
5432
  for (let i = 0;i < attempts; i++) {
@@ -4865,7 +5437,7 @@ async function withRetry(step, cardShortId, op, attempts, backoffMs) {
4865
5437
  const msg2 = err instanceof Error ? err.message : String(err);
4866
5438
  if (i < attempts - 1) {
4867
5439
  const wait = backoffMs * 2 ** i;
4868
- log15.warn(TAG14, `${step} failed for #${cardShortId} (attempt ${i + 1}/${attempts}): ${msg2} — retrying in ${wait}ms`);
5440
+ log16.warn(TAG15, `${step} failed for #${cardShortId} (attempt ${i + 1}/${attempts}): ${msg2} — retrying in ${wait}ms`);
4869
5441
  await new Promise((r) => setTimeout(r, wait));
4870
5442
  }
4871
5443
  }
@@ -4889,10 +5461,10 @@ async function runTransition(client, card, plan, opts = {}) {
4889
5461
  if (opts.strictColumn) {
4890
5462
  throw new TransitionError("move", 1, msg);
4891
5463
  }
4892
- log15.warn(TAG14, `#${shortId}: ${msg} — skipping move`);
5464
+ log16.warn(TAG15, `#${shortId}: ${msg} — skipping move`);
4893
5465
  } else if (card.column_id !== target.id) {
4894
5466
  await withRetry("move", shortId, () => client.moveCard(card.id, target.id), attempts, backoffMs);
4895
- log15.info(TAG14, `#${shortId} → "${target.name}"`);
5467
+ log16.info(TAG15, `#${shortId} → "${target.name}"`);
4896
5468
  card.column_id = target.id;
4897
5469
  moveLanded = true;
4898
5470
  } else {
@@ -4911,7 +5483,7 @@ async function runTransition(client, card, plan, opts = {}) {
4911
5483
  continue;
4912
5484
  await withRetry("addLabel", shortId, () => client.addLabelToCard(card.id, labelId), attempts, backoffMs);
4913
5485
  existing.add(labelId);
4914
- log15.info(TAG14, `#${shortId} +label "${name}"`);
5486
+ log16.info(TAG15, `#${shortId} +label "${name}"`);
4915
5487
  }
4916
5488
  card.labelIds = Array.from(existing);
4917
5489
  }
@@ -4923,23 +5495,23 @@ async function runTransition(client, card, plan, opts = {}) {
4923
5495
  continue;
4924
5496
  await withRetry("removeLabel", shortId, () => client.removeLabelFromCard(card.id, match.id), attempts, backoffMs);
4925
5497
  existing.delete(match.id);
4926
- log15.info(TAG14, `#${shortId} -label "${name}"`);
5498
+ log16.info(TAG15, `#${shortId} -label "${name}"`);
4927
5499
  }
4928
5500
  card.labelIds = Array.from(existing);
4929
5501
  }
4930
5502
  if (plan.updateCard) {
4931
5503
  await withRetry("updateCard", shortId, () => client.updateCard(card.id, plan.updateCard), attempts, backoffMs);
4932
- log15.info(TAG14, `#${shortId} updated`);
5504
+ log16.info(TAG15, `#${shortId} updated`);
4933
5505
  }
4934
5506
  if (plan.endSession) {
4935
5507
  const endResult = await withRetry("endSession", shortId, () => client.endAgentSession(card.id, plan.endSession), attempts, backoffMs);
4936
5508
  result.endSession = endResult;
4937
- log15.info(TAG14, `#${shortId} session ended (${plan.endSession.status})`);
5509
+ log16.info(TAG15, `#${shortId} session ended (${plan.endSession.status})`);
4938
5510
  }
4939
5511
  if (plan.assignAgent !== undefined) {
4940
5512
  const assignedAgentId = plan.assignAgent;
4941
5513
  await withRetry("assignAgent", shortId, () => client.updateCard(card.id, { assignedAgentId }), attempts, backoffMs);
4942
- log15.info(TAG14, assignedAgentId ? `#${shortId} assigned → agent ${assignedAgentId}` : `#${shortId} unassigned`);
5514
+ log16.info(TAG15, assignedAgentId ? `#${shortId} assigned → agent ${assignedAgentId}` : `#${shortId} unassigned`);
4943
5515
  }
4944
5516
  if (opts.store && opts.runId) {
4945
5517
  try {
@@ -4953,11 +5525,11 @@ async function ensureLabel(client, projectId, name, color, attempts, backoffMs)
4953
5525
  const result = await withRetry("addLabel", 0, () => client.createLabel(projectId, { name, color: color ?? "#8b5cf6" }), attempts, backoffMs);
4954
5526
  return result?.label?.id ?? null;
4955
5527
  } catch (err) {
4956
- log15.warn(TAG14, `ensureLabel "${name}" failed: ${err instanceof Error ? err.message : err}`);
5528
+ log16.warn(TAG15, `ensureLabel "${name}" failed: ${err instanceof Error ? err.message : err}`);
4957
5529
  return null;
4958
5530
  }
4959
5531
  }
4960
- var TAG14 = "transition", TransitionError;
5532
+ var TAG15 = "transition", TransitionError;
4961
5533
  var init_transitions = __esm(() => {
4962
5534
  TransitionError = class TransitionError extends Error {
4963
5535
  step;
@@ -4974,14 +5546,14 @@ var init_transitions = __esm(() => {
4974
5546
  });
4975
5547
 
4976
5548
  // src/review-worker.ts
4977
- import { execFileSync as execFileSync3 } from "node:child_process";
5549
+ import { execFileSync as execFileSync4 } from "node:child_process";
4978
5550
  import {
4979
5551
  buildGateCollectorRegistry,
4980
5552
  cleanupWorktree as cleanupWorktree3,
4981
5553
  collectGateEvidence,
4982
5554
  DevServerReadinessError,
4983
5555
  formatDiffSummary,
4984
- log as log16,
5556
+ log as log17,
4985
5557
  probeDevServer,
4986
5558
  resolveStageGate,
4987
5559
  signalGroup,
@@ -5012,10 +5584,17 @@ class ReviewWorker {
5012
5584
  lastSessionStats = null;
5013
5585
  aborted = false;
5014
5586
  timedOut = false;
5587
+ lastStopReason = null;
5015
5588
  sessionConflict = false;
5016
5589
  runId = null;
5017
5590
  lastRunLogPath = null;
5018
5591
  sessionId = null;
5592
+ cliSessionId = null;
5593
+ grantedTurns = null;
5594
+ resumeMessage = null;
5595
+ get effectiveMaxTurns() {
5596
+ return this.grantedTurns ?? this.config.claude.reviewMaxTurns;
5597
+ }
5019
5598
  constructor(id, config, client, identity, onDone, stateStore, workspaceId, _projectId) {
5020
5599
  this.config = config;
5021
5600
  this.client = client;
@@ -5041,6 +5620,14 @@ class ReviewWorker {
5041
5620
  this.heartbeatTimer = null;
5042
5621
  }
5043
5622
  }
5623
+ captureCliSessionId(sessionId) {
5624
+ if (!sessionId || sessionId === this.cliSessionId)
5625
+ return;
5626
+ this.cliSessionId = sessionId;
5627
+ if (this.runId) {
5628
+ this.stateStore.updateRun(this.runId, { cliSessionId: sessionId }).catch(() => {});
5629
+ }
5630
+ }
5044
5631
  async recordPhase(phase) {
5045
5632
  if (!this.runId)
5046
5633
  return;
@@ -5049,14 +5636,16 @@ class ReviewWorker {
5049
5636
  phase,
5050
5637
  lastHeartbeatAt: Date.now(),
5051
5638
  worktreePath: this.worktreePath,
5052
- branchName: this.branchName
5639
+ branchName: this.branchName,
5640
+ sessionId: this.sessionId,
5641
+ cliSessionId: this.cliSessionId
5053
5642
  });
5054
5643
  } catch (err) {
5055
- log16.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
5644
+ log17.warn(this.tag, `state store updateRun failed: ${err instanceof Error ? err.message : err}`);
5056
5645
  }
5057
5646
  }
5058
5647
  get tag() {
5059
- return `${TAG15}:${this.id}`;
5648
+ return `${TAG16}:${this.id}`;
5060
5649
  }
5061
5650
  get isIdle() {
5062
5651
  return this.state === "idle";
@@ -5083,70 +5672,95 @@ class ReviewWorker {
5083
5672
  async run(card, column, labels, subtasks) {
5084
5673
  this.aborted = false;
5085
5674
  this.timedOut = false;
5675
+ this.lastStopReason = null;
5086
5676
  this.sessionConflict = false;
5677
+ this.cliSessionId = null;
5678
+ this.grantedTurns = null;
5679
+ this.resumeMessage = null;
5087
5680
  this.cardId = card.id;
5088
5681
  this.startedAt = Date.now();
5089
5682
  this.runId = newRunId();
5683
+ const resuming = this.stateStore.getResumableRunForCard(card.id);
5684
+ if (resuming) {
5685
+ this.runId = resuming.runId;
5686
+ this.worktreePath = resuming.worktreePath;
5687
+ this.branchName = resuming.branchName;
5688
+ this.cliSessionId = resuming.cliSessionId ?? null;
5689
+ this.sessionId = resuming.sessionId;
5690
+ this.grantedTurns = resuming.grantedTurns ?? null;
5691
+ this.resumeMessage = resuming.resumeMessage ?? null;
5692
+ try {
5693
+ await this.stateStore.updateRun(resuming.runId, {
5694
+ grantedTurns: null,
5695
+ resumeMessage: null
5696
+ });
5697
+ } catch (err) {
5698
+ log17.warn(this.tag, `Failed to clear the consumed grant: ${err}`);
5699
+ }
5700
+ }
5090
5701
  try {
5091
5702
  this.state = "preparing";
5092
- log16.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
5703
+ log17.info(this.tag, resuming ? `Resuming review of #${card.short_id} "${card.title}" with ${this.grantedTurns ?? "the default"} more turns` : `Preparing review for #${card.short_id} "${card.title}"`);
5093
5704
  this.startHeartbeat();
5094
- await this.stateStore.insertRun({
5095
- runId: this.runId,
5096
- cardId: card.id,
5097
- cardShortId: card.short_id,
5098
- pipeline: "review",
5099
- workerId: this.id,
5100
- sessionId: null,
5101
- worktreePath: null,
5102
- branchName: null,
5103
- daemonPid: process.pid,
5104
- phase: "preparing",
5105
- startedAt: this.startedAt,
5106
- lastHeartbeatAt: this.startedAt,
5107
- endedAt: null,
5108
- status: "active",
5109
- costCents: 0,
5110
- numTurns: 0
5111
- });
5112
- const repoRoot = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
5113
- encoding: "utf-8",
5114
- timeout: 5000
5115
- }).trim();
5116
- const resolution = await resolveReviewBranch(card.description, repoRoot);
5117
- if (resolution.kind !== "branch") {
5118
- const why = resolution.kind === "skip" ? resolution.reason : "no branch or PR reference";
5119
- log16.info(this.tag, `#${card.short_id} not auto-reviewable (${why}) — marking for human review (staying in Review)`);
5120
- await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
5121
- return;
5122
- }
5123
- this.branchName = resolution.branch;
5124
- log16.info(this.tag, `Review branch: ${this.branchName}`);
5125
- let reviewSession;
5126
- try {
5127
- const started = await this.client.startAgentSession(card.id, {
5128
- agentIdentifier: agentIdentifier(this.id),
5129
- agentName: `${AGENT_NAME} (Review)`,
5130
- agentId: this.identity.agentId,
5131
- status: "working",
5132
- currentTask: "Setting up review worktree",
5133
- progressPercent: 5,
5134
- modelName: this.config.claude.reviewModel,
5135
- driver: "daemon"
5705
+ if (!resuming) {
5706
+ await this.stateStore.insertRun({
5707
+ runId: this.runId,
5708
+ cardId: card.id,
5709
+ cardShortId: card.short_id,
5710
+ pipeline: "review",
5711
+ workerId: this.id,
5712
+ sessionId: null,
5713
+ worktreePath: null,
5714
+ branchName: null,
5715
+ daemonPid: process.pid,
5716
+ phase: "preparing",
5717
+ startedAt: this.startedAt,
5718
+ lastHeartbeatAt: this.startedAt,
5719
+ endedAt: null,
5720
+ status: "active",
5721
+ costCents: 0,
5722
+ numTurns: 0
5136
5723
  });
5137
- reviewSession = started.session;
5138
- } catch (err) {
5139
- if (isSessionConflict(err)) {
5140
- this.sessionConflict = true;
5141
- log16.info(this.tag, `Skipping review of #${card.short_id}: ${err instanceof Error ? err.message : String(err)}`);
5724
+ const repoRoot = execFileSync4("git", ["rev-parse", "--show-toplevel"], {
5725
+ encoding: "utf-8",
5726
+ timeout: 5000
5727
+ }).trim();
5728
+ const resolution = await resolveReviewBranch(card.description, repoRoot);
5729
+ if (resolution.kind !== "branch") {
5730
+ const why = resolution.kind === "skip" ? resolution.reason : "no branch or PR reference";
5731
+ log17.info(this.tag, `#${card.short_id} not auto-reviewable (${why}) — marking for human review (staying in Review)`);
5732
+ await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
5142
5733
  return;
5143
5734
  }
5144
- throw err;
5735
+ this.branchName = resolution.branch;
5736
+ log17.info(this.tag, `Review branch: ${this.branchName}`);
5737
+ let reviewSession;
5738
+ try {
5739
+ const started = await this.client.startAgentSession(card.id, {
5740
+ agentIdentifier: agentIdentifier(this.id),
5741
+ agentName: `${AGENT_NAME} (Review)`,
5742
+ agentId: this.identity.agentId,
5743
+ status: "working",
5744
+ currentTask: "Setting up review worktree",
5745
+ progressPercent: 5,
5746
+ modelName: this.config.claude.reviewModel,
5747
+ driver: "daemon",
5748
+ awaitingDecisionUntil: null
5749
+ });
5750
+ reviewSession = started.session;
5751
+ } catch (err) {
5752
+ if (isSessionConflict(err)) {
5753
+ this.sessionConflict = true;
5754
+ log17.info(this.tag, `Skipping review of #${card.short_id}: ${err instanceof Error ? err.message : String(err)}`);
5755
+ return;
5756
+ }
5757
+ throw err;
5758
+ }
5759
+ this.sessionId = reviewSession && typeof reviewSession === "object" && "id" in reviewSession ? reviewSession.id ?? null : null;
5760
+ const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
5761
+ this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
5762
+ await labelPromise;
5145
5763
  }
5146
- this.sessionId = reviewSession && typeof reviewSession === "object" && "id" in reviewSession ? reviewSession.id ?? null : null;
5147
- const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
5148
- this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
5149
- await labelPromise;
5150
5764
  if (this.aborted)
5151
5765
  return;
5152
5766
  this.state = "running";
@@ -5156,7 +5770,7 @@ class ReviewWorker {
5156
5770
  }
5157
5771
  const port = this.reviewPort;
5158
5772
  const cwd = this.worktreePath;
5159
- log16.info(this.tag, `Starting dev server on port ${port}...`);
5773
+ log17.info(this.tag, `Starting dev server on port ${port}...`);
5160
5774
  const [devCmd, devArgs] = spawnRunArgs("dev", "--port", String(port));
5161
5775
  this.devServerProcess = spawnInGroup(devCmd, devArgs, {
5162
5776
  cwd,
@@ -5178,7 +5792,7 @@ class ReviewWorker {
5178
5792
  }
5179
5793
  await waitForDevServer(this.devServerProcess, 30000);
5180
5794
  await probeDevServer(port);
5181
- log16.info(this.tag, `Dev server ready on port ${port}`);
5795
+ log17.info(this.tag, `Dev server ready on port ${port}`);
5182
5796
  await this.client.updateAgentProgress(card.id, {
5183
5797
  agentIdentifier: agentIdentifier(this.id),
5184
5798
  agentName: `${AGENT_NAME} (Review)`,
@@ -5190,7 +5804,7 @@ class ReviewWorker {
5190
5804
  return;
5191
5805
  let diff = "";
5192
5806
  try {
5193
- diff = execFileSync3("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30000 });
5807
+ diff = execFileSync4("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30000 });
5194
5808
  } catch {
5195
5809
  diff = "(unable to retrieve diff)";
5196
5810
  }
@@ -5211,14 +5825,19 @@ class ReviewWorker {
5211
5825
  pinnedContract = extractPinnedContract(comments, this.identity);
5212
5826
  }
5213
5827
  } catch (err) {
5214
- log16.warn(this.tag, `pinned-contract fetch failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
5828
+ log17.warn(this.tag, `pinned-contract fetch failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
5215
5829
  }
5216
5830
  if (pinnedContract) {
5217
- log16.info(this.tag, `Grading pinned contract for #${card.short_id} (${pinnedContract.assertions.length} criteria)`);
5831
+ log17.info(this.tag, `Grading pinned contract for #${card.short_id} (${pinnedContract.assertions.length} criteria)`);
5218
5832
  }
5219
5833
  }
5220
5834
  const systemPrompt = buildReviewSystemPrompt();
5221
- const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diffSummary, this.config.worktree.baseBranch, undefined, pinnedContract);
5835
+ let userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diffSummary, this.config.worktree.baseBranch, undefined, pinnedContract);
5836
+ if (resuming && this.resumeMessage) {
5837
+ userPrompt = `${buildSteeringPrompt([this.resumeMessage])}
5838
+
5839
+ ${userPrompt}`;
5840
+ }
5222
5841
  try {
5223
5842
  await this.client.recordPromptHistory({
5224
5843
  cardId: card.id,
@@ -5227,7 +5846,7 @@ class ReviewWorker {
5227
5846
  contextIncluded: { source: "review-knowledge", mode: "review" }
5228
5847
  });
5229
5848
  } catch (err) {
5230
- log16.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
5849
+ log17.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
5231
5850
  }
5232
5851
  await this.client.updateAgentProgress(card.id, {
5233
5852
  agentIdentifier: agentIdentifier(this.id),
@@ -5237,13 +5856,16 @@ class ReviewWorker {
5237
5856
  progressPercent: 20
5238
5857
  });
5239
5858
  this.timeoutTimer = setTimeout(() => {
5240
- log16.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
5859
+ log17.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
5241
5860
  this.timedOut = true;
5242
5861
  this.cancel("timeout");
5243
5862
  }, this.config.review.maxTimeout);
5244
5863
  this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
5245
5864
  this.progressTracker.setRequestedModel(this.config.claude.reviewModel);
5246
- const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker, card.short_id);
5865
+ const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker, card.short_id, {
5866
+ maxTurns: this.grantedTurns ?? undefined,
5867
+ resumeSessionId: this.cliSessionId ?? undefined
5868
+ });
5247
5869
  this.lastSessionStats = this.progressTracker?.stats ?? null;
5248
5870
  this.progressTracker?.stop();
5249
5871
  this.progressTracker = null;
@@ -5256,10 +5878,10 @@ class ReviewWorker {
5256
5878
  }
5257
5879
  this.state = "completing";
5258
5880
  await this.recordPhase("completing");
5259
- log16.info(this.tag, `Claude review finished for #${card.short_id}`);
5881
+ log17.info(this.tag, `Claude review finished for #${card.short_id}`);
5260
5882
  this.killDevServer();
5261
5883
  const result = parseReviewOutput(stdout);
5262
- log16.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
5884
+ log17.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
5263
5885
  await this.client.updateAgentProgress(card.id, {
5264
5886
  agentIdentifier: agentIdentifier(this.id),
5265
5887
  agentName: `${AGENT_NAME} (Review)`,
@@ -5270,9 +5892,17 @@ class ReviewWorker {
5270
5892
  await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath, this.workspaceId, this.sessionId, this.stateStore, reviewedFromPrUrl(card.description));
5271
5893
  await this.collectReviewGate(card, result);
5272
5894
  } catch (err) {
5895
+ if (err instanceof BudgetPauseError) {
5896
+ await this.parkForDecision(card, err.trigger);
5897
+ return;
5898
+ }
5899
+ if (resuming && isSessionConflict(err)) {
5900
+ await this.holdParkOnSessionConflict(card, err);
5901
+ return;
5902
+ }
5273
5903
  this.state = "error";
5274
5904
  const msg = err instanceof Error ? err.message : String(err);
5275
- log16.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
5905
+ log17.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
5276
5906
  try {
5277
5907
  const stats = this.lastSessionStats ?? this.progressTracker?.stats;
5278
5908
  await runTransition(this.client, card, {
@@ -5282,21 +5912,21 @@ class ReviewWorker {
5282
5912
  }
5283
5913
  });
5284
5914
  } catch (tErr) {
5285
- log16.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
5915
+ log17.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
5286
5916
  }
5287
5917
  if (err instanceof DevServerReadinessError) {
5288
5918
  try {
5289
5919
  await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
5290
- log16.info(this.tag, `#${card.short_id} kept in Review — dev server unavailable, human review needed`);
5920
+ log17.info(this.tag, `#${card.short_id} kept in Review — dev server unavailable, human review needed`);
5291
5921
  } catch {
5292
- log16.warn(this.tag, "Failed to add Need Review label after dev-server failure");
5922
+ log17.warn(this.tag, "Failed to add Need Review label after dev-server failure");
5293
5923
  }
5294
5924
  } else {
5295
5925
  try {
5296
5926
  await moveCardToColumn(this.client, card, this.config.review.failColumn);
5297
- log16.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
5927
+ log17.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
5298
5928
  } catch {
5299
- log16.warn(this.tag, "Failed to move card to fail column after error");
5929
+ log17.warn(this.tag, "Failed to move card to fail column after error");
5300
5930
  }
5301
5931
  }
5302
5932
  if (this.runId) {
@@ -5308,21 +5938,119 @@ class ReviewWorker {
5308
5938
  if (this.runId) {
5309
5939
  try {
5310
5940
  const run = this.stateStore.getRun(this.runId);
5311
- if (run && run.endedAt === null) {
5941
+ if (run && run.endedAt === null && run.status !== "parked") {
5312
5942
  const status = this.timedOut ? "failed" : this.state === "error" || this.aborted || this.sessionConflict ? "paused" : "completed";
5313
5943
  await this.stateStore.endRun(this.runId, status, this.sessionConflict ? { errorMessage: "session_conflict", ...this.endLedger() } : this.endLedger());
5314
5944
  }
5315
5945
  } catch {}
5316
5946
  }
5947
+ if (this.cardId && this.timedOut && this.config.budget.pause.enabled && this.state !== "parked" && this.state !== "error") {
5948
+ try {
5949
+ await this.client.endAgentSession(this.cardId, {
5950
+ status: "failed",
5951
+ failureReason: "timeout",
5952
+ failureSummary: `Review exceeded the ${Math.round(this.config.review.maxTimeout / 60000)} min timeout`,
5953
+ ...buildTokenPayload(this.lastSessionStats)
5954
+ });
5955
+ } catch {}
5956
+ }
5317
5957
  this.cleanup();
5318
5958
  this.state = "idle";
5319
5959
  this.onDone(this);
5320
5960
  }
5321
5961
  }
5962
+ async holdParkOnSessionConflict(card, err) {
5963
+ this.state = "parked";
5964
+ this.progressTracker?.stop();
5965
+ this.progressTracker = null;
5966
+ const holderMessage = err instanceof Error ? err.message : String(err);
5967
+ const waitHours = this.config.budget.pause.waitHours;
5968
+ const until = computeDecisionDeadline(waitHours);
5969
+ log17.warn(this.tag, `#${card.short_id} stays parked — the resume could not reclaim its session: ${holderMessage}`);
5970
+ try {
5971
+ await this.client.addComment(card.id, formatResumeConflictComment({
5972
+ holderMessage,
5973
+ branchName: this.branchName,
5974
+ cliSessionId: this.cliSessionId,
5975
+ waitHours
5976
+ }), {
5977
+ commentType: "blocker",
5978
+ agentSessionId: this.sessionId ?? undefined
5979
+ });
5980
+ } catch (commentErr) {
5981
+ log17.warn(this.tag, `Failed to post the resume-conflict note for #${card.short_id}: ${commentErr}`);
5982
+ }
5983
+ if (this.runId) {
5984
+ const run = this.stateStore.getRun(this.runId);
5985
+ try {
5986
+ await this.stateStore.parkRun(this.runId, {
5987
+ pauseTrigger: run?.pauseTrigger ?? "timeout",
5988
+ blockerCommentId: run?.blockerCommentId ?? null,
5989
+ awaitingDecisionUntil: until
5990
+ });
5991
+ } catch (storeErr) {
5992
+ log17.error(this.tag, `#${card.short_id} could not be re-parked after a resume conflict — the daemon has no hold on it: ${storeErr}`);
5993
+ }
5994
+ }
5995
+ }
5996
+ async parkForDecision(card, trigger) {
5997
+ this.state = "parked";
5998
+ const stats = this.lastSessionStats ?? this.progressTracker?.stats;
5999
+ const lastAction = this.progressTracker?.lastActionSummary ?? null;
6000
+ this.progressTracker?.stop();
6001
+ this.progressTracker = null;
6002
+ const waitHours = this.config.budget.pause.waitHours;
6003
+ const until = computeDecisionDeadline(waitHours);
6004
+ log17.warn(this.tag, `#${card.short_id} parked (${trigger}) — awaiting a human decision for ${waitHours}h`);
6005
+ const body = formatBudgetComment({
6006
+ trigger,
6007
+ numTurns: stats?.cost?.numTurns ?? 0,
6008
+ maxTurns: this.effectiveMaxTurns,
6009
+ toolCalls: stats?.toolCalls ?? 0,
6010
+ durationMs: stats?.cost?.durationMs ?? 0,
6011
+ costUsd: stats?.cost?.totalCostUsd ?? 0,
6012
+ lastAction,
6013
+ branchName: this.branchName,
6014
+ waitHours
6015
+ });
6016
+ let commentId = null;
6017
+ try {
6018
+ const res = await this.client.addComment(card.id, body, {
6019
+ commentType: "blocker"
6020
+ });
6021
+ commentId = res?.comment?.id ?? null;
6022
+ } catch (err) {
6023
+ log17.warn(this.tag, `Failed to post the budget-pause comment: ${err}`);
6024
+ }
6025
+ try {
6026
+ await this.client.updateAgentProgress(card.id, {
6027
+ agentIdentifier: agentIdentifier(this.id),
6028
+ agentName: `${AGENT_NAME} (Review)`,
6029
+ status: "blocked",
6030
+ currentTask: "Waiting for your decision on the turn budget",
6031
+ awaitingDecisionUntil: new Date(until).toISOString()
6032
+ });
6033
+ } catch (err) {
6034
+ log17.warn(this.tag, `Failed to mark the session blocked: ${err}`);
6035
+ }
6036
+ if (this.runId) {
6037
+ try {
6038
+ await this.stateStore.parkRun(this.runId, {
6039
+ pauseTrigger: trigger,
6040
+ blockerCommentId: commentId,
6041
+ awaitingDecisionUntil: until,
6042
+ costCents: Math.round((stats?.cost?.totalCostUsd ?? 0) * 100),
6043
+ numTurns: stats?.cost?.numTurns ?? 0
6044
+ });
6045
+ } catch (err) {
6046
+ log17.error(this.tag, `#${card.short_id} parkRun failed after the blocker comment + blocked session were already written — the daemon has no local record of this park: ${err}`);
6047
+ }
6048
+ }
6049
+ }
5322
6050
  async pause() {
5323
6051
  if (!this.isActive || !this.process || this.process.killed)
5324
6052
  return;
5325
- log16.info(this.tag, `Pausing review on ${this.cardId}`);
6053
+ log17.info(this.tag, `Pausing review on ${this.cardId}`);
5326
6054
  signalGroup(this.process, "SIGSTOP");
5327
6055
  if (this.timeoutTimer) {
5328
6056
  clearTimeout(this.timeoutTimer);
@@ -5336,17 +6064,17 @@ class ReviewWorker {
5336
6064
  status: "paused"
5337
6065
  });
5338
6066
  } catch {
5339
- log16.warn(this.tag, "Failed to update agent session to paused");
6067
+ log17.warn(this.tag, "Failed to update agent session to paused");
5340
6068
  }
5341
6069
  }
5342
6070
  }
5343
6071
  async resume() {
5344
6072
  if (!this.isActive || !this.process || this.process.killed)
5345
6073
  return;
5346
- log16.info(this.tag, `Resuming review on ${this.cardId}`);
6074
+ log17.info(this.tag, `Resuming review on ${this.cardId}`);
5347
6075
  signalGroup(this.process, "SIGCONT");
5348
6076
  this.timeoutTimer = setTimeout(() => {
5349
- log16.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
6077
+ log17.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
5350
6078
  this.timedOut = true;
5351
6079
  this.cancel("timeout");
5352
6080
  }, this.config.review.maxTimeout);
@@ -5358,7 +6086,7 @@ class ReviewWorker {
5358
6086
  status: "working"
5359
6087
  });
5360
6088
  } catch {
5361
- log16.warn(this.tag, "Failed to update agent session to working");
6089
+ log17.warn(this.tag, "Failed to update agent session to working");
5362
6090
  }
5363
6091
  }
5364
6092
  }
@@ -5367,7 +6095,7 @@ class ReviewWorker {
5367
6095
  return;
5368
6096
  this.aborted = true;
5369
6097
  this.state = "cancelling";
5370
- log16.info(this.tag, `Cancelling review on ${this.cardId}`);
6098
+ log17.info(this.tag, `Cancelling review on ${this.cardId}`);
5371
6099
  const snapshotStats = this.lastSessionStats ?? this.progressTracker?.stats;
5372
6100
  if (this.progressTracker) {
5373
6101
  this.progressTracker?.stop();
@@ -5380,7 +6108,8 @@ class ReviewWorker {
5380
6108
  sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT
5381
6109
  });
5382
6110
  }
5383
- if (this.cardId) {
6111
+ const parkingOnTimeout = this.timedOut && this.config.budget.pause.enabled;
6112
+ if (this.cardId && !parkingOnTimeout) {
5384
6113
  try {
5385
6114
  await this.client.endAgentSession(this.cardId, {
5386
6115
  status: this.timedOut ? "failed" : endStatusForCancel(reason),
@@ -5393,7 +6122,8 @@ class ReviewWorker {
5393
6122
  } catch {}
5394
6123
  }
5395
6124
  }
5396
- spawnClaude(prompt, systemPrompt, tracker, shortId) {
6125
+ spawnClaude(prompt, systemPrompt, tracker, shortId, opts = {}) {
6126
+ const effectiveMaxTurns = opts.maxTurns ?? this.config.claude.reviewMaxTurns;
5397
6127
  return new Promise((resolve2, reject) => {
5398
6128
  const leanSources = this.config.claude.leanSettingSources;
5399
6129
  const reviewDenylist = reviewDisallowedTools();
@@ -5404,21 +6134,22 @@ class ReviewWorker {
5404
6134
  "--model",
5405
6135
  this.config.claude.reviewModel,
5406
6136
  "--max-turns",
5407
- String(this.config.claude.reviewMaxTurns),
6137
+ String(effectiveMaxTurns),
5408
6138
  "--allowedTools",
5409
6139
  "Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
5410
6140
  ...reviewDenylist ? ["--disallowedTools", reviewDenylist] : [],
6141
+ ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
5411
6142
  ...leanSources ? ["--setting-sources", leanSources] : [],
5412
6143
  ...systemPrompt ? ["--append-system-prompt", systemPrompt] : [],
5413
6144
  ...this.config.claude.additionalArgs,
5414
6145
  "--",
5415
6146
  prompt
5416
6147
  ];
5417
- log16.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
6148
+ log17.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
5418
6149
  const runLog = openRunLog(this.tag, this.runId, shortId);
5419
6150
  this.lastRunLogPath = runLog?.path ?? null;
5420
6151
  if (runLog) {
5421
- log16.info(this.tag, `Run log: ${runLog.path}`);
6152
+ log17.info(this.tag, `Run log: ${runLog.path}`);
5422
6153
  runLog.stream.write(`# run=${this.runId} card=#${shortId} pipeline=review started=${new Date().toISOString()}
5423
6154
  ` + `# args: ${args.slice(0, -2).join(" ")} -- <prompt:${prompt.length} chars>
5424
6155
 
@@ -5433,13 +6164,17 @@ class ReviewWorker {
5433
6164
  const textChunks = [];
5434
6165
  parser.on("text", (content) => {
5435
6166
  textChunks.push(content);
6167
+ this.captureCliSessionId(parser.sessionId);
5436
6168
  });
5437
6169
  parser.on("parse_error", (msg) => {
5438
- log16.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
6170
+ log17.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
5439
6171
  runLog?.stream.write(`
5440
6172
  [parse_error] ${msg}
5441
6173
  `);
5442
6174
  });
6175
+ parser.on("result", (stop) => {
6176
+ this.lastStopReason = stop;
6177
+ });
5443
6178
  if (this.process?.stdout) {
5444
6179
  parser.attach(this.process.stdout);
5445
6180
  if (runLog) {
@@ -5458,6 +6193,7 @@ class ReviewWorker {
5458
6193
  });
5459
6194
  this.process?.on("close", (code) => {
5460
6195
  this.process = null;
6196
+ this.captureCliSessionId(parser.sessionId);
5461
6197
  const stdout = textChunks.join("");
5462
6198
  const stats = tracker.stats;
5463
6199
  if (runLog) {
@@ -5466,10 +6202,21 @@ class ReviewWorker {
5466
6202
  `);
5467
6203
  runLog.stream.end();
5468
6204
  }
5469
- if (this.aborted) {
6205
+ const trigger = this.config.budget.pause.enabled ? classifyRunExit({
6206
+ exitCode: code ?? 1,
6207
+ stopReason: this.lastStopReason,
6208
+ numTurns: stats?.cost?.numTurns ?? 0,
6209
+ maxTurns: effectiveMaxTurns,
6210
+ timedOut: this.timedOut
6211
+ }) : null;
6212
+ if (this.timedOut && trigger) {
6213
+ reject(new BudgetPauseError(trigger));
6214
+ } else if (this.aborted) {
5470
6215
  resolve2(stdout);
5471
6216
  } else if (code === 0) {
5472
6217
  resolve2(stdout);
6218
+ } else if (trigger) {
6219
+ reject(new BudgetPauseError(trigger));
5473
6220
  } else {
5474
6221
  reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
5475
6222
  }
@@ -5499,16 +6246,16 @@ class ReviewWorker {
5499
6246
  const evidence = await collectGateEvidence(registry, context);
5500
6247
  const evaluation = gateEvaluate(resolved.gate, evidence);
5501
6248
  await this.client.request("POST", `/cards/${encodeURIComponent(card.id)}/stage-gate-evidence`, toStageGateEvidenceInsert(context, evidence));
5502
- log16.info(this.tag, `Recorded review_passed gate evidence for #${card.short_id} stage "${resolved.stage.name}": result=${evidence.result} passed=${evaluation.passed}`);
6249
+ log17.info(this.tag, `Recorded review_passed gate evidence for #${card.short_id} stage "${resolved.stage.name}": result=${evidence.result} passed=${evaluation.passed}`);
5503
6250
  } catch (err) {
5504
- log16.warn(this.tag, `review gate evidence collection failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
6251
+ log17.warn(this.tag, `review gate evidence collection failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
5505
6252
  }
5506
6253
  }
5507
6254
  killDevServer() {
5508
6255
  if (this.devServerProcess && !this.devServerProcess.killed) {
5509
6256
  signalGroup(this.devServerProcess, "SIGTERM");
5510
6257
  this.devServerProcess = null;
5511
- log16.debug(this.tag, "Killed dev server group");
6258
+ log17.debug(this.tag, "Killed dev server group");
5512
6259
  }
5513
6260
  }
5514
6261
  cleanup() {
@@ -5516,13 +6263,17 @@ class ReviewWorker {
5516
6263
  clearTimeout(this.timeoutTimer);
5517
6264
  this.timeoutTimer = null;
5518
6265
  }
6266
+ if (this.progressTracker) {
6267
+ this.progressTracker.stop();
6268
+ this.progressTracker = null;
6269
+ }
5519
6270
  this.stopHeartbeat();
5520
6271
  this.killDevServer();
5521
- if (this.worktreePath && this.state === "error" && this.branchName) {
6272
+ if (this.worktreePath && this.state === "error") {
5522
6273
  try {
5523
- cleanupWorktree3(this.worktreePath, this.branchName);
6274
+ cleanupWorktree3(this.worktreePath);
5524
6275
  } catch {
5525
- log16.warn(this.tag, "Failed to cleanup review worktree");
6276
+ log17.warn(this.tag, "Failed to cleanup review worktree");
5526
6277
  }
5527
6278
  }
5528
6279
  this.process = null;
@@ -5534,13 +6285,15 @@ class ReviewWorker {
5534
6285
  this.lastSessionStats = null;
5535
6286
  }
5536
6287
  }
5537
- var TAG15 = "review-worker", CANCEL_SIGINT_TIMEOUT = 30000, CANCEL_SIGTERM_TIMEOUT = 1e4;
6288
+ var TAG16 = "review-worker", CANCEL_SIGINT_TIMEOUT = 30000, CANCEL_SIGTERM_TIMEOUT = 1e4;
5538
6289
  var init_review_worker = __esm(() => {
5539
6290
  init_dist();
5540
6291
  init_board_helpers();
6292
+ init_budget_pause();
5541
6293
  init_completion();
5542
6294
  init_contract_phase();
5543
6295
  init_progress_tracker();
6296
+ init_prompt();
5544
6297
  init_review_completion();
5545
6298
  init_review_prompt();
5546
6299
  init_review_worktree();
@@ -5553,7 +6306,7 @@ var init_review_worker = __esm(() => {
5553
6306
 
5554
6307
  // src/sleep-guard.ts
5555
6308
  import { spawn } from "node:child_process";
5556
- import { log as log17 } from "@gethmy/harness";
6309
+ import { log as log18 } from "@gethmy/harness";
5557
6310
 
5558
6311
  class SleepGuard {
5559
6312
  platform;
@@ -5581,7 +6334,7 @@ class SleepGuard {
5581
6334
  if (!this.child.killed)
5582
6335
  this.child.kill("SIGTERM");
5583
6336
  this.child = null;
5584
- log17.info(TAG16, "sleep assertion released");
6337
+ log18.info(TAG17, "sleep assertion released");
5585
6338
  }
5586
6339
  }
5587
6340
  start() {
@@ -5596,7 +6349,7 @@ class SleepGuard {
5596
6349
  spawned = true;
5597
6350
  });
5598
6351
  child.on("error", (err) => {
5599
- log17.warn(TAG16, `caffeinate unavailable: ${err.message}`);
6352
+ log18.warn(TAG17, `caffeinate unavailable: ${err.message}`);
5600
6353
  if (this.child === child)
5601
6354
  this.child = null;
5602
6355
  });
@@ -5609,23 +6362,23 @@ class SleepGuard {
5609
6362
  });
5610
6363
  child.unref();
5611
6364
  this.child = child;
5612
- log17.info(TAG16, "sleep assertion acquired (caffeinate -i)");
6365
+ log18.info(TAG17, "sleep assertion acquired (caffeinate -i)");
5613
6366
  } catch (err) {
5614
- log17.warn(TAG16, `failed to spawn caffeinate: ${err instanceof Error ? err.message : err}`);
6367
+ log18.warn(TAG17, `failed to spawn caffeinate: ${err instanceof Error ? err.message : err}`);
5615
6368
  }
5616
6369
  }
5617
6370
  }
5618
- var TAG16 = "sleep-guard";
6371
+ var TAG17 = "sleep-guard";
5619
6372
  var init_sleep_guard = () => {};
5620
6373
 
5621
6374
  // src/unblock.ts
5622
- import { log as log18 } from "@gethmy/harness";
6375
+ import { log as log19 } from "@gethmy/harness";
5623
6376
  async function fetchBlocksLinks(client, cardId) {
5624
6377
  try {
5625
6378
  const { links } = await client.getCardLinks(cardId);
5626
6379
  return links.filter((l) => l.link_type === "blocks");
5627
6380
  } catch (err) {
5628
- log18.warn(TAG17, `link fetch failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
6381
+ log19.warn(TAG18, `link fetch failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
5629
6382
  return null;
5630
6383
  }
5631
6384
  }
@@ -5657,31 +6410,31 @@ async function promoteUnblockedSuccessors(completedCard, deps) {
5657
6410
  const successors = links.filter((l) => l.direction === "outgoing" && !l.target_card.done);
5658
6411
  if (successors.length === 0)
5659
6412
  return;
5660
- log18.info(TAG17, `#${completedCard.short_id} completed — checking ${successors.length} chained successor(s)`);
6413
+ log19.info(TAG18, `#${completedCard.short_id} completed — checking ${successors.length} chained successor(s)`);
5661
6414
  for (const link of successors) {
5662
6415
  const successorId = link.target_card.id;
5663
6416
  try {
5664
6417
  const { card } = await deps.client.getCard(successorId);
5665
6418
  if (card.assigned_agent_id === deps.agentId) {} else if (card.assigned_agent_id === null && !card.assignee_id) {
5666
- log18.info(TAG17, `successor #${card.short_id} unassigned — auto-assigning to continue chain`);
6419
+ log19.info(TAG18, `successor #${card.short_id} unassigned — auto-assigning to continue chain`);
5667
6420
  await deps.client.updateCard(successorId, {
5668
6421
  assignedAgentId: deps.agentId
5669
6422
  });
5670
6423
  } else {
5671
- log18.debug(TAG17, `successor #${card.short_id} assigned to different entity — skipping`);
6424
+ log19.debug(TAG18, `successor #${card.short_id} assigned to different entity — skipping`);
5672
6425
  continue;
5673
6426
  }
5674
6427
  await deps.enqueue(successorId);
5675
6428
  } catch (err) {
5676
- log18.warn(TAG17, `promotion failed for successor ${successorId}: ${err instanceof Error ? err.message : err}`);
6429
+ log19.warn(TAG18, `promotion failed for successor ${successorId}: ${err instanceof Error ? err.message : err}`);
5677
6430
  }
5678
6431
  }
5679
6432
  }
5680
- var TAG17 = "unblock";
6433
+ var TAG18 = "unblock";
5681
6434
  var init_unblock = () => {};
5682
6435
 
5683
6436
  // src/cli-agent-runner.ts
5684
- import { log as log19 } from "@gethmy/harness";
6437
+ import { log as log20 } from "@gethmy/harness";
5685
6438
  function truncateOutput(value) {
5686
6439
  return value === undefined ? undefined : value.slice(0, MAX_OUTPUT_LEN);
5687
6440
  }
@@ -5831,7 +6584,7 @@ class CliAgentRunner {
5831
6584
  events: batch
5832
6585
  });
5833
6586
  } catch (err) {
5834
- log19.warn(TAG18, `Failed to flush run events: ${err}`);
6587
+ log20.warn(TAG19, `Failed to flush run events: ${err}`);
5835
6588
  this.buffer.unshift(...batch);
5836
6589
  if (this.buffer.length > MAX_BUFFER) {
5837
6590
  this.buffer.length = MAX_BUFFER;
@@ -5868,15 +6621,19 @@ function mapCost(cost) {
5868
6621
  durationMs: cost.durationMs
5869
6622
  };
5870
6623
  }
5871
- var TAG18 = "cli-agent-runner", FLUSH_INTERVAL_MS = 2000, MAX_BUFFER = 1000, MAX_TEXT_LEN = 8000, MAX_OUTPUT_LEN = 4000;
6624
+ var TAG19 = "cli-agent-runner", FLUSH_INTERVAL_MS = 2000, MAX_BUFFER = 1000, MAX_TEXT_LEN = 8000, MAX_OUTPUT_LEN = 4000;
5872
6625
  var init_cli_agent_runner = () => {};
5873
6626
 
5874
6627
  // src/motor-driver.ts
5875
- import { spawn as spawn2 } from "node:child_process";
5876
6628
  import { mkdtempSync, writeFileSync as writeFileSync2 } from "node:fs";
5877
6629
  import { createRequire as createRequire2 } from "node:module";
5878
6630
  import { tmpdir } from "node:os";
5879
6631
  import { dirname as dirname2, join as join4 } from "node:path";
6632
+ import {
6633
+ reapGroup,
6634
+ spawnInGroup as spawnInGroup2,
6635
+ terminateGroup as terminateGroup2
6636
+ } from "@gethmy/harness";
5880
6637
  function motorApiBase(apiUrl) {
5881
6638
  return apiUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
5882
6639
  }
@@ -5900,7 +6657,7 @@ function evaluationFromVerdict(v, structured) {
5900
6657
  }
5901
6658
  function runMotorStage(args, deps) {
5902
6659
  const bin = deps.binPath ?? resolveMotorBin();
5903
- const spawnFn = deps.spawnFn ?? spawn2;
6660
+ const spawnFn = deps.spawnFn ?? spawnInGroup2;
5904
6661
  const argv = [
5905
6662
  bin,
5906
6663
  "stage",
@@ -5926,6 +6683,14 @@ function runMotorStage(args, deps) {
5926
6683
  },
5927
6684
  stdio: ["ignore", "pipe", "pipe"]
5928
6685
  });
6686
+ const pgid = child.pid;
6687
+ let swept = false;
6688
+ const sweepGroupOnce = () => {
6689
+ if (swept)
6690
+ return;
6691
+ swept = true;
6692
+ reapGroup(pgid);
6693
+ };
5929
6694
  const lines = [];
5930
6695
  let verdict = null;
5931
6696
  let structured = null;
@@ -5941,15 +6706,15 @@ function runMotorStage(args, deps) {
5941
6706
  resolve2(result);
5942
6707
  };
5943
6708
  function onAbort() {
5944
- try {
5945
- child.kill("SIGTERM");
5946
- const escalate = setTimeout(() => {
5947
- try {
5948
- child.kill("SIGKILL");
5949
- } catch {}
5950
- }, ABORT_SIGKILL_GRACE_MS);
5951
- escalate.unref?.();
5952
- } catch {}
6709
+ (async () => {
6710
+ try {
6711
+ await terminateGroup2(child, {
6712
+ sigintTimeoutMs: ABORT_SIGINT_GRACE_MS,
6713
+ sigtermTimeoutMs: ABORT_SIGTERM_GRACE_MS
6714
+ });
6715
+ } catch {}
6716
+ sweepGroupOnce();
6717
+ })();
5953
6718
  settleOnce({
5954
6719
  exitCode: 1,
5955
6720
  lines,
@@ -5975,7 +6740,8 @@ function runMotorStage(args, deps) {
5975
6740
  let line = null;
5976
6741
  try {
5977
6742
  line = JSON.parse(raw);
5978
- lines.push(line);
6743
+ if (line.type !== "agent_event")
6744
+ lines.push(line);
5979
6745
  if (line.type === "gate_verdict") {
5980
6746
  verdict = { passed: line.passed, findings: line.findings };
5981
6747
  }
@@ -6024,6 +6790,7 @@ function runMotorStage(args, deps) {
6024
6790
  });
6025
6791
  });
6026
6792
  child.on("close", (code) => {
6793
+ sweepGroupOnce();
6027
6794
  settleOnce({
6028
6795
  exitCode: code ?? 1,
6029
6796
  lines,
@@ -6034,156 +6801,9 @@ function runMotorStage(args, deps) {
6034
6801
  });
6035
6802
  });
6036
6803
  }
6037
- var ABORT_SIGKILL_GRACE_MS = 1e4;
6804
+ var ABORT_SIGINT_GRACE_MS = 12000, ABORT_SIGTERM_GRACE_MS = 6000;
6038
6805
  var init_motor_driver = () => {};
6039
6806
 
6040
- // src/prompt.ts
6041
- import { log as log20 } from "@gethmy/harness";
6042
- function renderPreviousAttemptsSection(failures) {
6043
- if (failures.length === 0)
6044
- return "";
6045
- const lines = failures.map((f) => {
6046
- const tag = f.reason ? `[${f.reason}] ` : "";
6047
- return `- ${tag}${f.summary}`;
6048
- });
6049
- return [
6050
- "## Previous attempt feedback",
6051
- "This is a re-attempt on the branch your last run already pushed — build on that existing work and FIX the issues below. Do NOT reimplement from scratch or revert the prior commits.",
6052
- ...lines
6053
- ].join(`
6054
- `);
6055
- }
6056
- async function buildPrompt(enriched, branchName, worktreePath, client, workspaceId, projectId) {
6057
- const { card } = enriched;
6058
- const pastEpisodesSection = await renderPastEpisodesSection(client, card.title, card.description ?? "", workspaceId, projectId);
6059
- try {
6060
- const result = await client.generateCardPrompt({
6061
- cardId: card.id,
6062
- workspaceId,
6063
- projectId,
6064
- variant: "execute",
6065
- customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6066
- Do NOT push to main. All your work stays on \`${branchName}\`.
6067
- The daemon owns the run lifecycle: once your work is committed it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this work tells you to move the card or end the session as a final step, SKIP it — it is handled for you (those tools are disabled for this run). Finish the implementation, commit, and stop.`
6068
- });
6069
- log20.info(TAG19, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6070
- return result.prompt + pastEpisodesSection;
6071
- } catch (err) {
6072
- const msg = err instanceof Error ? err.message : String(err);
6073
- log20.warn(TAG19, `Failed to generate prompt via API, using fallback: ${msg}`);
6074
- const commentsSection = await renderCommentsSection(client, card.id);
6075
- return buildFallbackPrompt(enriched, branchName, worktreePath) + commentsSection + pastEpisodesSection;
6076
- }
6077
- }
6078
- async function renderCommentsSection(client, cardId) {
6079
- try {
6080
- const { comments } = await client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc`);
6081
- if (!Array.isArray(comments) || comments.length === 0)
6082
- return "";
6083
- const section = serializeCommentThread(comments, {
6084
- heading: "Comments",
6085
- maxComments: 40
6086
- });
6087
- return section ? `
6088
-
6089
- ${section}` : "";
6090
- } catch (err) {
6091
- log20.warn(TAG19, "comment-thread fetch failed", {
6092
- event: "comment_fetch_failed",
6093
- error: err instanceof Error ? err.message : String(err)
6094
- });
6095
- return "";
6096
- }
6097
- }
6098
- async function renderPastEpisodesSection(client, title, description, workspaceId, projectId) {
6099
- if (!projectId)
6100
- return "";
6101
- try {
6102
- const query = `${title}
6103
- ${description}`.trim();
6104
- const { entities } = await client.harmonyRecall({
6105
- workspaceId,
6106
- projectId,
6107
- query,
6108
- type: ["solution", "error"],
6109
- memory_tier: "episode",
6110
- scope: "project",
6111
- topK: 3
6112
- });
6113
- if (entities.length === 0)
6114
- return "";
6115
- const bullets = entities.map((entity) => {
6116
- const e = entity;
6117
- const meta = e.metadata ?? {};
6118
- const outcomeTag = meta.outcome ? `[${meta.outcome}]` : "[?]";
6119
- const approach = meta.approach_summary ?? "";
6120
- const lines = [
6121
- `- ${outcomeTag} ${e.title ?? "(untitled episode)"}`,
6122
- ` Approach: ${approach}`
6123
- ];
6124
- if (meta.key_insight)
6125
- lines.push(` Key insight: ${meta.key_insight}`);
6126
- if (meta.changed_files && meta.changed_files.length > 0) {
6127
- const shown = meta.changed_files.slice(0, 8);
6128
- const extra = meta.changed_files.length - shown.length;
6129
- const suffix = extra > 0 ? ` (+${extra} more)` : "";
6130
- lines.push(` Changed files: ${shown.join(", ")}${suffix}`);
6131
- }
6132
- return lines.join(`
6133
- `);
6134
- }).join(`
6135
- `);
6136
- return `
6137
-
6138
- ## Similar past tasks
6139
- ${bullets}`;
6140
- } catch (err) {
6141
- log20.warn(TAG19, "past-episodes recall failed", {
6142
- event: "episode_recall_failed",
6143
- error: err instanceof Error ? err.message : String(err)
6144
- });
6145
- return "";
6146
- }
6147
- }
6148
- function buildFallbackPrompt(enriched, branchName, worktreePath) {
6149
- const { card, column, labels, subtasks } = enriched;
6150
- const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
6151
- const subtaskStr = subtasks.length > 0 ? subtasks.map((s) => `- [${s.completed ? "x" : " "}] ${s.title}`).join(`
6152
- `) : "No subtasks defined.";
6153
- const description = card.description?.trim() || "No description provided.";
6154
- return `You are an AI agent working on a task from the Harmony project board.
6155
-
6156
- ## Card: #${card.short_id} - ${card.title}
6157
- **Labels**: ${labelStr}
6158
- **Column**: ${column.name}
6159
- **Priority**: ${card.priority}
6160
-
6161
- ## Description
6162
- ${description}
6163
-
6164
- ## Subtasks
6165
- ${subtaskStr}
6166
-
6167
- ## Instructions
6168
- 1. Read the codebase and understand the context needed for this task
6169
- 2. Report progress via harmony_update_agent_progress at key milestones:
6170
- - After reading codebase and forming a plan (~20%)
6171
- - After each major implementation step (~30-60%)
6172
- - After completing each subtask (also toggle via harmony_toggle_subtask)
6173
- - Before committing (~65%)
6174
- Include a brief currentTask description.
6175
- 3. Implement the changes on branch \`${branchName}\`
6176
- 4. Commit your work with clear, descriptive commit messages
6177
- 5. When the work is committed, STOP. The daemon owns the run lifecycle: it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself — those tools are disabled for this run.
6178
-
6179
- You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6180
- Do NOT push to main. All your work stays on \`${branchName}\`.`;
6181
- }
6182
- var TAG19 = "prompt";
6183
- var init_prompt = __esm(() => {
6184
- init_dist();
6185
- });
6186
-
6187
6807
  // src/stage-advance.ts
6188
6808
  import { gateConfigErrorReason, log as log21 } from "@gethmy/harness";
6189
6809
  function handoffText(stage) {
@@ -6388,10 +7008,7 @@ async function advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps
6388
7008
  }
6389
7009
  const next = nextStageAfter(def, stageIndex);
6390
7010
  if (next.kind === "terminal") {
6391
- await persistStagePointer(deps.client, card, {
6392
- currentStage: null,
6393
- done: true
6394
- });
7011
+ await persistStagePointer(deps.client, card, { currentStage: null });
6395
7012
  deps.sink?.recordPlaybookAdvanced({
6396
7013
  fromStageId: stage.id,
6397
7014
  fromStageName: stage.name,
@@ -6401,7 +7018,7 @@ async function advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps
6401
7018
  reason: "Playbook complete — final stage gate passed."
6402
7019
  });
6403
7020
  deps.stateStore.recordOutcome(card.id, "success").catch(() => {});
6404
- log21.info(TAG20, `#${card.short_id} terminal stage "${stage.name}" passed — marked done`);
7021
+ log21.info(TAG20, `#${card.short_id} terminal stage "${stage.name}" passed — playbook complete (done left to the column)`);
6405
7022
  return { kind: "completed_terminal" };
6406
7023
  }
6407
7024
  if (next.kind === "out_of_range") {
@@ -6508,6 +7125,7 @@ var init_stage_advance = __esm(() => {
6508
7125
  });
6509
7126
 
6510
7127
  // src/worker.ts
7128
+ import { execFileSync as execFileSync5 } from "node:child_process";
6511
7129
  import { rmSync } from "node:fs";
6512
7130
  import { dirname as dirname3 } from "node:path";
6513
7131
  import {
@@ -6521,12 +7139,15 @@ import {
6521
7139
  makeBranchName,
6522
7140
  normalizeGateSpec,
6523
7141
  pushBranch as pushBranch3,
6524
- reapGroup,
7142
+ readWorktreeHead,
7143
+ reapGroup as reapGroup2,
6525
7144
  SdkAgentRunner,
6526
7145
  signalGroup as signalGroup2,
6527
- spawnInGroup as spawnInGroup2,
7146
+ sizeRun,
7147
+ sizingEventSource,
7148
+ spawnInGroup as spawnInGroup3,
6528
7149
  teardownWorktree as teardownWorktree2,
6529
- terminateGroup as terminateGroup2,
7150
+ terminateGroup as terminateGroup3,
6530
7151
  WorktreeBaseError
6531
7152
  } from "@gethmy/harness";
6532
7153
  function sdkDraftLogLine(ev) {
@@ -6574,12 +7195,6 @@ function buildStagePreamble(stage) {
6574
7195
  return lines.filter(Boolean).join(`
6575
7196
  `);
6576
7197
  }
6577
- function buildSteeringPrompt(messages) {
6578
- if (messages.length === 1)
6579
- return messages[0];
6580
- return messages.map((m, i) => `${i + 1}. ${m}`).join(`
6581
- `);
6582
- }
6583
7198
  function computeRunSpawnGating(stageAllowedTools) {
6584
7199
  const denylist = stageDisallowedTools();
6585
7200
  return {
@@ -6587,6 +7202,14 @@ function computeRunSpawnGating(stageAllowedTools) {
6587
7202
  ...denylist ? { disallowedTools: denylist } : {}
6588
7203
  };
6589
7204
  }
7205
+ function buildRunFailureSummary(apiKind, baseError, msg) {
7206
+ if (apiKind !== null)
7207
+ return describeApiError(apiKind);
7208
+ if (baseError) {
7209
+ return `${msg.slice(0, 300)} — requeued without counting an attempt`;
7210
+ }
7211
+ return `Run failed: ${msg.slice(0, 300)}`;
7212
+ }
6590
7213
 
6591
7214
  class Worker {
6592
7215
  config;
@@ -6603,6 +7226,7 @@ class Worker {
6603
7226
  cardId = null;
6604
7227
  branchName = null;
6605
7228
  worktreePath = null;
7229
+ runBaselineSha = null;
6606
7230
  startedAt = null;
6607
7231
  process = null;
6608
7232
  timeoutTimer = null;
@@ -6615,14 +7239,23 @@ class Worker {
6615
7239
  aborted = false;
6616
7240
  timedOut = false;
6617
7241
  verificationFailed = false;
7242
+ lastStopReason = null;
7243
+ lastActionSummary = null;
6618
7244
  held = false;
6619
7245
  sessionConflict = false;
6620
7246
  activeRunSpawnOpts = null;
6621
7247
  completionStarted = false;
6622
7248
  sessionId = null;
7249
+ sizing = null;
7250
+ modelChoice = null;
6623
7251
  runId = null;
6624
7252
  cliSessionId = null;
6625
7253
  lastDrainedSeq = 0;
7254
+ grantedTurns = null;
7255
+ resumeMessage = null;
7256
+ get effectiveMaxTurns() {
7257
+ return this.grantedTurns ?? this.config.claude.maxTurns;
7258
+ }
6626
7259
  runCostCents = 0;
6627
7260
  runTurns = 0;
6628
7261
  lastRunText = "";
@@ -6698,6 +7331,8 @@ class Worker {
6698
7331
  this.aborted = false;
6699
7332
  this.timedOut = false;
6700
7333
  this.verificationFailed = false;
7334
+ this.lastStopReason = null;
7335
+ this.lastActionSummary = null;
6701
7336
  this.held = false;
6702
7337
  this.sessionConflict = false;
6703
7338
  this.completionStarted = false;
@@ -6707,92 +7342,138 @@ class Worker {
6707
7342
  this.cliSessionId = null;
6708
7343
  this.lastDrainedSeq = 0;
6709
7344
  this.activeRunSpawnOpts = null;
7345
+ this.grantedTurns = null;
7346
+ this.resumeMessage = null;
7347
+ this.sizing = null;
7348
+ this.modelChoice = null;
6710
7349
  this.cardId = card.id;
6711
7350
  this.startedAt = Date.now();
6712
7351
  this.runId = newRunId();
7352
+ const resuming = this.stateStore.getResumableRunForCard(card.id);
7353
+ if (resuming) {
7354
+ this.runId = resuming.runId;
7355
+ this.worktreePath = resuming.worktreePath;
7356
+ this.branchName = resuming.branchName;
7357
+ this.cliSessionId = resuming.cliSessionId ?? null;
7358
+ this.sessionId = resuming.sessionId;
7359
+ this.grantedTurns = resuming.grantedTurns ?? null;
7360
+ this.resumeMessage = resuming.resumeMessage ?? null;
7361
+ try {
7362
+ await this.stateStore.updateRun(resuming.runId, {
7363
+ grantedTurns: null,
7364
+ resumeMessage: null
7365
+ });
7366
+ } catch (err) {
7367
+ log22.warn(this.tag, `Failed to clear the consumed grant: ${err}`);
7368
+ }
7369
+ }
6713
7370
  try {
6714
7371
  this.state = "preparing";
6715
- this.branchName = makeBranchName(card.short_id, card.title, this.config.worktree.failedBranchPrefix);
6716
- log22.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
7372
+ if (!resuming) {
7373
+ this.branchName = makeBranchName(card.short_id, card.title, this.config.worktree.failedBranchPrefix);
7374
+ }
7375
+ log22.info(this.tag, resuming ? `Resuming #${card.short_id} "${card.title}" with ${this.grantedTurns ?? "the default"} more turns` : `Preparing #${card.short_id} "${card.title}"`);
6717
7376
  const attemptCount = await this.stateStore.incrementAttempt(card.id);
6718
7377
  const isRework = attemptCount > 1;
7378
+ const recordedBranch = extractBranchRef(card.description);
7379
+ const continuesPushedWork = isRework || recordsPushedWorkOn(card.description, this.branchName);
7380
+ if (continuesPushedWork && !isRework) {
7381
+ log22.info(this.tag, `Card records completed work on ${this.branchName} — continuing that branch instead of rebuilding from ${this.config.worktree.baseBranch}`);
7382
+ } else if (recordedBranch && recordedBranch !== this.branchName) {
7383
+ log22.warn(this.tag, `Card records branch ${recordedBranch} but this run targets ${this.branchName} — starting fresh; the recorded branch is left untouched`);
7384
+ }
6719
7385
  this.startHeartbeat();
6720
- await this.stateStore.insertRun({
6721
- runId: this.runId,
6722
- cardId: card.id,
6723
- cardShortId: card.short_id,
6724
- pipeline: "implement",
6725
- workerId: this.id,
6726
- sessionId: null,
6727
- worktreePath: null,
6728
- branchName: this.branchName,
6729
- daemonPid: process.pid,
6730
- phase: "preparing",
6731
- startedAt: this.startedAt,
6732
- lastHeartbeatAt: this.startedAt,
6733
- endedAt: null,
6734
- status: "active",
6735
- costCents: 0,
6736
- numTurns: 0
6737
- });
7386
+ this.sizing = await this.sizeThisRun(card);
6738
7387
  const implementModel = this.selectImplementModel(card);
6739
- let session;
6740
- try {
6741
- const started = await this.client.startAgentSession(card.id, {
6742
- agentIdentifier: agentIdentifier(this.id),
6743
- agentName: AGENT_NAME,
6744
- agentId: this.identity.agentId,
6745
- status: "working",
6746
- currentTask: "Setting up worktree",
6747
- progressPercent: 5,
6748
- modelName: implementModel,
6749
- driver: "daemon"
7388
+ let stageCtx = { kind: "generic" };
7389
+ if (!resuming) {
7390
+ await this.stateStore.insertRun({
7391
+ runId: this.runId,
7392
+ cardId: card.id,
7393
+ cardShortId: card.short_id,
7394
+ pipeline: "implement",
7395
+ workerId: this.id,
7396
+ sessionId: null,
7397
+ worktreePath: null,
7398
+ branchName: this.branchName,
7399
+ daemonPid: process.pid,
7400
+ phase: "preparing",
7401
+ startedAt: this.startedAt,
7402
+ lastHeartbeatAt: this.startedAt,
7403
+ endedAt: null,
7404
+ status: "active",
7405
+ costCents: 0,
7406
+ numTurns: 0
6750
7407
  });
6751
- session = started.session;
6752
- } catch (err) {
6753
- if (isSessionConflict(err)) {
6754
- this.sessionConflict = true;
6755
- log22.info(this.tag, `Skipping #${card.short_id}: ${err instanceof Error ? err.message : String(err)}`);
6756
- await this.stateStore.decrementAttempt(card.id);
6757
- return;
7408
+ let session;
7409
+ try {
7410
+ const started = await this.client.startAgentSession(card.id, {
7411
+ agentIdentifier: agentIdentifier(this.id),
7412
+ agentName: AGENT_NAME,
7413
+ agentId: this.identity.agentId,
7414
+ status: "working",
7415
+ currentTask: "Setting up worktree",
7416
+ progressPercent: 5,
7417
+ modelName: implementModel,
7418
+ driver: "daemon",
7419
+ awaitingDecisionUntil: null
7420
+ });
7421
+ session = started.session;
7422
+ } catch (err) {
7423
+ if (isSessionConflict(err)) {
7424
+ this.sessionConflict = true;
7425
+ log22.info(this.tag, `Skipping #${card.short_id}: ${err instanceof Error ? err.message : String(err)}`);
7426
+ await this.stateStore.decrementAttempt(card.id);
7427
+ return;
7428
+ }
7429
+ throw err;
6758
7430
  }
6759
- throw err;
6760
- }
6761
- const sid = session && typeof session === "object" && "id" in session ? session.id : null;
6762
- if (!sid) {
6763
- log22.warn(TAG21, "startAgentSession returned no session id");
7431
+ const sid = session && typeof session === "object" && "id" in session ? session.id : null;
7432
+ if (!sid) {
7433
+ log22.warn(TAG21, "startAgentSession returned no session id");
7434
+ }
7435
+ this.sessionId = sid;
6764
7436
  }
6765
- this.sessionId = sid;
6766
7437
  if (this.sessionId) {
6767
7438
  this.cliRunner = new CliAgentRunner(this.client, card.id, this.sessionId);
6768
- this.cliRunner.recordRunStarted({
6769
- runner: this.config.runner,
6770
- model: implementModel
6771
- });
7439
+ if (!resuming) {
7440
+ this.cliRunner.recordRunStarted({
7441
+ runner: this.config.runner,
7442
+ model: implementModel
7443
+ });
7444
+ this.recordRunSized();
7445
+ }
6772
7446
  }
6773
7447
  await this.recordPhase("preparing");
6774
- const moved = await moveCardAndAddLabel(this.client, card, IN_PROGRESS_COLUMN, "agent");
6775
- if (!moved) {
6776
- log22.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
7448
+ if (!resuming) {
7449
+ const moved = await moveCardAndAddLabel(this.client, card, IN_PROGRESS_COLUMN, "agent");
7450
+ if (!moved) {
7451
+ log22.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
7452
+ }
6777
7453
  }
6778
7454
  if (this.aborted)
6779
7455
  return;
6780
- card = await this.autoBindPlaybookAtPickup(card, labels);
7456
+ if (!resuming) {
7457
+ card = await this.autoBindPlaybookAtPickup(card, labels);
7458
+ }
6781
7459
  if (this.aborted)
6782
7460
  return;
6783
- const stageCtx = await this.resolveStageContext(card);
7461
+ stageCtx = await this.resolveStageContext(card);
6784
7462
  if (stageCtx.kind === "hold") {
6785
7463
  this.held = true;
6786
7464
  await this.holdStageCard(card, stageCtx.reason, stageCtx.wait);
6787
7465
  return;
6788
7466
  }
6789
- this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName, {
6790
- continueExisting: stageCtx.kind === "run" || stageCtx.kind === "motor" || isRework
6791
- });
7467
+ if (!resuming) {
7468
+ this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName, {
7469
+ continueExisting: stageCtx.kind === "run" || stageCtx.kind === "motor" || continuesPushedWork
7470
+ });
7471
+ this.runBaselineSha = readWorktreeHead(this.worktreePath);
7472
+ }
6792
7473
  if (this.aborted)
6793
7474
  return;
6794
- if (stageCtx.kind === "motor") {
6795
- await this.runMotorStageCtx(card, stageCtx);
7475
+ if (!resuming && stageCtx.kind === "motor") {
7476
+ await this.runMotorStageCtx(card, stageCtx, subtasks);
6796
7477
  return;
6797
7478
  }
6798
7479
  const enriched = {
@@ -6802,12 +7483,12 @@ class Worker {
6802
7483
  subtasks,
6803
7484
  mode: "implement"
6804
7485
  };
6805
- if (stageCtx.kind !== "run" && shouldRunContract(this.config.contractFirst)) {
7486
+ if (!resuming && stageCtx.kind !== "run" && shouldRunContract(this.config.contractFirst)) {
6806
7487
  await this.runContractPhase(enriched);
6807
7488
  if (this.aborted)
6808
7489
  return;
6809
7490
  }
6810
- if (shouldPlan(enriched, this.config.planning)) {
7491
+ if (!resuming && shouldPlan(enriched, this.config.planning)) {
6811
7492
  this.state = "planning";
6812
7493
  await this.recordPhase("planning");
6813
7494
  const parked = await this.runPlanningPhase(enriched);
@@ -6829,33 +7510,40 @@ class Worker {
6829
7510
  prompt = [buildStagePreamble(stageCtx.stage), inherited, basePrompt].filter(Boolean).join(`
6830
7511
 
6831
7512
  `);
6832
- this.cliRunner?.recordStageEntered({
6833
- stageId: stageCtx.stage.id,
6834
- stageName: stageCtx.stage.name,
6835
- owner: stageCtx.stage.owner
6836
- });
6837
- if (isLoop && loop) {
6838
- const priorIterations = this.stateStore.getLoopIterations(card.id, stageCtx.stage.id);
6839
- this.cliRunner?.recordLoopIterationStarted({
7513
+ if (!resuming) {
7514
+ this.cliRunner?.recordStageEntered({
6840
7515
  stageId: stageCtx.stage.id,
6841
7516
  stageName: stageCtx.stage.name,
6842
- iteration: priorIterations + 1,
6843
- maxIterations: Math.max(1, Math.floor(loop.max_iterations) || 1),
6844
- mode: loop.mode
7517
+ owner: stageCtx.stage.owner
6845
7518
  });
7519
+ if (isLoop && loop) {
7520
+ const priorIterations = this.stateStore.getLoopIterations(card.id, stageCtx.stage.id);
7521
+ this.cliRunner?.recordLoopIterationStarted({
7522
+ stageId: stageCtx.stage.id,
7523
+ stageName: stageCtx.stage.name,
7524
+ iteration: priorIterations + 1,
7525
+ maxIterations: Math.max(1, Math.floor(loop.max_iterations) || 1),
7526
+ mode: loop.mode
7527
+ });
7528
+ }
6846
7529
  }
6847
- } else if (isRework) {
7530
+ } else if (continuesPushedWork) {
6848
7531
  const digest = renderPreviousAttemptsSection(this.stateStore.getRecentFailures(card.id, 3));
6849
7532
  if (digest)
6850
7533
  prompt = `${digest}
6851
7534
 
6852
7535
  ${basePrompt}`;
7536
+ }
7537
+ if (resuming && this.resumeMessage) {
7538
+ prompt = `${buildSteeringPrompt([this.resumeMessage])}
7539
+
7540
+ ${prompt}`;
6853
7541
  }
6854
7542
  await this.client.updateAgentProgress(card.id, {
6855
7543
  agentIdentifier: agentIdentifier(this.id),
6856
7544
  agentName: AGENT_NAME,
6857
7545
  status: "working",
6858
- currentTask: stageCtx.kind === "run" ? `Running stage "${stageCtx.stage.name}"` : "Running Claude CLI",
7546
+ currentTask: resuming ? "Resuming Claude CLI" : stageCtx.kind === "run" ? `Running stage "${stageCtx.stage.name}"` : "Running Claude CLI",
6859
7547
  progressPercent: 10
6860
7548
  });
6861
7549
  this.timeoutTimer = setTimeout(() => {
@@ -6866,6 +7554,8 @@ ${basePrompt}`;
6866
7554
  this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
6867
7555
  await this.spawnClaude(prompt, card, subtasks, {
6868
7556
  model: implementModel,
7557
+ maxTurns: this.grantedTurns ?? undefined,
7558
+ resumeSessionId: this.cliSessionId ?? undefined,
6869
7559
  ...this.activeRunSpawnOpts ?? {}
6870
7560
  });
6871
7561
  if (this.aborted)
@@ -6899,7 +7589,11 @@ ${basePrompt}`;
6899
7589
  stageGateEvaluation = await this.collectStageGateEvidence(card, stageRun.stage, worktreePath, subtasks);
6900
7590
  return stageEndDisposition(stageGateEvaluation, stageRun.stage, stageRun.index, stageRun.def);
6901
7591
  } : undefined;
6902
- const completed = await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId, this.stateStore, this.onCardCompleted, onBeforeWorktreeCleanup);
7592
+ const completed = await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id, this.lastSessionStats, this.workspaceId, this.sessionId, this.stateStore, this.onCardCompleted, onBeforeWorktreeCleanup, this.runBaselineSha, this.effectiveMaxTurns);
7593
+ if (completed === "park") {
7594
+ await this.parkForDecision(card, "max_turns");
7595
+ return;
7596
+ }
6903
7597
  this.worktreePath = null;
6904
7598
  this.verificationFailed = !completed;
6905
7599
  if (completed && stageRun) {
@@ -6920,6 +7614,14 @@ ${basePrompt}`;
6920
7614
  }
6921
7615
  }
6922
7616
  } catch (err) {
7617
+ if (err instanceof BudgetPauseError) {
7618
+ await this.parkForDecision(card, err.trigger);
7619
+ return;
7620
+ }
7621
+ if (resuming && isSessionConflict(err)) {
7622
+ await this.holdParkOnSessionConflict(card, err);
7623
+ return;
7624
+ }
6923
7625
  this.state = "error";
6924
7626
  const msg = err instanceof Error ? err.message : String(err);
6925
7627
  log22.error(this.tag, `Error on #${card.short_id}: ${msg}`);
@@ -6950,7 +7652,7 @@ ${basePrompt}`;
6950
7652
  this.worktreePath = null;
6951
7653
  }
6952
7654
  const failureReason = apiError ? errClass.kind : "other";
6953
- const failureSummary = apiError ? describeApiError(errClass.kind) : baseError ? "Could not fetch current base branch — requeued without counting an attempt" : `Run failed: ${msg.slice(0, 300)}`;
7655
+ const failureSummary = buildRunFailureSummary(errClass.kind, baseError, msg);
6954
7656
  try {
6955
7657
  await runTransition(this.client, card, {
6956
7658
  move: { columnName: this.config.pickupColumns[0] ?? "To Do" },
@@ -6978,8 +7680,8 @@ ${basePrompt}`;
6978
7680
  }
6979
7681
  }
6980
7682
  } finally {
6981
- const succeeded = this.runId && !this.held && !this.sessionConflict && this.state !== "error" && !this.aborted && !this.verificationFailed;
6982
- if (this.held) {} else if (this.sessionConflict) {
7683
+ const succeeded = this.runId && !this.held && !this.sessionConflict && this.state !== "error" && this.state !== "parked" && !this.aborted && !this.verificationFailed;
7684
+ if (this.state === "parked") {} else if (this.held) {} else if (this.sessionConflict) {
6983
7685
  if (this.runId) {
6984
7686
  try {
6985
7687
  await this.stateStore.endRun(this.runId, "paused", {
@@ -7067,6 +7769,11 @@ ${basePrompt}`;
7067
7769
  status: "stopped",
7068
7770
  stopReason: "user_requested"
7069
7771
  });
7772
+ } else if (this.state === "parked") {
7773
+ this.cliRunner.recordFinished({
7774
+ status: "stopped",
7775
+ stopReason: "user_requested"
7776
+ });
7070
7777
  } else if (succeeded) {
7071
7778
  this.cliRunner.recordFinished({ status: "completed" });
7072
7779
  } else if (this.timedOut) {
@@ -7098,8 +7805,6 @@ ${basePrompt}`;
7098
7805
  const { playbooks } = await this.client.request("GET", `/playbooks?workspaceId=${encodeURIComponent(this.workspaceId)}`);
7099
7806
  const subject = {
7100
7807
  labels: labels.map((label) => label.name.toLowerCase()),
7101
- ...card.intent != null ? { intent: card.intent } : {},
7102
- ...card.complexity_score != null ? { complexity_score: card.complexity_score } : {},
7103
7808
  ...card.priority != null ? { priority: card.priority } : {}
7104
7809
  };
7105
7810
  const { pick, reason } = selectAutoPlaybook(subject, playbooks ?? []);
@@ -7211,28 +7916,115 @@ ${basePrompt}`;
7211
7916
  await this.client.addComment(card.id, reason, { commentType: "blocker" });
7212
7917
  } catch {}
7213
7918
  try {
7214
- await runTransition(this.client, card, {
7215
- removeLabels: ["agent"],
7216
- endSession: {
7217
- status: wait ? "blocked" : "paused",
7218
- blockers: wait ? [reason] : undefined,
7219
- failureReason: "other",
7220
- failureSummary: reason.slice(0, 300)
7221
- }
7919
+ await runTransition(this.client, card, {
7920
+ removeLabels: ["agent"],
7921
+ endSession: {
7922
+ status: wait ? "blocked" : "paused",
7923
+ blockers: wait ? [reason] : undefined,
7924
+ failureReason: "other",
7925
+ failureSummary: reason.slice(0, 300)
7926
+ }
7927
+ });
7928
+ } catch (tErr) {
7929
+ log22.warn(this.tag, `hold transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
7930
+ }
7931
+ if (this.runId) {
7932
+ try {
7933
+ await this.stateStore.endRun(this.runId, "paused", {
7934
+ errorMessage: "stage_hold",
7935
+ ...this.runLedger()
7936
+ });
7937
+ } catch {}
7938
+ }
7939
+ }
7940
+ async holdParkOnSessionConflict(card, err) {
7941
+ this.state = "parked";
7942
+ this.progressTracker?.stop();
7943
+ this.progressTracker = null;
7944
+ const holderMessage = err instanceof Error ? err.message : String(err);
7945
+ const waitHours = this.config.budget.pause.waitHours;
7946
+ const until = computeDecisionDeadline(waitHours);
7947
+ log22.warn(this.tag, `#${card.short_id} stays parked — the resume could not reclaim its session: ${holderMessage}`);
7948
+ try {
7949
+ await this.client.addComment(card.id, formatResumeConflictComment({
7950
+ holderMessage,
7951
+ branchName: this.branchName,
7952
+ cliSessionId: this.cliSessionId,
7953
+ waitHours
7954
+ }), {
7955
+ commentType: "blocker",
7956
+ agentSessionId: this.sessionId ?? undefined
7957
+ });
7958
+ } catch (commentErr) {
7959
+ log22.warn(this.tag, `Failed to post the resume-conflict note for #${card.short_id}: ${commentErr}`);
7960
+ }
7961
+ if (this.runId) {
7962
+ const run = this.stateStore.getRun(this.runId);
7963
+ try {
7964
+ await this.stateStore.parkRun(this.runId, {
7965
+ pauseTrigger: run?.pauseTrigger ?? "timeout",
7966
+ blockerCommentId: run?.blockerCommentId ?? null,
7967
+ awaitingDecisionUntil: until
7968
+ });
7969
+ } catch (storeErr) {
7970
+ log22.error(this.tag, `#${card.short_id} could not be re-parked after a resume conflict — the daemon has no hold on it: ${storeErr}`);
7971
+ }
7972
+ }
7973
+ }
7974
+ async parkForDecision(card, trigger) {
7975
+ this.state = "parked";
7976
+ const stats = this.lastSessionStats;
7977
+ this.progressTracker?.stop();
7978
+ this.progressTracker = null;
7979
+ const waitHours = this.config.budget.pause.waitHours;
7980
+ const until = computeDecisionDeadline(waitHours);
7981
+ log22.warn(this.tag, `#${card.short_id} parked (${trigger}) — awaiting a human decision for ${waitHours}h`);
7982
+ const body = formatBudgetComment({
7983
+ trigger,
7984
+ numTurns: stats?.cost?.numTurns ?? 0,
7985
+ maxTurns: this.effectiveMaxTurns,
7986
+ toolCalls: stats?.toolCalls ?? 0,
7987
+ durationMs: stats?.cost?.durationMs ?? 0,
7988
+ costUsd: stats?.cost?.totalCostUsd ?? 0,
7989
+ lastAction: this.lastActionSummary,
7990
+ branchName: this.branchName,
7991
+ waitHours
7992
+ });
7993
+ let commentId = null;
7994
+ try {
7995
+ const res = await this.client.addComment(card.id, body, {
7996
+ commentType: "blocker"
7997
+ });
7998
+ commentId = res?.comment?.id ?? null;
7999
+ } catch (err) {
8000
+ log22.warn(this.tag, `Failed to post the budget-pause comment: ${err}`);
8001
+ }
8002
+ try {
8003
+ await this.client.updateAgentProgress(card.id, {
8004
+ agentIdentifier: agentIdentifier(this.id),
8005
+ agentName: AGENT_NAME,
8006
+ status: "blocked",
8007
+ currentTask: "Waiting for your decision on the turn budget",
8008
+ awaitingDecisionUntil: new Date(until).toISOString()
7222
8009
  });
7223
- } catch (tErr) {
7224
- log22.warn(this.tag, `hold transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
8010
+ } catch (err) {
8011
+ log22.warn(this.tag, `Failed to mark the session blocked: ${err}`);
7225
8012
  }
7226
8013
  if (this.runId) {
7227
8014
  try {
7228
- await this.stateStore.endRun(this.runId, "paused", {
7229
- errorMessage: "stage_hold",
7230
- ...this.runLedger()
8015
+ await this.stateStore.parkRun(this.runId, {
8016
+ pauseTrigger: trigger,
8017
+ blockerCommentId: commentId,
8018
+ awaitingDecisionUntil: until,
8019
+ costCents: Math.round((stats?.cost?.totalCostUsd ?? 0) * 100),
8020
+ numTurns: stats?.cost?.numTurns ?? 0
7231
8021
  });
7232
- } catch {}
8022
+ } catch (err) {
8023
+ log22.error(this.tag, `#${card.short_id} parkRun failed after the blocker comment + blocked session were already written — the daemon has no local record of this park: ${err}`);
8024
+ }
7233
8025
  }
7234
8026
  }
7235
- async runMotorStageCtx(card, ctx) {
8027
+ async runMotorStageCtx(card, ctx, subtasks = []) {
7236
8028
  const stageId = ctx.stage.id;
7237
8029
  const worktreePath = this.worktreePath;
7238
8030
  const sessionId = this.sessionId;
@@ -7267,7 +8059,31 @@ ${basePrompt}`;
7267
8059
  this.timedOut = true;
7268
8060
  this.cancel("timeout");
7269
8061
  }, this.config.maxTimeout);
8062
+ const motorTracker = new ProgressTracker(this.client, card.id, this.id, subtasks, "exploring");
8063
+ if (this.cliRunner)
8064
+ motorTracker.setRunEventSink(this.cliRunner);
8065
+ let motorTrackerLive = false;
8066
+ let motorRunSettled = false;
8067
+ const onMotorLine = (line) => {
8068
+ if (line.type !== "agent_event") {
8069
+ log22.info(this.tag, `motor: ${line.type}`);
8070
+ return;
8071
+ }
8072
+ if (motorRunSettled)
8073
+ return;
8074
+ log22.debug(this.tag, `motor: agent_event ${line.event.kind}`);
8075
+ if (line.event.kind === "tool_started" && STAGE_DAEMON_OWNED_TOOLS.includes(line.event.payload.toolName)) {
8076
+ return;
8077
+ }
8078
+ if (!motorTrackerLive) {
8079
+ motorTrackerLive = true;
8080
+ this.progressTracker = motorTracker;
8081
+ }
8082
+ motorTracker.ingest(line.event);
8083
+ };
7270
8084
  const heartbeat = setInterval(() => {
8085
+ if (motorTrackerLive && !motorTracker.isStopped)
8086
+ return;
7271
8087
  this.client.updateAgentProgress(card.id, {
7272
8088
  agentIdentifier: agentIdentifier(this.id),
7273
8089
  agentName: AGENT_NAME,
@@ -7293,7 +8109,7 @@ ${basePrompt}`;
7293
8109
  apiUrl: this.client.getApiUrl(),
7294
8110
  apiKey: this.client.getApiKey()
7295
8111
  },
7296
- onLine: (line) => log22.info(this.tag, `motor: ${line.type}`),
8112
+ onLine: onMotorLine,
7297
8113
  signal: motorAbort.signal
7298
8114
  });
7299
8115
  } finally {
@@ -7302,6 +8118,17 @@ ${basePrompt}`;
7302
8118
  this.timeoutTimer = null;
7303
8119
  }
7304
8120
  clearInterval(heartbeat);
8121
+ motorRunSettled = true;
8122
+ if (motorTrackerLive) {
8123
+ this.lastSessionStats = motorTracker.stats;
8124
+ const motorCost = this.lastSessionStats.cost;
8125
+ if (motorCost) {
8126
+ this.runCostCents += Math.round(motorCost.totalCostUsd * 100);
8127
+ this.runTurns += motorCost.numTurns;
8128
+ }
8129
+ }
8130
+ motorTracker.stop();
8131
+ this.progressTracker = null;
7305
8132
  this.motorAbort = null;
7306
8133
  if (metricsPath) {
7307
8134
  try {
@@ -7494,12 +8321,59 @@ ${basePrompt}`;
7494
8321
  }
7495
8322
  selectImplementModel(card) {
7496
8323
  const attempts = this.stateStore.getCard(card.id)?.attempts ?? 1;
7497
- const { model, escalated, source } = chooseImplementModel(this.config.claude, card, attempts);
8324
+ const choice = chooseImplementModel(this.config.claude, card, attempts, this.sizing ?? undefined);
8325
+ this.modelChoice = choice;
8326
+ const { model, escalated, source } = choice;
7498
8327
  if (source !== "policy" || escalated) {
7499
- log22.info(this.tag, `Implement model "${model}" (source=${source}, escalated=${escalated}, attempts=${attempts}, priority=${card.priority ?? "none"}, tier=${card.model_tier ?? "none"})`);
8328
+ log22.info(this.tag, `Implement model "${model}" (source=${source}, escalated=${escalated}, attempts=${attempts}, priority=${card.priority ?? "none"}, tier=${this.sizing?.tier ?? "none"})`);
7500
8329
  }
7501
8330
  return model;
7502
8331
  }
8332
+ async sizeThisRun(card) {
8333
+ const model = this.config.claude.sizingModel;
8334
+ if (!model)
8335
+ return null;
8336
+ let repoRoot;
8337
+ try {
8338
+ repoRoot = execFileSync5("git", ["rev-parse", "--show-toplevel"], {
8339
+ encoding: "utf-8"
8340
+ }).trim();
8341
+ } catch (err) {
8342
+ log22.warn(this.tag, `Sizing #${card.short_id}: could not resolve the repo root (${err instanceof Error ? err.message : String(err)}) — using the policy fallback`);
8343
+ return null;
8344
+ }
8345
+ const sized = await sizeRun({
8346
+ cwd: repoRoot,
8347
+ cardId: card.id,
8348
+ workspaceId: this.workspaceId,
8349
+ runId: this.runId ?? card.id,
8350
+ title: card.title,
8351
+ description: card.description,
8352
+ model
8353
+ });
8354
+ log22.info(this.tag, sized ? `Sized #${card.short_id}: complexity ${sized.complexity}/10 -> ${sized.tier}` : `Sizing #${card.short_id} produced no verdict — using the policy fallback`);
8355
+ return sized;
8356
+ }
8357
+ recordRunSized() {
8358
+ if (!this.modelChoice)
8359
+ return;
8360
+ const { model, escalated, source } = this.modelChoice;
8361
+ this.cliRunner?.record({
8362
+ kind: "run_sized",
8363
+ source: "system",
8364
+ payload: {
8365
+ source: sizingEventSource(source),
8366
+ model,
8367
+ escalated,
8368
+ ...this.sizing ? {
8369
+ tier: this.sizing.tier,
8370
+ complexity: this.sizing.complexity,
8371
+ ...this.sizing.reasoning ? { reasoning: this.sizing.reasoning } : {},
8372
+ ...this.sizing.filesInspected ? { filesInspected: this.sizing.filesInspected } : {}
8373
+ } : {}
8374
+ }
8375
+ });
8376
+ }
7503
8377
  async recordOutcome(cardId, outcome) {
7504
8378
  try {
7505
8379
  const cost = this.lastSessionStats?.cost;
@@ -7512,15 +8386,41 @@ ${basePrompt}`;
7512
8386
  const max = this.config.budget.maxAttemptsPerCard;
7513
8387
  const attempts = this.stateStore.getCard(cardId)?.attempts ?? 0;
7514
8388
  if (attempts >= max) {
8389
+ let giveUpCommentId = null;
7515
8390
  try {
7516
- const body = buildGaveUpComment(max, this.stateStore.getRecentFailures(cardId, 3));
7517
- await this.client.addComment(cardId, body, {
8391
+ const body = buildGaveUpComment(max, this.stateStore.getRecentFailures(cardId, 3), this.config.budget.pause.enabled);
8392
+ const res = await this.client.addComment(cardId, body, {
7518
8393
  commentType: "blocker"
7519
8394
  });
8395
+ giveUpCommentId = res?.comment?.id ?? null;
7520
8396
  log22.warn(this.tag, `gave up on ${cardId} after ${attempts} attempts`);
7521
8397
  } catch (err) {
7522
8398
  log22.warn(this.tag, `failed to post give-up comment for ${cardId}: ${err instanceof Error ? err.message : err}`);
7523
8399
  }
8400
+ if (this.config.budget.pause.enabled) {
8401
+ const waitHours = this.config.budget.pause.waitHours;
8402
+ const until = computeDecisionDeadline(waitHours);
8403
+ try {
8404
+ await this.client.updateAgentProgress(cardId, {
8405
+ agentIdentifier: agentIdentifier(this.id),
8406
+ agentName: AGENT_NAME,
8407
+ status: "blocked",
8408
+ currentTask: "Waiting for your decision on the attempt budget",
8409
+ awaitingDecisionUntil: new Date(until).toISOString()
8410
+ });
8411
+ } catch (err) {
8412
+ log22.warn(this.tag, `Failed to mark the attempt cap pending for ${cardId}: ${err}`);
8413
+ }
8414
+ try {
8415
+ await this.stateStore.markAwaitingDecision(cardId, {
8416
+ until,
8417
+ blockerCommentId: giveUpCommentId,
8418
+ agentIdentifier: agentIdentifier(this.id)
8419
+ });
8420
+ } catch (err) {
8421
+ log22.warn(this.tag, `Failed to record the attempt-cap decision locally for ${cardId}: ${err}`);
8422
+ }
8423
+ }
7524
8424
  }
7525
8425
  }
7526
8426
  } catch (err) {
@@ -7580,7 +8480,7 @@ ${basePrompt}`;
7580
8480
  if (this.sdkRunner) {
7581
8481
  await this.sdkRunner.stop(this.timedOut ? "timeout" : "user_requested");
7582
8482
  } else if (this.process && !this.process.killed) {
7583
- await terminateGroup2(this.process, {
8483
+ await terminateGroup3(this.process, {
7584
8484
  sigintTimeoutMs: CANCEL_SIGINT_TIMEOUT2,
7585
8485
  sigtermTimeoutMs: CANCEL_SIGTERM_TIMEOUT2
7586
8486
  });
@@ -7617,7 +8517,7 @@ ${basePrompt}`;
7617
8517
  if (this.sdkRunner) {
7618
8518
  this.sdkRunner.stop("timeout").catch(() => {});
7619
8519
  } else if (this.process && !this.process.killed) {
7620
- terminateGroup2(this.process, {
8520
+ terminateGroup3(this.process, {
7621
8521
  sigintTimeoutMs: 1e4,
7622
8522
  sigtermTimeoutMs: 5000
7623
8523
  }).catch(() => {});
@@ -7735,7 +8635,7 @@ ${basePrompt}`;
7735
8635
  if (this.sdkRunner) {
7736
8636
  this.sdkRunner.stop("timeout").catch(() => {});
7737
8637
  } else if (this.process && !this.process.killed) {
7738
- terminateGroup2(this.process, {
8638
+ terminateGroup3(this.process, {
7739
8639
  sigintTimeoutMs: 1e4,
7740
8640
  sigtermTimeoutMs: 5000
7741
8641
  }).catch(() => {});
@@ -7862,7 +8762,7 @@ ${basePrompt}`;
7862
8762
 
7863
8763
  `);
7864
8764
  }
7865
- this.process = spawnInGroup2("claude", args, {
8765
+ this.process = spawnInGroup3("claude", args, {
7866
8766
  cwd: this.worktreePath,
7867
8767
  stdio: ["ignore", "pipe", "pipe"]
7868
8768
  });
@@ -7892,6 +8792,9 @@ ${basePrompt}`;
7892
8792
  [parse_error] ${msg}
7893
8793
  `);
7894
8794
  });
8795
+ parser.on("result", (stop) => {
8796
+ this.lastStopReason = stop;
8797
+ });
7895
8798
  let stderr = "";
7896
8799
  this.process.stderr?.on("data", (data) => {
7897
8800
  stderr += data.toString();
@@ -7905,6 +8808,7 @@ ${basePrompt}`;
7905
8808
  this.process = null;
7906
8809
  this.captureCliSessionId(parser.sessionId);
7907
8810
  this.lastSessionStats = this.progressTracker?.stats;
8811
+ this.lastActionSummary = this.progressTracker?.lastActionSummary ?? null;
7908
8812
  const spawnCost = this.lastSessionStats?.cost;
7909
8813
  if (spawnCost) {
7910
8814
  this.runCostCents += Math.round(spawnCost.totalCostUsd * 100);
@@ -7919,11 +8823,22 @@ ${basePrompt}`;
7919
8823
  `);
7920
8824
  runLog.stream.end();
7921
8825
  }
7922
- reapGroup(leaderPid);
7923
- if (this.aborted) {
8826
+ reapGroup2(leaderPid);
8827
+ const trigger = this.config.budget.pause.enabled ? classifyRunExit({
8828
+ exitCode: code ?? 1,
8829
+ stopReason: this.lastStopReason,
8830
+ numTurns: this.lastSessionStats?.cost?.numTurns ?? 0,
8831
+ maxTurns,
8832
+ timedOut: this.timedOut
8833
+ }) : null;
8834
+ if (this.timedOut && trigger) {
8835
+ reject(new BudgetPauseError(trigger));
8836
+ } else if (this.aborted) {
7924
8837
  resolve2();
7925
8838
  } else if (code === 0) {
7926
8839
  resolve2();
8840
+ } else if (trigger) {
8841
+ reject(new BudgetPauseError(trigger));
7927
8842
  } else {
7928
8843
  const err = new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
7929
8844
  err.stderr = stderr;
@@ -7998,6 +8913,7 @@ ${basePrompt}`;
7998
8913
  } finally {
7999
8914
  this.captureCliSessionId(runner.sessionId);
8000
8915
  this.lastSessionStats = this.progressTracker?.stats;
8916
+ this.lastActionSummary = this.progressTracker?.lastActionSummary ?? null;
8001
8917
  const spawnCost = this.lastSessionStats?.cost;
8002
8918
  if (spawnCost) {
8003
8919
  this.runCostCents += Math.round(spawnCost.totalCostUsd * 100);
@@ -8015,9 +8931,30 @@ ${basePrompt}`;
8015
8931
  this.process = null;
8016
8932
  this.sdkRunner = null;
8017
8933
  }
8934
+ if (this.timedOut && failure && this.config.budget.pause.enabled) {
8935
+ const timeoutTrigger = classifyRunExit({
8936
+ exitCode: 1,
8937
+ stopReason: null,
8938
+ numTurns: this.lastSessionStats?.cost?.numTurns ?? 0,
8939
+ maxTurns,
8940
+ timedOut: true
8941
+ });
8942
+ if (timeoutTrigger)
8943
+ throw new BudgetPauseError(timeoutTrigger);
8944
+ }
8018
8945
  if (this.aborted)
8019
8946
  return;
8020
8947
  if (failure) {
8948
+ const trigger = classifyRunExit({
8949
+ exitCode: 1,
8950
+ stopReason: null,
8951
+ numTurns: this.lastSessionStats?.cost?.numTurns ?? 0,
8952
+ maxTurns,
8953
+ timedOut: this.timedOut
8954
+ });
8955
+ if (trigger && this.config.budget.pause.enabled) {
8956
+ throw new BudgetPauseError(trigger);
8957
+ }
8021
8958
  const err = new Error(failure);
8022
8959
  err.stderr = runner.capturedStderrText;
8023
8960
  err.errorKind = failureKind;
@@ -8040,7 +8977,7 @@ ${basePrompt}`;
8040
8977
  clearTimeout(this.timeoutTimer);
8041
8978
  this.timeoutTimer = null;
8042
8979
  }
8043
- if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8980
+ if (this.worktreePath && this.state !== "parked" && (this.state === "error" || this.timedOut || this.aborted)) {
8044
8981
  try {
8045
8982
  await teardownWorktree2(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8046
8983
  } catch {
@@ -8050,6 +8987,7 @@ ${basePrompt}`;
8050
8987
  this.process = null;
8051
8988
  this.cardId = null;
8052
8989
  this.branchName = null;
8990
+ this.runBaselineSha = null;
8053
8991
  this.worktreePath = null;
8054
8992
  this.startedAt = null;
8055
8993
  this.runId = null;
@@ -8062,6 +9000,7 @@ var TAG21 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 =
8062
9000
  var init_worker = __esm(() => {
8063
9001
  init_dist();
8064
9002
  init_board_helpers();
9003
+ init_budget_pause();
8065
9004
  init_cli_agent_runner();
8066
9005
  init_completion();
8067
9006
  init_contract_phase();
@@ -8086,8 +9025,31 @@ import {
8086
9025
  describeApiError as describeApiError2,
8087
9026
  log as log23
8088
9027
  } from "@gethmy/harness";
9028
+ async function routeBudgetDecision(d, run, actions, cardId) {
9029
+ if (!run) {
9030
+ if (!cardId)
9031
+ return;
9032
+ if (d.decision === "continue")
9033
+ await actions.grantAttempt(cardId);
9034
+ else
9035
+ await actions.stopAttemptCap(cardId);
9036
+ return;
9037
+ }
9038
+ if (d.decision === "stop") {
9039
+ await actions.stopRun(run);
9040
+ return;
9041
+ }
9042
+ await actions.continueRun(run, {
9043
+ extraTurns: d.extraTurns,
9044
+ message: d.message
9045
+ });
9046
+ }
9047
+ function hasParkedRun(store, cardId) {
9048
+ return store.getParkedRunForCard(cardId) !== null;
9049
+ }
8089
9050
 
8090
9051
  class Pool {
9052
+ config;
8091
9053
  client;
8092
9054
  identity;
8093
9055
  projectId;
@@ -8103,6 +9065,7 @@ class Pool {
8103
9065
  authPaused = false;
8104
9066
  onCardCompleted = null;
8105
9067
  constructor(config, client, identity, workspaceId, projectId, stateStore) {
9068
+ this.config = config;
8106
9069
  this.client = client;
8107
9070
  this.identity = identity;
8108
9071
  this.projectId = projectId;
@@ -8249,7 +9212,7 @@ class Pool {
8249
9212
  await this.stateStore.resetAttempts(cardId);
8250
9213
  }
8251
9214
  isCardActive(cardId) {
8252
- return this.implWorkers.some((w) => w.cardId === cardId && w.isActive) || this.reviewWorkers.some((w) => w.cardId === cardId && w.isActive);
9215
+ return hasParkedRun(this.stateStore, cardId) || this.implWorkers.some((w) => w.cardId === cardId && w.isActive) || this.reviewWorkers.some((w) => w.cardId === cardId && w.isActive);
8253
9216
  }
8254
9217
  isCardKnown(cardId) {
8255
9218
  return this.implQueue.has(cardId) || this.reviewQueue.has(cardId) || this.isCardActive(cardId);
@@ -8270,6 +9233,10 @@ class Pool {
8270
9233
  return ids;
8271
9234
  }
8272
9235
  async handleAgentCommand(cardId, command) {
9236
+ if (command === "continue") {
9237
+ await this.drainBudgetDecisions(cardId);
9238
+ return;
9239
+ }
8273
9240
  const worker = this.implWorkers.find((w) => w.cardId === cardId && w.isActive) ?? this.reviewWorkers.find((w) => w.cardId === cardId && w.isActive);
8274
9241
  if (!worker) {
8275
9242
  log23.debug(TAG22, `No active worker for card ${cardId}, ignoring ${command}`);
@@ -8333,6 +9300,268 @@ class Pool {
8333
9300
  this.sleepGuard.stop();
8334
9301
  log23.info(TAG22, "Pool shutdown complete");
8335
9302
  }
9303
+ async drainBudgetDecisions(cardId) {
9304
+ const targets = cardId ? [cardId] : [
9305
+ ...new Set([
9306
+ ...this.stateStore.getParkedRuns().map((r) => r.cardId),
9307
+ ...this.stateStore.getResumableRuns().map((r) => r.cardId),
9308
+ ...this.stateStore.listCards().filter((c) => c.attempts >= this.config.budget.maxAttemptsPerCard).map((c) => c.cardId)
9309
+ ])
9310
+ ];
9311
+ for (const id of targets) {
9312
+ await this.drainBudgetDecisionsForCard(id);
9313
+ }
9314
+ }
9315
+ draining = new Set;
9316
+ async drainBudgetDecisionsForCard(cardId) {
9317
+ if (this.draining.has(cardId))
9318
+ return;
9319
+ this.draining.add(cardId);
9320
+ try {
9321
+ const granted = this.stateStore.getResumableRunForCard(cardId);
9322
+ if (granted) {
9323
+ await this.adoptGrantedRun(granted);
9324
+ return;
9325
+ }
9326
+ const run = this.stateStore.getParkedRunForCard(cardId);
9327
+ const sinceMs = run ? run.parkedAt ?? run.startedAt : this.stateStore.getCard(cardId)?.lastAttemptAt ?? 0;
9328
+ let decisions;
9329
+ try {
9330
+ ({ decisions } = await this.client.getBudgetDecisions(cardId, new Date(sinceMs).toISOString()));
9331
+ } catch (err) {
9332
+ log23.warn(TAG22, `getBudgetDecisions failed for ${cardId}: ${err}`);
9333
+ return;
9334
+ }
9335
+ if (decisions.length > 0) {
9336
+ await routeBudgetDecision(decisions[0], run, {
9337
+ continueRun: (r, opts) => this.continueRun(r, opts),
9338
+ stopRun: (r) => this.stopRun(r),
9339
+ grantAttempt: (id) => this.grantAttempt(id),
9340
+ stopAttemptCap: (id) => this.stopAttemptCap(id)
9341
+ }, cardId);
9342
+ return;
9343
+ }
9344
+ if (run && run.awaitingDecisionUntil != null && run.awaitingDecisionUntil < Date.now()) {
9345
+ await this.releaseExpiredPark(run);
9346
+ return;
9347
+ }
9348
+ const card = this.stateStore.getCard(cardId);
9349
+ if (!run && card?.awaitingDecisionUntil != null && card.awaitingDecisionUntil < Date.now()) {
9350
+ await this.releaseExpiredAttemptCap(cardId, card.blockerCommentId);
9351
+ }
9352
+ } finally {
9353
+ this.draining.delete(cardId);
9354
+ }
9355
+ }
9356
+ async releaseExpiredPark(run) {
9357
+ try {
9358
+ await this.client.addComment(run.cardId, formatExpiredParkComment({
9359
+ branchName: run.branchName,
9360
+ cliSessionId: run.cliSessionId ?? null
9361
+ }), {
9362
+ commentType: "summary",
9363
+ agentSessionId: run.sessionId ?? undefined,
9364
+ ...run.blockerCommentId ? { replyToId: run.blockerCommentId } : {}
9365
+ });
9366
+ } catch (err) {
9367
+ log23.warn(TAG22, `Failed to post the expired-park closing comment for ${run.cardId}: ${err}`);
9368
+ }
9369
+ try {
9370
+ const { card } = await this.client.getCard(run.cardId);
9371
+ const failColumn = this.failColumnFor(run.pipeline);
9372
+ if (failColumn) {
9373
+ await runTransition(this.client, card, {
9374
+ move: { columnName: failColumn }
9375
+ });
9376
+ }
9377
+ } catch (err) {
9378
+ log23.error(TAG22, `Failed to move #${run.cardShortId} after an expired park: ${err}`);
9379
+ }
9380
+ try {
9381
+ await this.stateStore.endRun(run.runId, "failed", {
9382
+ errorMessage: "budget decision expired"
9383
+ });
9384
+ } catch (err) {
9385
+ log23.warn(TAG22, `Failed to release the expired park for ${run.cardId}: ${err}`);
9386
+ }
9387
+ }
9388
+ async releaseExpiredAttemptCap(cardId, blockerCommentId) {
9389
+ try {
9390
+ await this.client.addComment(cardId, formatExpiredAttemptCapComment(), {
9391
+ commentType: "summary",
9392
+ ...blockerCommentId ? { replyToId: blockerCommentId } : {}
9393
+ });
9394
+ } catch (err) {
9395
+ log23.warn(TAG22, `Failed to post the expired attempt-cap note for ${cardId}: ${err}`);
9396
+ }
9397
+ try {
9398
+ await this.client.endAgentSession(cardId, {
9399
+ status: "failed",
9400
+ failureReason: "budget",
9401
+ failureSummary: "The attempt-budget decision expired with no answer. Reassign the card to grant a fresh attempt."
9402
+ });
9403
+ } catch (err) {
9404
+ log23.warn(TAG22, `Failed to end the expired attempt-cap session for ${cardId}: ${err}`);
9405
+ }
9406
+ await this.stateStore.clearAwaitingDecision(cardId);
9407
+ }
9408
+ async adoptGrantedRun(run) {
9409
+ if (this.isCardKnown(run.cardId))
9410
+ return;
9411
+ log23.warn(TAG22, `#${run.cardShortId}: granted continue never reached a worker — re-enqueueing (${run.pipeline})`);
9412
+ await this.enqueueCard(run.cardId, run.pipeline);
9413
+ }
9414
+ sessionIdentityFor(run) {
9415
+ return {
9416
+ agentIdentifier: agentIdentifier(run.workerId),
9417
+ agentName: run.pipeline === "review" ? `${AGENT_NAME} (Review)` : AGENT_NAME
9418
+ };
9419
+ }
9420
+ attemptCapIdentityFor(cardId) {
9421
+ return {
9422
+ agentIdentifier: this.stateStore.getCard(cardId)?.awaitingDecisionAgentIdentifier ?? agentIdentifier(0),
9423
+ agentName: AGENT_NAME
9424
+ };
9425
+ }
9426
+ async continueRun(run, opts) {
9427
+ await this.stateStore.decrementAttempt(run.cardId);
9428
+ const grantedTurns = opts.extraTurns > 0 ? Math.min(opts.extraTurns, MAX_GRANTED_TURNS) : this.config.budget.pause.extraTurns ?? this.defaultTurnsFor(run.pipeline);
9429
+ await this.stateStore.updateRun(run.runId, {
9430
+ status: "active",
9431
+ awaitingDecisionUntil: null,
9432
+ grantedTurns,
9433
+ resumeMessage: opts.message ?? null,
9434
+ daemonPid: process.pid,
9435
+ lastHeartbeatAt: Date.now()
9436
+ });
9437
+ try {
9438
+ await this.client.updateAgentProgress(run.cardId, {
9439
+ ...this.sessionIdentityFor(run),
9440
+ awaitingDecisionUntil: null
9441
+ });
9442
+ } catch (err) {
9443
+ log23.warn(TAG22, `Failed to clear the decision deadline for ${run.cardId}: ${err}`);
9444
+ }
9445
+ if (run.blockerCommentId) {
9446
+ try {
9447
+ await this.client.updateComment(run.blockerCommentId, {
9448
+ resolve: true
9449
+ });
9450
+ } catch (err) {
9451
+ log23.warn(TAG22, `Failed to resolve the blocker comment: ${err}`);
9452
+ }
9453
+ }
9454
+ await this.enqueueCard(run.cardId, run.pipeline);
9455
+ }
9456
+ async stopRun(run) {
9457
+ if (run.blockerCommentId) {
9458
+ try {
9459
+ await this.client.updateComment(run.blockerCommentId, {
9460
+ resolve: true
9461
+ });
9462
+ } catch (err) {
9463
+ log23.warn(TAG22, `Failed to resolve the blocker comment: ${err}`);
9464
+ }
9465
+ }
9466
+ try {
9467
+ await this.client.updateAgentProgress(run.cardId, {
9468
+ ...this.sessionIdentityFor(run),
9469
+ awaitingDecisionUntil: null
9470
+ });
9471
+ } catch (err) {
9472
+ log23.warn(TAG22, `Failed to clear the decision deadline for ${run.cardId}: ${err}`);
9473
+ }
9474
+ try {
9475
+ const { card } = await this.client.getCard(run.cardId);
9476
+ const failColumn = this.failColumnFor(run.pipeline);
9477
+ await runTransition(this.client, card, {
9478
+ ...failColumn ? { move: { columnName: failColumn } } : {},
9479
+ endSession: {
9480
+ status: "failed",
9481
+ failureReason: "budget",
9482
+ failureSummary: "Stopped by a human decision on the turn budget."
9483
+ }
9484
+ });
9485
+ } catch (err) {
9486
+ log23.error(TAG22, `Failed to hand #${run.cardShortId} back after a stop: ${err}`);
9487
+ }
9488
+ try {
9489
+ await this.stateStore.endRun(run.runId, "failed", {
9490
+ errorMessage: "budget_decision_stop"
9491
+ });
9492
+ } catch (err) {
9493
+ log23.warn(TAG22, `Failed to end the local run record for ${run.cardId}: ${err}`);
9494
+ }
9495
+ }
9496
+ async grantAttempt(cardId) {
9497
+ const identity = this.attemptCapIdentityFor(cardId);
9498
+ await this.stateStore.resetAttempts(cardId);
9499
+ await this.stateStore.clearAwaitingDecision(cardId);
9500
+ try {
9501
+ await this.client.updateAgentProgress(cardId, {
9502
+ ...identity,
9503
+ awaitingDecisionUntil: null
9504
+ });
9505
+ } catch (err) {
9506
+ log23.warn(TAG22, `Failed to clear the attempt-cap decision deadline for ${cardId}: ${err}`);
9507
+ }
9508
+ await this.enqueueCard(cardId, "implement");
9509
+ }
9510
+ async stopAttemptCap(cardId) {
9511
+ const blockerCommentId = this.stateStore.getCard(cardId)?.blockerCommentId ?? null;
9512
+ const identity = this.attemptCapIdentityFor(cardId);
9513
+ if (blockerCommentId) {
9514
+ try {
9515
+ await this.client.updateComment(blockerCommentId, { resolve: true });
9516
+ } catch (err) {
9517
+ log23.warn(TAG22, `Failed to resolve the blocker comment: ${err}`);
9518
+ }
9519
+ }
9520
+ try {
9521
+ await this.client.updateAgentProgress(cardId, {
9522
+ ...identity,
9523
+ awaitingDecisionUntil: null
9524
+ });
9525
+ } catch (err) {
9526
+ log23.warn(TAG22, `Failed to clear the decision deadline for ${cardId}: ${err}`);
9527
+ }
9528
+ try {
9529
+ await this.client.endAgentSession(cardId, {
9530
+ status: "failed",
9531
+ failureReason: "budget",
9532
+ failureSummary: "Stopped by a human decision on the attempt budget."
9533
+ });
9534
+ } catch (err) {
9535
+ log23.warn(TAG22, `Failed to end the attempt-cap session for ${cardId}: ${err}`);
9536
+ }
9537
+ await this.stateStore.clearAwaitingDecision(cardId);
9538
+ }
9539
+ failColumnFor(pipeline) {
9540
+ return pipeline === "review" ? this.config.review.failColumn : this.config.pickupColumns[0];
9541
+ }
9542
+ defaultTurnsFor(pipeline) {
9543
+ return pipeline === "review" ? this.config.claude.reviewMaxTurns : this.config.claude.maxTurns;
9544
+ }
9545
+ async enqueueCard(cardId, mode) {
9546
+ try {
9547
+ const { card } = await this.client.getCard(cardId);
9548
+ const board = await this.client.getBoard(this.projectId, {
9549
+ summary: true
9550
+ });
9551
+ const columns = board.columns ?? [];
9552
+ const column = columns.find((c) => c.id === card.column_id);
9553
+ if (!column) {
9554
+ log23.warn(TAG22, `#${card.short_id}: column not found — cannot re-enqueue`);
9555
+ return;
9556
+ }
9557
+ const labelMap = buildLabelMap(board.labels ?? []);
9558
+ const cardLabels = resolveCardLabels(card, labelMap);
9559
+ const subtasks = card.subtasks ?? [];
9560
+ await this.enqueue(card, column, cardLabels, subtasks, mode);
9561
+ } catch (err) {
9562
+ log23.error(TAG22, `Failed to re-enqueue ${cardId} after a budget decision: ${err}`);
9563
+ }
9564
+ }
8336
9565
  reservations = new Set;
8337
9566
  cardDataCache = new Map;
8338
9567
  tryDispatchFor(workers, queue, label) {
@@ -8361,9 +9590,12 @@ class Pool {
8361
9590
  }
8362
9591
  var TAG22 = "pool";
8363
9592
  var init_pool = __esm(() => {
9593
+ init_board_helpers();
9594
+ init_budget_pause();
8364
9595
  init_queue();
8365
9596
  init_review_worker();
8366
9597
  init_sleep_guard();
9598
+ init_transitions();
8367
9599
  init_types2();
8368
9600
  init_unblock();
8369
9601
  init_worker();
@@ -8478,6 +9710,11 @@ async function recoverOrphans(store, client, config) {
8478
9710
  errors: []
8479
9711
  };
8480
9712
  outcomes.push(outcome);
9713
+ if (isBudgetHeldRun(run)) {
9714
+ log25.info(TAG24, `run ${run.runId} (#${run.cardShortId}) is held for a human budget decision — leaving it`);
9715
+ outcome.actions.push("skipped: held for a human budget decision");
9716
+ continue;
9717
+ }
8481
9718
  if (isProcessAlive(run.daemonPid, process.pid)) {
8482
9719
  log25.warn(TAG24, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
8483
9720
  outcome.actions.push("skipped: daemon pid still alive");
@@ -8560,6 +9797,7 @@ async function recoverRun(run, store, client, config, outcome, opts = {}) {
8560
9797
  var TAG24 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
8561
9798
  var init_recovery = __esm(() => {
8562
9799
  init_board_helpers();
9800
+ init_state_store();
8563
9801
  });
8564
9802
 
8565
9803
  // src/claim.ts
@@ -8703,6 +9941,10 @@ class Reconciler {
8703
9941
  const active = this.stateStore.getActiveRuns();
8704
9942
  const pool = this.pool;
8705
9943
  for (const run of active) {
9944
+ if (isBudgetHeldRun(run)) {
9945
+ log28.info(TAG27, `run ${run.runId} (#${run.cardShortId}) is held for a human budget decision — leaving it`);
9946
+ continue;
9947
+ }
8706
9948
  const foreignDaemon = run.daemonPid !== process.pid;
8707
9949
  const daemonDead = foreignDaemon && !isProcessAlive(run.daemonPid, process.pid);
8708
9950
  const heartbeatStale = now - run.lastHeartbeatAt > stale;
@@ -8866,6 +10108,11 @@ class Reconciler {
8866
10108
  if (this.stateStore && this.agentConfig) {
8867
10109
  await this.recoverStaleRuns();
8868
10110
  }
10111
+ try {
10112
+ await this.pool.drainBudgetDecisions();
10113
+ } catch (err) {
10114
+ log28.error(TAG27, `budget decisions were not drained this tick: ${err instanceof Error ? err.message : err}`);
10115
+ }
8869
10116
  await this.recoverStrandedInProgress(cards, columns, knownCardIds);
8870
10117
  await this.recoverStrandedReview(cards, columns, labelMap, knownCardIds);
8871
10118
  for (const knownId of knownCardIds) {
@@ -8886,6 +10133,7 @@ var init_reconcile = __esm(() => {
8886
10133
  init_board_helpers();
8887
10134
  init_recovery();
8888
10135
  init_review_worktree();
10136
+ init_state_store();
8889
10137
  init_strand_recovery();
8890
10138
  init_types2();
8891
10139
  });
@@ -9179,6 +10427,9 @@ class Watcher {
9179
10427
  reconnectTimer = null;
9180
10428
  reconnectAttempts = 0;
9181
10429
  broadcastGen = 0;
10430
+ presenceReconnectTimer = null;
10431
+ presenceReconnectAttempts = 0;
10432
+ presenceGen = 0;
9182
10433
  get isConnected() {
9183
10434
  return this.connected;
9184
10435
  }
@@ -9206,29 +10457,85 @@ class Watcher {
9206
10457
  log30.info(TAG29, "Connecting to Supabase realtime (broadcast)...");
9207
10458
  }
9208
10459
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
9209
- const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
9210
10460
  this.subscribeBroadcast();
10461
+ this.subscribePresence();
10462
+ }
10463
+ subscribePresence() {
10464
+ if (!this.supabase)
10465
+ return;
10466
+ const gen = ++this.presenceGen;
10467
+ const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
10468
+ this.presenceChannel = presenceChannel;
9211
10469
  presenceChannel.on("presence", { event: "sync" }, () => {
9212
10470
  log30.debug(TAG29, "Presence sync");
9213
10471
  }).subscribe(async (status) => {
10472
+ if (gen !== this.presenceGen)
10473
+ return;
9214
10474
  if (status === "SUBSCRIBED") {
9215
- await presenceChannel.track({
9216
- daemonId: this.daemonId,
9217
- startedAt: new Date().toISOString(),
9218
- userId: this.identity.userId,
9219
- agentId: this.identity.agentId,
9220
- userEmail: this.identity.userEmail,
9221
- agentIdentifier: this.identity.agentIdentifier,
9222
- agentName: this.identity.agentName
9223
- });
10475
+ let trackStatus;
10476
+ try {
10477
+ trackStatus = await presenceChannel.track({
10478
+ daemonId: this.daemonId,
10479
+ startedAt: new Date().toISOString(),
10480
+ userId: this.identity.userId,
10481
+ agentId: this.identity.agentId,
10482
+ userEmail: this.identity.userEmail,
10483
+ agentIdentifier: this.identity.agentIdentifier,
10484
+ agentName: this.identity.agentName
10485
+ });
10486
+ } catch (err) {
10487
+ trackStatus = `error (${String(err)})`;
10488
+ }
10489
+ if (this.stopping || gen !== this.presenceGen)
10490
+ return;
10491
+ if (trackStatus !== "ok") {
10492
+ this.presenceTracked = false;
10493
+ if (!this.stopping) {
10494
+ log30.warn(TAG29, `Presence track returned "${trackStatus}" — scheduling reconnect`);
10495
+ this.schedulePresenceReconnect();
10496
+ }
10497
+ return;
10498
+ }
9224
10499
  if (!isPretty2() || !this.suppressStartupLogs) {
9225
10500
  log30.info(TAG29, "Presence tracked on board-presence channel");
9226
10501
  }
9227
10502
  this.presenceTracked = true;
10503
+ this.presenceReconnectAttempts = 0;
9228
10504
  this.maybeResolveReady();
10505
+ } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
10506
+ this.presenceTracked = false;
10507
+ if (!this.stopping) {
10508
+ log30.warn(TAG29, `Presence subscription ${status} — scheduling reconnect`);
10509
+ this.schedulePresenceReconnect();
10510
+ }
9229
10511
  }
9230
10512
  });
9231
- this.presenceChannel = presenceChannel;
10513
+ }
10514
+ schedulePresenceReconnect() {
10515
+ if (this.stopping || this.presenceReconnectTimer)
10516
+ return;
10517
+ const delay = Math.min(30000, 1000 * 2 ** this.presenceReconnectAttempts);
10518
+ this.presenceReconnectAttempts++;
10519
+ this.presenceReconnectTimer = setTimeout(() => {
10520
+ this.presenceReconnectTimer = null;
10521
+ this.reconnectPresence();
10522
+ }, delay);
10523
+ }
10524
+ async reconnectPresence() {
10525
+ if (this.stopping || !this.supabase)
10526
+ return;
10527
+ log30.warn(TAG29, `Reconnecting presence subscription (attempt ${this.presenceReconnectAttempts})`);
10528
+ if (this.presenceChannel) {
10529
+ const old = this.presenceChannel;
10530
+ this.presenceChannel = null;
10531
+ this.presenceGen++;
10532
+ try {
10533
+ await this.supabase.removeChannel(old);
10534
+ } catch {}
10535
+ }
10536
+ if (this.stopping || !this.supabase)
10537
+ return;
10538
+ this.subscribePresence();
9232
10539
  }
9233
10540
  subscribeBroadcast() {
9234
10541
  if (!this.supabase)
@@ -9295,6 +10602,8 @@ class Watcher {
9295
10602
  await this.supabase.removeChannel(old);
9296
10603
  } catch {}
9297
10604
  }
10605
+ if (this.stopping || !this.supabase)
10606
+ return;
9298
10607
  this.subscribeBroadcast();
9299
10608
  }
9300
10609
  async stop() {
@@ -9303,6 +10612,10 @@ class Watcher {
9303
10612
  clearTimeout(this.reconnectTimer);
9304
10613
  this.reconnectTimer = null;
9305
10614
  }
10615
+ if (this.presenceReconnectTimer) {
10616
+ clearTimeout(this.presenceReconnectTimer);
10617
+ this.presenceReconnectTimer = null;
10618
+ }
9306
10619
  if (this.presenceChannel) {
9307
10620
  await this.supabase?.removeChannel(this.presenceChannel);
9308
10621
  this.presenceChannel = null;
@@ -9316,6 +10629,7 @@ class Watcher {
9316
10629
  this.supabase = null;
9317
10630
  }
9318
10631
  this.connected = false;
10632
+ this.presenceTracked = false;
9319
10633
  log30.info(TAG29, "Broadcast subscription stopped");
9320
10634
  }
9321
10635
  }
@@ -9330,7 +10644,7 @@ __export(exports_worktree_gc, {
9330
10644
  isTransientGitNetworkError: () => isTransientGitNetworkError,
9331
10645
  WorktreeGc: () => WorktreeGc
9332
10646
  });
9333
- import { execFileSync as execFileSync4 } from "node:child_process";
10647
+ import { execFileSync as execFileSync6 } from "node:child_process";
9334
10648
  import { readdirSync, statSync as statSync2 } from "node:fs";
9335
10649
  import { resolve as resolve2 } from "node:path";
9336
10650
  import { cleanupWorktree as cleanupWorktree4, log as log31 } from "@gethmy/harness";
@@ -9399,7 +10713,7 @@ function runWorktreeGc(basePath, store, opts = {}) {
9399
10713
  }
9400
10714
  }
9401
10715
  try {
9402
- execFileSync4("git", ["worktree", "prune", "--expire=now"], {
10716
+ execFileSync6("git", ["worktree", "prune", "--expire=now"], {
9403
10717
  cwd: repoRoot,
9404
10718
  stdio: "pipe"
9405
10719
  });
@@ -9430,7 +10744,7 @@ function pruneFailedRemoteBranches(opts) {
9430
10744
  return result;
9431
10745
  }
9432
10746
  try {
9433
- execFileSync4("git", ["fetch", "--prune", "origin"], {
10747
+ execFileSync6("git", ["fetch", "--prune", "origin"], {
9434
10748
  cwd: repoRoot,
9435
10749
  stdio: "pipe",
9436
10750
  ...GIT_NETWORK_EXEC
@@ -9446,7 +10760,7 @@ function pruneFailedRemoteBranches(opts) {
9446
10760
  const refPattern = `refs/remotes/origin/${opts.prefix}*`;
9447
10761
  let listing = "";
9448
10762
  try {
9449
- listing = execFileSync4("git", [
10763
+ listing = execFileSync6("git", [
9450
10764
  "for-each-ref",
9451
10765
  "--format=%(refname:strip=3) %(committerdate:unix)",
9452
10766
  refPattern
@@ -9481,7 +10795,7 @@ function pruneFailedRemoteBranches(opts) {
9481
10795
  break;
9482
10796
  }
9483
10797
  try {
9484
- execFileSync4("git", ["push", "origin", `:refs/heads/${ref}`], {
10798
+ execFileSync6("git", ["push", "origin", `:refs/heads/${ref}`], {
9485
10799
  cwd: repoRoot,
9486
10800
  stdio: "pipe",
9487
10801
  ...GIT_NETWORK_EXEC
@@ -9544,7 +10858,7 @@ class WorktreeGc {
9544
10858
  }
9545
10859
  function getRepoRoot2() {
9546
10860
  try {
9547
- return execFileSync4("git", ["rev-parse", "--show-toplevel"], {
10861
+ return execFileSync6("git", ["rev-parse", "--show-toplevel"], {
9548
10862
  encoding: "utf-8"
9549
10863
  }).trim();
9550
10864
  } catch {
@@ -9583,7 +10897,7 @@ __export(exports_src, {
9583
10897
  validatePrerequisites: () => validatePrerequisites,
9584
10898
  main: () => main
9585
10899
  });
9586
- import { execFileSync as execFileSync5 } from "node:child_process";
10900
+ import { execFileSync as execFileSync7 } from "node:child_process";
9587
10901
  import { randomUUID as randomUUID3 } from "node:crypto";
9588
10902
  import { createRequire as createRequire3 } from "node:module";
9589
10903
  import {
@@ -9593,7 +10907,7 @@ import {
9593
10907
  } from "@gethmy/harness";
9594
10908
  async function validatePrerequisites(config, banner) {
9595
10909
  try {
9596
- const ver = execFileSync5("claude", ["--version"], {
10910
+ const ver = execFileSync7("claude", ["--version"], {
9597
10911
  encoding: "utf-8"
9598
10912
  }).trim();
9599
10913
  banner.check(`Claude CLI ${ver}`);
@@ -9608,20 +10922,23 @@ async function validatePrerequisites(config, banner) {
9608
10922
  validateGitProviderCli(provider);
9609
10923
  }
9610
10924
  try {
9611
- const status = execFileSync5("git", ["status", "--porcelain"], {
9612
- encoding: "utf-8"
10925
+ const status = execFileSync7("git", ["status", "--porcelain"], {
10926
+ encoding: "utf-8",
10927
+ stdio: "pipe"
9613
10928
  }).trim();
9614
10929
  if (status) {
9615
10930
  banner.warn(`Working directory has uncommitted changes:
9616
10931
  ${status}`);
9617
10932
  }
9618
- execFileSync5("git", ["rev-parse", "--verify", `origin/${config.agent.worktree.baseBranch}`], {
9619
- encoding: "utf-8",
9620
- stdio: "pipe"
9621
- });
9622
10933
  } catch {
9623
- throw new Error(`Git base branch "origin/${config.agent.worktree.baseBranch}" not found. Fetch or configure a different base branch.`);
10934
+ throw new Error(`Not a git repository: ${process.cwd()}. Start the daemon from the checkout its worktrees should branch from.`);
9624
10935
  }
10936
+ const base = config.agent.worktree.baseBranch;
10937
+ const resolution = resolveBaseBranch(base, BASE_REMOTE, createGitProbe(process.cwd()));
10938
+ if (!resolution.ok) {
10939
+ throw new Error(`Git base branch "${BASE_REMOTE}/${base}" is unusable. ${resolution.message}`);
10940
+ }
10941
+ banner.check(resolution.fetched ? `Base branch ${BASE_REMOTE}/${base} (fetched)` : `Base branch ${BASE_REMOTE}/${base}`);
9625
10942
  const client = createApiClient(config);
9626
10943
  try {
9627
10944
  await client.listWorkspaces();
@@ -9687,10 +11004,17 @@ async function main() {
9687
11004
  const { agent: registeredAgent } = await client.registerWorkspaceAgent(config.workspaceId, {
9688
11005
  identifier: config.agentIdentifier,
9689
11006
  name: config.agentName,
9690
- color: config.agentColor
11007
+ color: config.agentColor,
11008
+ declaredGateMetrics: declaredMetricNames(config.agent.playbooks.metrics)
9691
11009
  });
9692
11010
  const agentId = registeredAgent.id;
9693
11011
  banner.check(`Agent registered (${config.agentName})`);
11012
+ try {
11013
+ const undeclared = await findUndeclaredGateMetrics(client, config.projectId, config.agent);
11014
+ for (const finding of undeclared) {
11015
+ banner.warn(formatUndeclaredMetricWarning(finding));
11016
+ }
11017
+ } catch {}
9694
11018
  const identity = { userId: agentUserId, agentId };
9695
11019
  const realtimeCreds = await fetchRealtimeCredentials(client);
9696
11020
  banner.check("Realtime credentials");
@@ -9920,14 +11244,16 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9920
11244
  }
9921
11245
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
9922
11246
  }
9923
- var TAG31 = "daemon", PKG_VERSION;
11247
+ var TAG31 = "daemon", BASE_REMOTE = "origin", PKG_VERSION;
9924
11248
  var init_src = __esm(() => {
11249
+ init_base_branch();
9925
11250
  init_board_helpers();
9926
11251
  init_board_reviewer();
9927
11252
  init_config();
9928
11253
  init_config_validation();
9929
11254
  init_http_server();
9930
11255
  init_merge_monitor();
11256
+ init_metric_validation();
9931
11257
  init_pool();
9932
11258
  init_port_registry();
9933
11259
  init_reconcile();
@@ -10148,7 +11474,7 @@ function computeRunStats(runs, cards, logs = []) {
10148
11474
  const verifyFailCount = failureReasons.verification ?? 0;
10149
11475
  return {
10150
11476
  totalRuns: runs.length,
10151
- activeRuns: runs.length - ended.length,
11477
+ activeRuns: runs.length - ended.length - byStatus.parked,
10152
11478
  endedRuns: ended.length,
10153
11479
  byPipeline,
10154
11480
  byStatus,
@@ -10195,6 +11521,7 @@ function buildRunListRows(runs, logs, opts = {}) {
10195
11521
  }
10196
11522
  var EMPTY_STATUS_HISTOGRAM = () => ({
10197
11523
  active: 0,
11524
+ parked: 0,
10198
11525
  completed: 0,
10199
11526
  paused: 0,
10200
11527
  failed: 0,
@@ -10370,10 +11697,12 @@ async function recoverCommand() {
10370
11697
  const { buildLabelMap: buildLabelMap2 } = await Promise.resolve().then(() => (init_board_helpers(), exports_board_helpers));
10371
11698
  const config = loadDaemonConfig2();
10372
11699
  const client = createApiClient2(config);
11700
+ const { declaredMetricNames: declaredMetricNames2 } = await Promise.resolve().then(() => exports_declared_metrics);
10373
11701
  const { agent: registeredAgent } = await client.registerWorkspaceAgent(config.workspaceId, {
10374
11702
  identifier: config.agentIdentifier,
10375
11703
  name: config.agentName,
10376
- color: config.agentColor
11704
+ color: config.agentColor,
11705
+ declaredGateMetrics: declaredMetricNames2(config.agent.playbooks.metrics)
10377
11706
  });
10378
11707
  const agentId = registeredAgent.id;
10379
11708
  const monitor = new MergeMonitor2(client, config.projectId, config.agent);
@@ -10633,12 +11962,12 @@ async function statsCommand() {
10633
11962
  return 0;
10634
11963
  }
10635
11964
  const pct = (x) => `${(x * 100).toFixed(0)}%`;
10636
- out.write(`runs total=${stats.totalRuns} active=${stats.activeRuns} ended=${stats.endedRuns} (failure rate ${pct(stats.runFailureRate)})
11965
+ out.write(`runs total=${stats.totalRuns} active=${stats.activeRuns} parked=${stats.byStatus.parked} ended=${stats.endedRuns} (failure rate ${pct(stats.runFailureRate)})
10637
11966
  `);
10638
11967
  out.write(`pipelines implement=${stats.byPipeline.implement} review=${stats.byPipeline.review}
10639
11968
  `);
10640
11969
  const s = stats.byStatus;
10641
- out.write(`status completed=${s.completed} failed=${s.failed} orphaned=${s.orphaned} paused=${s.paused} active=${s.active}
11970
+ out.write(`status completed=${s.completed} failed=${s.failed} orphaned=${s.orphaned} paused=${s.paused} parked=${s.parked} active=${s.active}
10642
11971
  `);
10643
11972
  out.write(`cards distinct=${stats.distinctCards} avg runs/card=${stats.avgRunsPerCard.toFixed(1)}
10644
11973
  `);