@gethmy/agent 1.14.4 → 1.16.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 +576 -99
  2. package/dist/index.js +526 -99
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -101,6 +101,16 @@ var init_log = __esm(() => {
101
101
  });
102
102
 
103
103
  // src/board-helpers.ts
104
+ var exports_board_helpers = {};
105
+ __export(exports_board_helpers, {
106
+ resolveCardLabels: () => resolveCardLabels,
107
+ moveCardToColumn: () => moveCardToColumn,
108
+ moveCardAndAddLabel: () => moveCardAndAddLabel,
109
+ hasLabel: () => hasLabel,
110
+ findOrCreateLabel: () => findOrCreateLabel,
111
+ buildLabelMap: () => buildLabelMap,
112
+ addLabelByName: () => addLabelByName
113
+ });
104
114
  function buildLabelMap(boardLabels) {
105
115
  const map = new Map;
106
116
  for (const label of boardLabels) {
@@ -694,6 +704,22 @@ var init_config_validation = __esm(() => {
694
704
  });
695
705
 
696
706
  // src/git-pr.ts
707
+ var exports_git_pr = {};
708
+ __export(exports_git_pr, {
709
+ validateGitProviderCli: () => validateGitProviderCli,
710
+ updateExistingPr: () => updateExistingPr,
711
+ resolvePrUrl: () => resolvePrUrl,
712
+ renameRemoteBranch: () => renameRemoteBranch,
713
+ remoteBranchExists: () => remoteBranchExists,
714
+ pushBranch: () => pushBranch,
715
+ getBranchWebUrl: () => getBranchWebUrl,
716
+ findExistingPr: () => findExistingPr,
717
+ extractPrUrl: () => extractPrUrl,
718
+ detectGitProvider: () => detectGitProvider,
719
+ createPullRequest: () => createPullRequest,
720
+ checkPrMergeStatus: () => checkPrMergeStatus,
721
+ buildPrBody: () => buildPrBody
722
+ });
697
723
  import { execFile, execFileSync } from "node:child_process";
698
724
  import { promisify } from "node:util";
699
725
  function detectGitProvider(cwd) {
@@ -815,6 +841,14 @@ function extractPrUrl(description) {
815
841
  return null;
816
842
  }
817
843
  }
844
+ function resolvePrUrl(description, branchName, cwd, provider) {
845
+ const fromDesc = extractPrUrl(description);
846
+ if (fromDesc)
847
+ return fromDesc;
848
+ if (!branchName)
849
+ return null;
850
+ return findExistingPr(branchName, cwd, provider) || null;
851
+ }
818
852
  function remoteBranchExists(branchName, cwd) {
819
853
  try {
820
854
  execFileSync("git", ["ls-remote", "--exit-code", "origin", `refs/heads/${branchName}`], { cwd, stdio: "pipe" });
@@ -1601,6 +1635,51 @@ var init_logger = () => {};
1601
1635
  var init_playbookCatalog = () => {};
1602
1636
 
1603
1637
  // ../harmony-shared/dist/playbookStage.js
1638
+ function normalizeLoopDef(raw) {
1639
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
1640
+ return null;
1641
+ const obj = raw;
1642
+ if (obj.mode !== "converge" && obj.mode !== "fanout")
1643
+ return null;
1644
+ const mode = obj.mode;
1645
+ const rawMax = obj.max_iterations;
1646
+ const maxInt = typeof rawMax === "number" && Number.isFinite(rawMax) && rawMax >= 1 ? Math.floor(rawMax) : DEFAULT_LOOP_MAX_ITERATIONS;
1647
+ const exitGate = obj.exit_gate && typeof obj.exit_gate === "object" && !Array.isArray(obj.exit_gate) ? obj.exit_gate : null;
1648
+ const def = { mode, max_iterations: maxInt };
1649
+ if (exitGate)
1650
+ def.exit_gate = exitGate;
1651
+ if (obj.item_source && typeof obj.item_source === "object" && !Array.isArray(obj.item_source)) {
1652
+ def.item_source = obj.item_source;
1653
+ }
1654
+ if (typeof obj.concurrency === "number" && obj.concurrency >= 1) {
1655
+ def.concurrency = Math.floor(obj.concurrency);
1656
+ }
1657
+ if (obj.on_item_fail === "continue" || obj.on_item_fail === "halt") {
1658
+ def.on_item_fail = obj.on_item_fail;
1659
+ }
1660
+ return def;
1661
+ }
1662
+ function getStageLoop(stage) {
1663
+ return normalizeLoopDef(stage.loop);
1664
+ }
1665
+ function isConvergeLoop(loop) {
1666
+ return loop !== null && loop.mode === "converge";
1667
+ }
1668
+ function resolveLoopExitGate(stage, loop) {
1669
+ return loop.exit_gate ?? stage.gate ?? null;
1670
+ }
1671
+ function decideLoopContinuation(args) {
1672
+ const { loop, gatePassed, hasExitGate, completedIterations } = args;
1673
+ const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
1674
+ if (!hasExitGate) {
1675
+ return completedIterations >= max ? "exit" : "iterate";
1676
+ }
1677
+ if (gatePassed)
1678
+ return "exit";
1679
+ if (completedIterations >= max)
1680
+ return "exhausted";
1681
+ return "iterate";
1682
+ }
1604
1683
  function readStageDefs(def) {
1605
1684
  if (def.steps_version !== 2)
1606
1685
  return [];
@@ -1637,7 +1716,10 @@ function entryActionAllowlist(entryAction) {
1637
1716
  return `mcp__${entryAction}`;
1638
1717
  return null;
1639
1718
  }
1640
- var SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1719
+ function stageDisallowedTools() {
1720
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1721
+ }
1722
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1641
1723
  var init_playbookStage = __esm(() => {
1642
1724
  SKILL_TOOL_ALLOWLIST = {
1643
1725
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -1648,6 +1730,11 @@ var init_playbookStage = __esm(() => {
1648
1730
  "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1649
1731
  };
1650
1732
  HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1733
+ STAGE_DAEMON_OWNED_TOOLS = [
1734
+ "mcp__harmony__harmony_end_agent_session",
1735
+ "mcp__harmony__harmony_start_agent_session",
1736
+ "mcp__harmony__harmony_move_card"
1737
+ ];
1651
1738
  });
1652
1739
 
1653
1740
  // ../harmony-shared/dist/projectTemplates.js
@@ -2082,6 +2169,58 @@ function cleanupWorktree(worktreePath, branchName) {
2082
2169
  } catch {}
2083
2170
  }
2084
2171
  }
2172
+ function resolveRepoRoot() {
2173
+ return execFileSync3("git", ["rev-parse", "--show-toplevel"], {
2174
+ encoding: "utf-8"
2175
+ }).trim();
2176
+ }
2177
+ function branchAheadOfItsRemote(branchName, repoRoot = resolveRepoRoot()) {
2178
+ try {
2179
+ const out = execFileSync3("git", ["rev-list", branchName, "--not", "--remotes=origin"], { cwd: repoRoot, encoding: "utf-8" }).trim();
2180
+ return out.length > 0;
2181
+ } catch {
2182
+ return false;
2183
+ }
2184
+ }
2185
+ async function rescueUnpushedBranch(client, cardId, branchName, repoRoot = resolveRepoRoot()) {
2186
+ const { getBranchWebUrl: getBranchWebUrl2, pushBranch: pushBranch2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
2187
+ try {
2188
+ pushBranch2(branchName, repoRoot);
2189
+ } catch (err) {
2190
+ log.error(TAG5, `push-rescue failed for ${branchName} — leaving local branch ref intact (recoverable via git reflog / the local branch): ${err instanceof Error ? err.message : err}`);
2191
+ return false;
2192
+ }
2193
+ log.warn(TAG5, `push-rescued unpushed branch ${branchName} to origin before teardown`);
2194
+ try {
2195
+ const url = getBranchWebUrl2(branchName, repoRoot);
2196
+ const recover = url ? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\`` : `Recover it locally: \`git fetch && git checkout ${branchName}\``;
2197
+ const body = `⚠ Run ended before completion. Committed work was push-rescued to ` + `\`origin/${branchName}\` so it isn't lost. ${recover}`;
2198
+ await client.addComment(cardId, body, { commentType: "message" });
2199
+ } catch (err) {
2200
+ log.warn(TAG5, `push-rescue comment failed for ${branchName} (work is still safe on origin): ${err instanceof Error ? err.message : err}`);
2201
+ }
2202
+ return true;
2203
+ }
2204
+ async function teardownWorktree(client, cardId, worktreePath, branchName) {
2205
+ let skipBranchDelete = false;
2206
+ if (branchName && cardId) {
2207
+ let repoRoot;
2208
+ try {
2209
+ repoRoot = resolveRepoRoot();
2210
+ } catch {
2211
+ cleanupWorktree(worktreePath, branchName);
2212
+ return;
2213
+ }
2214
+ if (branchAheadOfItsRemote(branchName, repoRoot)) {
2215
+ const ok = await rescueUnpushedBranch(client, cardId, branchName, repoRoot);
2216
+ if (!ok) {
2217
+ skipBranchDelete = true;
2218
+ log.error(TAG5, `Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`);
2219
+ }
2220
+ }
2221
+ }
2222
+ cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
2223
+ }
2085
2224
  function makeBranchName(shortId, title, prefix = "agent-attempts/") {
2086
2225
  const slug = title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2087
2226
  return `${prefix}${shortId}-${slug || "task"}`;
@@ -2186,6 +2325,10 @@ var init_review_worktree = __esm(() => {
2186
2325
  });
2187
2326
 
2188
2327
  // src/merge-monitor.ts
2328
+ var exports_merge_monitor = {};
2329
+ __export(exports_merge_monitor, {
2330
+ MergeMonitor: () => MergeMonitor
2331
+ });
2189
2332
  import { execFile as execFile2 } from "node:child_process";
2190
2333
  import { promisify as promisify2 } from "node:util";
2191
2334
 
@@ -2219,6 +2362,9 @@ class MergeMonitor {
2219
2362
  }
2220
2363
  log.info(TAG7, "Merge monitor stopped");
2221
2364
  }
2365
+ async runOnce() {
2366
+ await this.tick();
2367
+ }
2222
2368
  async scheduleNext(delayMs) {
2223
2369
  await new Promise((resolve3) => {
2224
2370
  this.timer = setTimeout(() => resolve3(), delayMs);
@@ -2256,9 +2402,10 @@ class MergeMonitor {
2256
2402
  const batch = candidatesWithLabels.slice(0, 5);
2257
2403
  log.debug(TAG7, `Checking ${batch.length} Ready to Merge card(s)`);
2258
2404
  const results = await Promise.allSettled(batch.map(async ({ card, labels }) => {
2259
- const prUrl = extractPrUrl(card.description ?? null);
2405
+ const branchName = extractBranchFromDescription(card.description);
2406
+ const prUrl = resolvePrUrl(card.description ?? null, branchName, this.cwd, this.provider);
2260
2407
  if (!prUrl) {
2261
- log.debug(TAG7, `#${card.short_id} has no PR URL — skipping`);
2408
+ log.debug(TAG7, `#${card.short_id} has no resolvable PR — skipping`);
2262
2409
  return;
2263
2410
  }
2264
2411
  const state = await checkPrMergeStatus(prUrl, this.cwd, this.provider);
@@ -3424,7 +3571,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3424
3571
  failureSummary,
3425
3572
  ...buildTokenPayload(sessionStats)
3426
3573
  });
3427
- cleanupWorktree(worktreePath, branchName);
3574
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3428
3575
  return false;
3429
3576
  }
3430
3577
  log.info(TAG14, `Pushing branch ${branchName} (pre-verify)...`);
@@ -3506,7 +3653,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3506
3653
  recoveryBranch: branchName,
3507
3654
  ...buildTokenPayload(sessionStats)
3508
3655
  });
3509
- cleanupWorktree(worktreePath, branchName);
3656
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3510
3657
  return false;
3511
3658
  }
3512
3659
  log.info(TAG14, `Verification passed for #${card.short_id}`);
@@ -3562,7 +3709,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3562
3709
  log.warn(TAG14, `onBeforeWorktreeCleanup hook failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3563
3710
  }
3564
3711
  }
3565
- cleanupWorktree(worktreePath, branchName);
3712
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3566
3713
  log.info(TAG14, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
3567
3714
  return true;
3568
3715
  }
@@ -3882,6 +4029,7 @@ class SdkAgentRunner {
3882
4029
  cwd: input.cwd,
3883
4030
  model: input.model ?? this.cfg.model,
3884
4031
  allowedTools: allowed,
4032
+ ...this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0 ? { disallowedTools: this.cfg.disallowedTools } : {},
3885
4033
  tools: builtinTools,
3886
4034
  permissionMode: "dontAsk",
3887
4035
  maxTurns: this.cfg.maxTurns,
@@ -5574,6 +5722,30 @@ class StateStore {
5574
5722
  rec.attempts = 0;
5575
5723
  await this.persist();
5576
5724
  }
5725
+ getLoopIterations(cardId, stageId) {
5726
+ const rec = this.getCard(cardId);
5727
+ if (!rec || rec.loopStageId !== stageId)
5728
+ return 0;
5729
+ return rec.loopIterations ?? 0;
5730
+ }
5731
+ async incrementLoopIteration(cardId, stageId) {
5732
+ const rec = this.ensureCard(cardId);
5733
+ if (rec.loopStageId !== stageId) {
5734
+ rec.loopStageId = stageId;
5735
+ rec.loopIterations = 0;
5736
+ }
5737
+ rec.loopIterations = (rec.loopIterations ?? 0) + 1;
5738
+ await this.persist();
5739
+ return rec.loopIterations;
5740
+ }
5741
+ async resetLoopIterations(cardId) {
5742
+ const rec = this.getCard(cardId);
5743
+ if (!rec || rec.loopIterations == null && rec.loopStageId == null)
5744
+ return;
5745
+ rec.loopStageId = null;
5746
+ rec.loopIterations = 0;
5747
+ await this.persist();
5748
+ }
5577
5749
  async resetAttempts(cardId) {
5578
5750
  const rec = this.getCard(cardId);
5579
5751
  if (!rec || rec.attempts === 0)
@@ -6707,6 +6879,26 @@ class CliAgentRunner {
6707
6879
  this.enqueue({ kind: "playbook_advanced", source: "system", payload });
6708
6880
  this.startTimer();
6709
6881
  }
6882
+ recordLoopIterationStarted(payload) {
6883
+ this.enqueue({ kind: "loop_iteration_started", source: "system", payload });
6884
+ this.startTimer();
6885
+ }
6886
+ recordLoopIterationEvaluated(payload) {
6887
+ this.enqueue({
6888
+ kind: "loop_iteration_evaluated",
6889
+ source: "system",
6890
+ payload: { ...payload, evidence: truncateOutput(payload.evidence) }
6891
+ });
6892
+ this.startTimer();
6893
+ }
6894
+ recordLoopCompleted(payload) {
6895
+ this.enqueue({ kind: "loop_completed", source: "system", payload });
6896
+ this.startTimer();
6897
+ }
6898
+ recordLoopExhausted(payload) {
6899
+ this.enqueue({ kind: "loop_exhausted", source: "system", payload });
6900
+ this.startTimer();
6901
+ }
6710
6902
  record(body) {
6711
6903
  this.enqueue(body);
6712
6904
  this.startTimer();
@@ -6789,7 +6981,7 @@ async function buildPrompt(enriched, branchName, worktreePath, client, workspace
6789
6981
  variant: "execute",
6790
6982
  customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6791
6983
  Do NOT push to main. All your work stays on \`${branchName}\`.
6792
- When finished, call harmony_end_agent_session with status="completed".`
6984
+ 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.`
6793
6985
  });
6794
6986
  log.info(TAG27, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6795
6987
  return result.prompt + pastEpisodesSection;
@@ -6802,7 +6994,7 @@ When finished, call harmony_end_agent_session with status="completed".`
6802
6994
  }
6803
6995
  async function renderCommentsSection(client, cardId) {
6804
6996
  try {
6805
- const { comments } = await client.getComments(cardId, { limit: 200 });
6997
+ const { comments } = await client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc`);
6806
6998
  if (!Array.isArray(comments) || comments.length === 0)
6807
6999
  return "";
6808
7000
  const section = serializeCommentThread(comments, {
@@ -6899,7 +7091,7 @@ ${subtaskStr}
6899
7091
  Include a brief currentTask description.
6900
7092
  3. Implement the changes on branch \`${branchName}\`
6901
7093
  4. Commit your work with clear, descriptive commit messages
6902
- 5. When finished, call harmony_end_agent_session with status="completed"
7094
+ 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.
6903
7095
 
6904
7096
  You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6905
7097
  Do NOT push to main. All your work stays on \`${branchName}\`.`;
@@ -6934,6 +7126,121 @@ async function resolveStageColumnName(client, card, stage) {
6934
7126
  async function persistStagePointer(client, card, body) {
6935
7127
  await client.request("POST", `/cards/${encodeURIComponent(card.id)}/advance-stage`, body);
6936
7128
  }
7129
+ async function advanceStageRun(card, stage, stageIndex, def, evaluation, deps) {
7130
+ const loop = getStageLoop(stage);
7131
+ if (!isConvergeLoop(loop)) {
7132
+ if (!evaluation)
7133
+ return { kind: "no_advance" };
7134
+ return advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps);
7135
+ }
7136
+ return advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps);
7137
+ }
7138
+ function firstErrorMessage(evaluation) {
7139
+ const e = evaluation?.findings.find((f) => f.level === "error");
7140
+ return e ? e.message : null;
7141
+ }
7142
+ async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7143
+ const hasExitGate = resolveLoopExitGate(stage, loop) != null;
7144
+ const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7145
+ const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7146
+ const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
7147
+ const iteration = await deps.stateStore.incrementLoopIteration(card.id, stage.id);
7148
+ const decision = decideLoopContinuation({
7149
+ loop,
7150
+ gatePassed,
7151
+ hasExitGate,
7152
+ completedIterations: iteration
7153
+ });
7154
+ const evidence = evaluation ? evaluation.findings.map((f) => `[${f.level}] ${f.message}`).join(`
7155
+ `) : undefined;
7156
+ const verdictGloss = !hasExitGate ? "count-only" : gatePassed ? "gate passed" : firstErrorMessage(evaluation) ?? "gate unmet";
7157
+ const summary = `iteration ${iteration}/${maxIterations} — ${verdictGloss}${decision === "iterate" ? " → retrying" : ""}`;
7158
+ deps.sink?.recordLoopIterationEvaluated?.({
7159
+ stageId: stage.id,
7160
+ iteration,
7161
+ maxIterations,
7162
+ gateResult,
7163
+ evidence,
7164
+ summary
7165
+ });
7166
+ log.info(TAG28, `#${card.short_id} converge loop "${stage.name}": ${summary} → ${decision}`);
7167
+ if (decision === "exit") {
7168
+ await deps.stateStore.resetLoopIterations(card.id).catch(() => {});
7169
+ deps.sink?.recordLoopCompleted?.({
7170
+ stageId: stage.id,
7171
+ iterations: iteration,
7172
+ maxIterations,
7173
+ reason: hasExitGate ? "exit gate passed" : `count-only loop completed ${iteration} iteration(s)`
7174
+ });
7175
+ const exitEval = evaluation?.passed ? evaluation : {
7176
+ passed: true,
7177
+ findings: [
7178
+ {
7179
+ level: "info",
7180
+ message: `Converge loop "${stage.name}" complete after ${iteration} iteration(s).`
7181
+ }
7182
+ ],
7183
+ structured: evaluation?.structured ?? {}
7184
+ };
7185
+ return advanceStageOnGate(card, stage, stageIndex, def, exitEval, deps);
7186
+ }
7187
+ if (decision === "exhausted") {
7188
+ await deps.stateStore.resetLoopIterations(card.id).catch(() => {});
7189
+ await deps.stateStore.decrementAttempt(card.id).catch(() => {});
7190
+ const reason = `Converge loop "${stage.name}" exhausted its ${maxIterations}-iteration budget with its exit gate still unmet — ${firstErrorMessage(evaluation) ?? "gate unmet"}. Holding for a human.`;
7191
+ deps.sink?.recordLoopExhausted?.({
7192
+ stageId: stage.id,
7193
+ iterations: iteration,
7194
+ maxIterations,
7195
+ reason
7196
+ });
7197
+ try {
7198
+ await deps.client.updateAgentProgress(card.id, {
7199
+ agentIdentifier: "claude-code-stage",
7200
+ agentName: "Harmony Agent",
7201
+ status: "waiting",
7202
+ currentTask: reason
7203
+ });
7204
+ } catch {}
7205
+ await holdForHuman(deps.client, card, reason, deps.runId, deps.stateStore, {
7206
+ keepAttempts: true
7207
+ });
7208
+ log.info(TAG28, `#${card.short_id} LoopExhausted: ${reason}`);
7209
+ return { kind: "held_gate_unmet", reason };
7210
+ }
7211
+ await deps.stateStore.decrementAttempt(card.id).catch(() => {});
7212
+ await writeIterationHandoff(card, stage, iteration, maxIterations, evaluation, deps);
7213
+ const toColumn = await resolveStageColumnName(deps.client, card, stage) ?? deps.fallbackColumn;
7214
+ try {
7215
+ await deps.client.addComment(card.id, `Converge loop — ${summary}. Re-running "${stage.name}".`, { commentType: "progress" });
7216
+ } catch {}
7217
+ await runTransition(deps.client, card, {
7218
+ move: { columnName: toColumn },
7219
+ addLabels: [{ name: AGENT_LABEL }],
7220
+ ...isAgentRunnableOwner(stage.owner) ? { assignAgent: deps.agentId } : {}
7221
+ }, { store: deps.stateStore, runId: deps.runId });
7222
+ log.info(TAG28, `#${card.short_id} converge loop "${stage.name}" — requeued to "${toColumn}" for iteration ${iteration + 1}/${maxIterations}`);
7223
+ return { kind: "requeued_gate_unmet", toColumn };
7224
+ }
7225
+ async function writeIterationHandoff(card, stage, iteration, maxIterations, evaluation, deps) {
7226
+ try {
7227
+ const findings = evaluation?.findings.filter((f) => f.level !== "info") ?? [];
7228
+ const produced = findings.length > 0 ? `Iteration ${iteration}/${maxIterations} of the "${stage.name}" converge loop did not pass its exit gate. Findings:
7229
+ ${findings.map((f) => `- [${f.level}] ${f.message}`).join(`
7230
+ `)}` : `Iteration ${iteration}/${maxIterations} of the "${stage.name}" converge loop completed; the loop continues.`;
7231
+ const body = buildHandoffCommentBody({
7232
+ stageId: stage.id,
7233
+ stageName: stage.name,
7234
+ artifactType: stage.artifact_type,
7235
+ produced,
7236
+ decisions: [],
7237
+ nextStageNeeds: "Address the findings above on the same branch and re-run; this stage repeats until its exit gate passes."
7238
+ });
7239
+ await deps.client.addComment(card.id, body, { commentType: "decision" });
7240
+ } catch (err) {
7241
+ log.warn(TAG28, `iteration-handoff write failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
7242
+ }
7243
+ }
6937
7244
  async function advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps) {
6938
7245
  const summary = gateSummary(stage, evaluation);
6939
7246
  deps.sink?.recordStageGateEvaluated({
@@ -7086,7 +7393,7 @@ function buildStagePreamble(stage) {
7086
7393
  lines.push(`Hand off when done: ${summary.trim()}`);
7087
7394
  }
7088
7395
  }
7089
- lines.push("Do only this stage's work. When the stage's handoff is met, end your session advancement to the next stage is handled by the board.");
7396
+ lines.push("Do only this stage's work, then stop. The daemon owns the run lifecycle here: it ends the agent session and performs every card move and stage advancement automatically once your stage work is done. 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 stage tells you to move the card (e.g. to Review) or end your session as a final step, SKIP that step — it is handled for you. Those tools are disabled for this run, so attempting them only wastes turns. Finish the stage's work and stop.");
7090
7397
  if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7091
7398
  lines.push("", "**This stage's gate is `review_passed` — your deliverable is a review verdict, not a prose handoff.** Do NOT end the session until you have actually reviewed the change and emitted EXACTLY one JSON block as the LAST thing you output (nothing after it):", "```json", REVIEW_VERDICT_SCHEMA, "```", "Decision rules:", REVIEW_DECISION_RULES, "The board reads this verdict to decide advancement: `approved` advances the card, `rejected` sends it back. A missing or unparseable verdict blocks the card. Do NOT modify any code — this is a read-only review.");
7092
7399
  }
@@ -7099,6 +7406,13 @@ function buildSteeringPrompt(messages) {
7099
7406
  return messages.map((m, i) => `${i + 1}. ${m}`).join(`
7100
7407
  `);
7101
7408
  }
7409
+ function computeRunSpawnGating(stageAllowedTools) {
7410
+ const denylist = stageDisallowedTools();
7411
+ return {
7412
+ ...stageAllowedTools ? { allowedTools: stageAllowedTools } : {},
7413
+ ...denylist ? { disallowedTools: denylist } : {}
7414
+ };
7415
+ }
7102
7416
 
7103
7417
  class Worker {
7104
7418
  config;
@@ -7127,6 +7441,7 @@ class Worker {
7127
7441
  timedOut = false;
7128
7442
  verificationFailed = false;
7129
7443
  held = false;
7444
+ activeRunSpawnOpts = null;
7130
7445
  completionStarted = false;
7131
7446
  sessionId = null;
7132
7447
  runId = null;
@@ -7206,6 +7521,7 @@ class Worker {
7206
7521
  this.lastRunText = "";
7207
7522
  this.cliSessionId = null;
7208
7523
  this.lastDrainedSeq = 0;
7524
+ this.activeRunSpawnOpts = null;
7209
7525
  this.cardId = card.id;
7210
7526
  this.startedAt = Date.now();
7211
7527
  this.runId = newRunId();
@@ -7292,7 +7608,9 @@ class Worker {
7292
7608
  const basePrompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
7293
7609
  let prompt = basePrompt;
7294
7610
  if (stageCtx.kind === "run") {
7295
- const inherited = await this.loadInheritedHandoffSection(card.id, stageCtx.stage.id);
7611
+ const loop = getStageLoop(stageCtx.stage);
7612
+ const isLoop = isConvergeLoop(loop);
7613
+ const inherited = await this.loadInheritedHandoffSection(card.id, stageCtx.stage.id, { includeOwnStage: isLoop });
7296
7614
  prompt = [buildStagePreamble(stageCtx.stage), inherited, basePrompt].filter(Boolean).join(`
7297
7615
 
7298
7616
  `);
@@ -7301,6 +7619,16 @@ class Worker {
7301
7619
  stageName: stageCtx.stage.name,
7302
7620
  owner: stageCtx.stage.owner
7303
7621
  });
7622
+ if (isLoop && loop) {
7623
+ const priorIterations = this.stateStore.getLoopIterations(card.id, stageCtx.stage.id);
7624
+ this.cliRunner?.recordLoopIterationStarted({
7625
+ stageId: stageCtx.stage.id,
7626
+ stageName: stageCtx.stage.name,
7627
+ iteration: priorIterations + 1,
7628
+ maxIterations: Math.max(1, Math.floor(loop.max_iterations) || 1),
7629
+ mode: loop.mode
7630
+ });
7631
+ }
7304
7632
  }
7305
7633
  await this.client.updateAgentProgress(card.id, {
7306
7634
  agentIdentifier: agentIdentifier(this.id),
@@ -7314,9 +7642,10 @@ class Worker {
7314
7642
  this.timedOut = true;
7315
7643
  this.cancel();
7316
7644
  }, this.config.maxTimeout);
7645
+ this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
7317
7646
  await this.spawnClaude(prompt, card, subtasks, {
7318
7647
  model: this.selectImplementModel(card),
7319
- ...stageCtx.kind === "run" ? { allowedTools: stageCtx.allowedTools } : {}
7648
+ ...this.activeRunSpawnOpts ?? {}
7320
7649
  });
7321
7650
  if (this.aborted)
7322
7651
  return;
@@ -7391,7 +7720,7 @@ class Worker {
7391
7720
  }
7392
7721
  if (this.worktreePath) {
7393
7722
  try {
7394
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7723
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7395
7724
  } catch {
7396
7725
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7397
7726
  }
@@ -7435,7 +7764,7 @@ class Worker {
7435
7764
  } else if (this.runId && this.timedOut) {
7436
7765
  if (this.worktreePath) {
7437
7766
  try {
7438
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7767
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7439
7768
  } catch {
7440
7769
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7441
7770
  }
@@ -7462,15 +7791,19 @@ class Worker {
7462
7791
  } catch {}
7463
7792
  await this.recordOutcome(card.id, "failure");
7464
7793
  } else if (this.runId && this.aborted) {
7465
- try {
7466
- await this.client.updateCard(card.id, { assignedAgentId: null });
7467
- } catch (err) {
7468
- log.warn(this.tag, `failed to release card after stop: ${err instanceof Error ? err.message : err}`);
7469
- }
7470
- try {
7471
- await runTransition(this.client, card, { removeLabels: ["agent"] });
7472
- } catch (tErr) {
7473
- log.warn(this.tag, `stop label cleanup failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
7794
+ if (!this.completionStarted) {
7795
+ try {
7796
+ await this.client.updateCard(card.id, { assignedAgentId: null });
7797
+ } catch (err) {
7798
+ log.warn(this.tag, `failed to release card after stop: ${err instanceof Error ? err.message : err}`);
7799
+ }
7800
+ try {
7801
+ await runTransition(this.client, card, { removeLabels: ["agent"] });
7802
+ } catch (tErr) {
7803
+ log.warn(this.tag, `stop label cleanup failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
7804
+ }
7805
+ } else {
7806
+ log.info(this.tag, `cancel arrived after completion on #${card.short_id} — keeping assignment so review picks it up (#585)`);
7474
7807
  }
7475
7808
  try {
7476
7809
  await this.stateStore.endRun(this.runId, "paused", {
@@ -7521,7 +7854,7 @@ class Worker {
7521
7854
  await this.cliRunner.flushFinal();
7522
7855
  } catch {}
7523
7856
  }
7524
- this.cleanup();
7857
+ await this.cleanup();
7525
7858
  this.state = "idle";
7526
7859
  this.onDone(this);
7527
7860
  }
@@ -7612,15 +7945,13 @@ class Worker {
7612
7945
  } catch {}
7613
7946
  }
7614
7947
  }
7615
- async loadInheritedHandoffSection(cardId, currentStageId) {
7948
+ async loadInheritedHandoffSection(cardId, currentStageId, opts = {}) {
7616
7949
  try {
7617
- const { comments } = await this.client.getComments(cardId, {
7618
- limit: 200
7619
- });
7950
+ const { comments } = await this.client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc&comment_type=decision`);
7620
7951
  if (!Array.isArray(comments) || comments.length === 0)
7621
7952
  return "";
7622
7953
  const handoff = extractLatestHandoff(comments, {
7623
- excludeStageId: currentStageId
7954
+ excludeStageId: opts.includeOwnStage ? undefined : currentStageId
7624
7955
  });
7625
7956
  return handoff ? renderInheritedHandoffSection(handoff) : "";
7626
7957
  } catch (err) {
@@ -7648,7 +7979,9 @@ class Worker {
7648
7979
  }
7649
7980
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7650
7981
  try {
7651
- const gate = normalizeGateSpec(stage.gate);
7982
+ const loop = getStageLoop(stage);
7983
+ const gateSource = isConvergeLoop(loop) && loop ? resolveLoopExitGate(stage, loop) : stage.gate;
7984
+ const gate = normalizeGateSpec(gateSource);
7652
7985
  if (!gate) {
7653
7986
  return null;
7654
7987
  }
@@ -7691,11 +8024,8 @@ class Worker {
7691
8024
  }
7692
8025
  }
7693
8026
  async advanceFromGateEvaluation(card, stage, stageIndex, def, evaluation) {
7694
- if (!evaluation) {
7695
- return { kind: "no_advance" };
7696
- }
7697
8027
  try {
7698
- return await advanceStageOnGate(card, stage, stageIndex, def, evaluation, {
8028
+ return await advanceStageRun(card, stage, stageIndex, def, evaluation, {
7699
8029
  client: this.client,
7700
8030
  stateStore: this.stateStore,
7701
8031
  agentId: this.agentId,
@@ -7948,7 +8278,8 @@ class Worker {
7948
8278
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => m.text)), card, subtasks, {
7949
8279
  model: this.selectImplementModel(card),
7950
8280
  maxTurns: STEERING_MAX_TURNS,
7951
- resumeSessionId: this.cliSessionId
8281
+ resumeSessionId: this.cliSessionId,
8282
+ ...this.activeRunSpawnOpts ?? {}
7952
8283
  });
7953
8284
  } catch (err) {
7954
8285
  log.warn(this.tag, `Steering resume failed (non-fatal): ${err instanceof Error ? err.message : err}`);
@@ -7976,6 +8307,7 @@ class Worker {
7976
8307
  String(maxTurns),
7977
8308
  "--allowedTools",
7978
8309
  allowedTools,
8310
+ ...opts.disallowedTools ? ["--disallowedTools", opts.disallowedTools] : [],
7979
8311
  ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
7980
8312
  ...this.config.claude.additionalArgs,
7981
8313
  "--",
@@ -8063,6 +8395,7 @@ class Worker {
8063
8395
  const model = opts.model ?? this.config.claude.model;
8064
8396
  const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
8065
8397
  const allowedTools = (opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS).split(",").map((t) => t.trim()).filter(Boolean);
8398
+ const disallowedTools = opts.disallowedTools ? opts.disallowedTools.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
8066
8399
  const initialPhase = opts.initialPhase ?? "exploring";
8067
8400
  const sdkCfg = this.config.sdk;
8068
8401
  log.info(this.tag, `Spawning Agent SDK runner (model=${model}, maxTurns=${maxTurns}${opts.resumeSessionId ? ", resume" : ""})`);
@@ -8082,6 +8415,7 @@ class Worker {
8082
8415
  model,
8083
8416
  maxTurns,
8084
8417
  allowedTools,
8418
+ ...disallowedTools ? { disallowedTools } : {},
8085
8419
  maxBudgetUsd: sdkCfg?.maxBudgetUsd,
8086
8420
  settingSources: sdkCfg?.settingSources,
8087
8421
  mcpServers: sdkCfg?.mcpServers,
@@ -8148,7 +8482,7 @@ class Worker {
8148
8482
  throw err;
8149
8483
  }
8150
8484
  }
8151
- cleanup() {
8485
+ async cleanup() {
8152
8486
  if (this.progressTracker) {
8153
8487
  this.progressTracker.stop();
8154
8488
  this.progressTracker = null;
@@ -8166,7 +8500,7 @@ class Worker {
8166
8500
  }
8167
8501
  if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8168
8502
  try {
8169
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
8503
+ await teardownWorktree(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8170
8504
  } catch {
8171
8505
  log.warn(this.tag, "Failed to cleanup worktree");
8172
8506
  }
@@ -8647,7 +8981,7 @@ async function recoverRun(run, store, client, config, outcome) {
8647
8981
  }
8648
8982
  if (run.worktreePath) {
8649
8983
  try {
8650
- cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
8984
+ await teardownWorktree(client, run.cardId, run.worktreePath, run.branchName ?? undefined);
8651
8985
  outcome.actions.push("cleaned up worktree");
8652
8986
  } catch (err) {
8653
8987
  const msg = err instanceof Error ? err.message : String(err);
@@ -8671,6 +9005,71 @@ var init_recovery = __esm(() => {
8671
9005
  init_worktree();
8672
9006
  });
8673
9007
 
9008
+ // src/strand-recovery.ts
9009
+ var exports_strand_recovery = {};
9010
+ __export(exports_strand_recovery, {
9011
+ reclaimPreReviewStrands: () => reclaimPreReviewStrands
9012
+ });
9013
+ async function reclaimPreReviewStrands(opts) {
9014
+ const {
9015
+ client,
9016
+ agentId,
9017
+ cards,
9018
+ columns,
9019
+ labelMap,
9020
+ reviewColumns,
9021
+ approvedLabel,
9022
+ graceMs,
9023
+ knownCardIds,
9024
+ cwd,
9025
+ provider
9026
+ } = opts;
9027
+ const now = opts.now ?? Date.now();
9028
+ const maxPerSweep = opts.maxPerSweep ?? 5;
9029
+ const reviewColIds = new Set(columns.filter((c) => reviewColumns.some((n) => n.toLowerCase() === c.name.toLowerCase())).map((c) => c.id));
9030
+ if (reviewColIds.size === 0)
9031
+ return [];
9032
+ const reclaimed = [];
9033
+ let checked = 0;
9034
+ for (const card of cards) {
9035
+ if (checked >= maxPerSweep)
9036
+ break;
9037
+ if (card.archived_at || !reviewColIds.has(card.column_id) || card.assigned_agent_id != null || card.assignee_id != null || knownCardIds.has(card.id)) {
9038
+ continue;
9039
+ }
9040
+ const branch = extractBranchFromDescription(card.description);
9041
+ if (!branch)
9042
+ continue;
9043
+ const labels = resolveCardLabels(card, labelMap);
9044
+ if (hasLabel(labels, approvedLabel) || hasLabel(labels, NEED_REVIEW_LABEL)) {
9045
+ continue;
9046
+ }
9047
+ const stalledAt = Date.parse(card.updated_at ?? "");
9048
+ if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
9049
+ continue;
9050
+ checked++;
9051
+ const prUrl = resolvePrUrl(card.description ?? null, branch, cwd, provider);
9052
+ if (prUrl)
9053
+ continue;
9054
+ log.warn(TAG33, `#${card.short_id} stranded in review (branch pushed, no PR, unowned) — re-asserting daemon assignment`);
9055
+ try {
9056
+ await client.updateCard(card.id, { assignedAgentId: agentId });
9057
+ reclaimed.push(card.id);
9058
+ } catch (err) {
9059
+ log.error(TAG33, `review re-claim failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9060
+ }
9061
+ }
9062
+ return reclaimed;
9063
+ }
9064
+ var TAG33 = "strand-recovery";
9065
+ var init_strand_recovery = __esm(() => {
9066
+ init_board_helpers();
9067
+ init_git_pr();
9068
+ init_log();
9069
+ init_review_worktree();
9070
+ init_types();
9071
+ });
9072
+
8674
9073
  // src/reconcile.ts
8675
9074
  class Reconciler {
8676
9075
  client;
@@ -8685,6 +9084,7 @@ class Reconciler {
8685
9084
  agentConfig;
8686
9085
  timer = null;
8687
9086
  lastTickAt = null;
9087
+ gitProvider = null;
8688
9088
  get lastTick() {
8689
9089
  return this.lastTickAt;
8690
9090
  }
@@ -8712,7 +9112,7 @@ class Reconciler {
8712
9112
  clearInterval(this.timer);
8713
9113
  this.timer = null;
8714
9114
  }
8715
- log.info(TAG33, "Heartbeat stopped");
9115
+ log.info(TAG34, "Heartbeat stopped");
8716
9116
  }
8717
9117
  async recoverStaleRuns() {
8718
9118
  if (!this.stateStore || !this.agentConfig)
@@ -8729,7 +9129,7 @@ class Reconciler {
8729
9129
  if (!daemonDead && !(heartbeatStale && ourZombie))
8730
9130
  continue;
8731
9131
  const reason = daemonDead ? `foreign daemon ${run.daemonPid} is dead` : `our worker lost card ${run.cardId} with ${Math.round((now - run.lastHeartbeatAt) / 1000)}s stale heartbeat`;
8732
- log.warn(TAG33, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
9132
+ log.warn(TAG34, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
8733
9133
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
8734
9134
  runId: run.runId,
8735
9135
  cardId: run.cardId,
@@ -8756,13 +9156,37 @@ class Reconciler {
8756
9156
  const stalledAt = Date.parse(card.updated_at ?? "");
8757
9157
  if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
8758
9158
  continue;
8759
- log.warn(TAG33, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
9159
+ log.warn(TAG34, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
8760
9160
  try {
8761
9161
  await this.client.moveCard(card.id, pickupCol.id);
8762
9162
  } catch (err) {
8763
- log.error(TAG33, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9163
+ log.error(TAG34, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9164
+ }
9165
+ }
9166
+ }
9167
+ async recoverStrandedReview(cards, columns, labelMap, knownCardIds) {
9168
+ if (this.reviewColumns.length === 0)
9169
+ return;
9170
+ if (!this.gitProvider) {
9171
+ try {
9172
+ this.gitProvider = detectGitProvider();
9173
+ } catch {
9174
+ return;
8764
9175
  }
8765
9176
  }
9177
+ await reclaimPreReviewStrands({
9178
+ client: this.client,
9179
+ agentId: this.agentId,
9180
+ cards,
9181
+ columns,
9182
+ labelMap,
9183
+ reviewColumns: this.reviewColumns,
9184
+ approvedLabel: this.approvedLabel,
9185
+ graceMs: this.agentConfig?.timing.staleHeartbeatMs ?? 120000,
9186
+ knownCardIds,
9187
+ cwd: process.cwd(),
9188
+ provider: this.gitProvider
9189
+ });
8766
9190
  }
8767
9191
  async releaseStalledApprovals(cards, columns, knownCardIds) {
8768
9192
  const planning = this.agentConfig?.planning;
@@ -8783,11 +9207,11 @@ class Reconciler {
8783
9207
  const parkedAt = Date.parse(card.updated_at ?? "");
8784
9208
  if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
8785
9209
  continue;
8786
- log.warn(TAG33, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
9210
+ log.warn(TAG34, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
8787
9211
  try {
8788
9212
  await this.client.moveCard(card.id, pickupCol.id);
8789
9213
  } catch (err) {
8790
- log.error(TAG33, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9214
+ log.error(TAG34, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
8791
9215
  }
8792
9216
  }
8793
9217
  }
@@ -8830,21 +9254,21 @@ class Reconciler {
8830
9254
  const subtasks = card.subtasks ?? [];
8831
9255
  const mode = route.mode;
8832
9256
  if (route.stage) {
8833
- log.info(TAG33, `Stage card #${card.short_id} (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement) regardless of column`);
9257
+ log.info(TAG34, `Stage card #${card.short_id} (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement) regardless of column`);
8834
9258
  }
8835
9259
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
8836
- log.debug(TAG33, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
9260
+ log.debug(TAG34, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
8837
9261
  continue;
8838
9262
  }
8839
9263
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
8840
- log.debug(TAG33, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
9264
+ log.debug(TAG34, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
8841
9265
  continue;
8842
9266
  }
8843
9267
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
8844
- log.debug(TAG33, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
9268
+ log.debug(TAG34, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
8845
9269
  continue;
8846
9270
  }
8847
- log.info(TAG33, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
9271
+ log.info(TAG34, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
8848
9272
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
8849
9273
  }
8850
9274
  }
@@ -8852,25 +9276,28 @@ class Reconciler {
8852
9276
  await this.recoverStaleRuns();
8853
9277
  }
8854
9278
  await this.recoverStrandedInProgress(cards, columns, knownCardIds);
9279
+ await this.recoverStrandedReview(cards, columns, labelMap, knownCardIds);
8855
9280
  for (const knownId of knownCardIds) {
8856
9281
  if (!allAgentCardIds.has(knownId)) {
8857
- log.info(TAG33, `Missed unassign: ${knownId} — removing`);
9282
+ log.info(TAG34, `Missed unassign: ${knownId} — removing`);
8858
9283
  await this.pool.removeCard(knownId);
8859
9284
  }
8860
9285
  }
8861
9286
  await this.releaseStalledApprovals(cards, columns, knownCardIds);
8862
- log.debug(TAG33, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
9287
+ log.debug(TAG34, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
8863
9288
  } catch (err) {
8864
- log.error(TAG33, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
9289
+ log.error(TAG34, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
8865
9290
  }
8866
9291
  }
8867
9292
  }
8868
- var TAG33 = "reconcile";
9293
+ var TAG34 = "reconcile";
8869
9294
  var init_reconcile = __esm(() => {
8870
9295
  init_board_helpers();
9296
+ init_git_pr();
8871
9297
  init_log();
8872
9298
  init_recovery();
8873
9299
  init_review_worktree();
9300
+ init_strand_recovery();
8874
9301
  init_types();
8875
9302
  });
8876
9303
 
@@ -8903,7 +9330,7 @@ function prettyBanner(config, version) {
8903
9330
  checks.push({ kind: "ok", message });
8904
9331
  },
8905
9332
  warn(message) {
8906
- log.warn(TAG34, message);
9333
+ log.warn(TAG35, message);
8907
9334
  checks.push({ kind: "warn", message: message.split(`
8908
9335
  `, 1)[0] });
8909
9336
  },
@@ -8928,25 +9355,25 @@ function prettyBanner(config, version) {
8928
9355
  };
8929
9356
  }
8930
9357
  function jsonBanner(config, version) {
8931
- log.info(TAG34, `Harmony Agent Daemon v${version} starting...`);
8932
- log.info(TAG34, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
9358
+ log.info(TAG35, `Harmony Agent Daemon v${version} starting...`);
9359
+ log.info(TAG35, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
8933
9360
  if (config.agent.review.enabled) {
8934
- log.info(TAG34, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
9361
+ log.info(TAG35, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
8935
9362
  }
8936
9363
  let failed = false;
8937
9364
  return {
8938
9365
  setProjectName(_name) {},
8939
9366
  setGitProvider(provider) {
8940
- log.info(TAG34, `Git provider: ${provider}`);
9367
+ log.info(TAG35, `Git provider: ${provider}`);
8941
9368
  },
8942
9369
  setHttpPort(port) {
8943
- log.info(TAG34, `HTTP server on port ${port}`);
9370
+ log.info(TAG35, `HTTP server on port ${port}`);
8944
9371
  },
8945
9372
  check(message) {
8946
- log.info(TAG34, message);
9373
+ log.info(TAG35, message);
8947
9374
  },
8948
9375
  warn(message) {
8949
- log.warn(TAG34, message);
9376
+ log.warn(TAG35, message);
8950
9377
  },
8951
9378
  fail() {
8952
9379
  failed = true;
@@ -8954,7 +9381,7 @@ function jsonBanner(config, version) {
8954
9381
  async ready(message) {
8955
9382
  if (failed)
8956
9383
  return;
8957
- log.info(TAG34, message);
9384
+ log.info(TAG35, message);
8958
9385
  }
8959
9386
  };
8960
9387
  }
@@ -9035,7 +9462,7 @@ function cyan(s) {
9035
9462
  function yellow(s) {
9036
9463
  return `${ANSI.yellow}${s}${ANSI.reset}`;
9037
9464
  }
9038
- var TAG34 = "daemon", RULE_WIDTH = 70, ANSI;
9465
+ var TAG35 = "daemon", RULE_WIDTH = 70, ANSI;
9039
9466
  var init_startup_banner = __esm(() => {
9040
9467
  init_log();
9041
9468
  ANSI = {
@@ -9186,13 +9613,13 @@ class Watcher {
9186
9613
  }
9187
9614
  async start() {
9188
9615
  if (!isPretty()) {
9189
- log.info(TAG35, "Connecting to Supabase realtime (broadcast)...");
9616
+ log.info(TAG36, "Connecting to Supabase realtime (broadcast)...");
9190
9617
  }
9191
9618
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
9192
9619
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
9193
9620
  this.subscribeBroadcast();
9194
9621
  presenceChannel.on("presence", { event: "sync" }, () => {
9195
- log.debug(TAG35, "Presence sync");
9622
+ log.debug(TAG36, "Presence sync");
9196
9623
  }).subscribe(async (status) => {
9197
9624
  if (status === "SUBSCRIBED") {
9198
9625
  await presenceChannel.track({
@@ -9205,7 +9632,7 @@ class Watcher {
9205
9632
  agentName: this.identity.agentName
9206
9633
  });
9207
9634
  if (!isPretty() || !this.suppressStartupLogs) {
9208
- log.info(TAG35, "Presence tracked on board-presence channel");
9635
+ log.info(TAG36, "Presence tracked on board-presence channel");
9209
9636
  }
9210
9637
  this.presenceTracked = true;
9211
9638
  this.maybeResolveReady();
@@ -9218,13 +9645,13 @@ class Watcher {
9218
9645
  return;
9219
9646
  const gen = ++this.broadcastGen;
9220
9647
  this.channel = this.supabase.channel(`board-${this.projectId}`).on("broadcast", { event: "card_update" }, (msg) => {
9221
- log.debug(TAG35, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
9648
+ log.debug(TAG36, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
9222
9649
  this.onCardBroadcast({
9223
9650
  event: "card_update",
9224
9651
  payload: msg.payload ?? {}
9225
9652
  });
9226
9653
  }).on("broadcast", { event: "card_created" }, (msg) => {
9227
- log.debug(TAG35, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9654
+ log.debug(TAG36, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9228
9655
  this.onCardBroadcast({
9229
9656
  event: "card_created",
9230
9657
  payload: msg.payload ?? {}
@@ -9234,7 +9661,7 @@ class Watcher {
9234
9661
  const cardId = payload.card_id;
9235
9662
  const command = payload.command;
9236
9663
  if (cardId && command) {
9237
- log.info(TAG35, `Broadcast: agent_command ${command} for ${cardId}`);
9664
+ log.info(TAG36, `Broadcast: agent_command ${command} for ${cardId}`);
9238
9665
  this.onAgentCommand?.({ cardId, command });
9239
9666
  }
9240
9667
  }).subscribe((status) => {
@@ -9244,13 +9671,13 @@ class Watcher {
9244
9671
  this.connected = true;
9245
9672
  this.reconnectAttempts = 0;
9246
9673
  if (!isPretty() || !this.suppressStartupLogs) {
9247
- log.info(TAG35, "Broadcast subscription active");
9674
+ log.info(TAG36, "Broadcast subscription active");
9248
9675
  }
9249
9676
  this.maybeResolveReady();
9250
9677
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
9251
9678
  this.connected = false;
9252
9679
  if (!this.stopping) {
9253
- log.warn(TAG35, `Broadcast subscription ${status} — scheduling reconnect`);
9680
+ log.warn(TAG36, `Broadcast subscription ${status} — scheduling reconnect`);
9254
9681
  this.scheduleReconnect();
9255
9682
  }
9256
9683
  }
@@ -9269,7 +9696,7 @@ class Watcher {
9269
9696
  async reconnectBroadcast() {
9270
9697
  if (this.stopping || !this.supabase)
9271
9698
  return;
9272
- log.warn(TAG35, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9699
+ log.warn(TAG36, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9273
9700
  if (this.channel) {
9274
9701
  const old = this.channel;
9275
9702
  this.channel = null;
@@ -9299,10 +9726,10 @@ class Watcher {
9299
9726
  this.supabase = null;
9300
9727
  }
9301
9728
  this.connected = false;
9302
- log.info(TAG35, "Broadcast subscription stopped");
9729
+ log.info(TAG36, "Broadcast subscription stopped");
9303
9730
  }
9304
9731
  }
9305
- var TAG35 = "watcher";
9732
+ var TAG36 = "watcher";
9306
9733
  var init_watcher = __esm(() => {
9307
9734
  init_log();
9308
9735
  });
@@ -9389,10 +9816,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
9389
9816
  });
9390
9817
  } catch {}
9391
9818
  if (result.removed.length > 0) {
9392
- log.info(TAG36, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
9819
+ log.info(TAG37, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
9393
9820
  }
9394
9821
  if (result.errors.length > 0) {
9395
- log.warn(TAG36, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
9822
+ log.warn(TAG37, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
9396
9823
  }
9397
9824
  return result;
9398
9825
  }
@@ -9422,7 +9849,7 @@ function pruneFailedRemoteBranches(opts) {
9422
9849
  } catch (err) {
9423
9850
  const detail = gitErrorDetail2(err);
9424
9851
  if (isTransientGitNetworkError(detail)) {
9425
- log.debug(TAG36, `Remote branch GC skipped — remote unreachable: ${detail}`);
9852
+ log.debug(TAG37, `Remote branch GC skipped — remote unreachable: ${detail}`);
9426
9853
  return result;
9427
9854
  }
9428
9855
  result.errors.push({ ref: "fetch", error: detail });
@@ -9461,7 +9888,7 @@ function pruneFailedRemoteBranches(opts) {
9461
9888
  continue;
9462
9889
  }
9463
9890
  if (clock() > sweepDeadline) {
9464
- log.debug(TAG36, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
9891
+ log.debug(TAG37, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
9465
9892
  break;
9466
9893
  }
9467
9894
  try {
@@ -9474,17 +9901,17 @@ function pruneFailedRemoteBranches(opts) {
9474
9901
  } catch (err) {
9475
9902
  const detail = gitErrorDetail2(err);
9476
9903
  if (isTransientGitNetworkError(detail)) {
9477
- log.debug(TAG36, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9904
+ log.debug(TAG37, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9478
9905
  break;
9479
9906
  }
9480
9907
  result.errors.push({ ref, error: detail });
9481
9908
  }
9482
9909
  }
9483
9910
  if (result.removed.length > 0) {
9484
- log.info(TAG36, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
9911
+ log.info(TAG37, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
9485
9912
  }
9486
9913
  if (result.errors.length > 0) {
9487
- log.warn(TAG36, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
9914
+ log.warn(TAG37, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
9488
9915
  }
9489
9916
  return result;
9490
9917
  }
@@ -9515,13 +9942,13 @@ class WorktreeGc {
9515
9942
  try {
9516
9943
  runWorktreeGc(this.basePath, this.store);
9517
9944
  } catch (err) {
9518
- log.warn(TAG36, `GC tick failed: ${err instanceof Error ? err.message : err}`);
9945
+ log.warn(TAG37, `GC tick failed: ${err instanceof Error ? err.message : err}`);
9519
9946
  }
9520
9947
  if (this.remoteOpts) {
9521
9948
  try {
9522
9949
  pruneFailedRemoteBranches(this.remoteOpts);
9523
9950
  } catch (err) {
9524
- log.warn(TAG36, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
9951
+ log.warn(TAG37, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
9525
9952
  }
9526
9953
  }
9527
9954
  }
@@ -9535,7 +9962,7 @@ function getRepoRoot2() {
9535
9962
  return null;
9536
9963
  }
9537
9964
  }
9538
- var TAG36 = "worktree-gc", GIT_NETWORK_TIMEOUT_MS = 30000, GIT_SSH_CONNECT_TIMEOUT_SECS = 10, GIT_PRUNE_SWEEP_BUDGET_MS = 60000, GIT_NETWORK_EXEC, TRANSIENT_GIT_NETWORK_ERROR;
9965
+ var TAG37 = "worktree-gc", GIT_NETWORK_TIMEOUT_MS = 30000, GIT_SSH_CONNECT_TIMEOUT_SECS = 10, GIT_PRUNE_SWEEP_BUDGET_MS = 60000, GIT_NETWORK_EXEC, TRANSIENT_GIT_NETWORK_ERROR;
9539
9966
  var init_worktree_gc = __esm(() => {
9540
9967
  init_log();
9541
9968
  init_worktree();
@@ -9639,7 +10066,7 @@ async function main() {
9639
10066
  } catch (err) {
9640
10067
  if (err instanceof ConfigValidationError) {
9641
10068
  banner.fail();
9642
- log.error(TAG37, err.message);
10069
+ log.error(TAG38, err.message);
9643
10070
  process.exit(1);
9644
10071
  }
9645
10072
  throw err;
@@ -9749,7 +10176,7 @@ async function main() {
9749
10176
  if (shuttingDown)
9750
10177
  return;
9751
10178
  shuttingDown = true;
9752
- log.info(TAG37, `Received ${signal}, shutting down gracefully...`);
10179
+ log.info(TAG38, `Received ${signal}, shutting down gracefully...`);
9753
10180
  reconciler.stop();
9754
10181
  mergeMonitor?.stop();
9755
10182
  worktreeGc.stop();
@@ -9759,18 +10186,18 @@ async function main() {
9759
10186
  }
9760
10187
  await watcher.stop();
9761
10188
  await pool.shutdown();
9762
- log.info(TAG37, "Daemon stopped.");
10189
+ log.info(TAG38, "Daemon stopped.");
9763
10190
  process.exit(exitCode);
9764
10191
  };
9765
10192
  process.on("SIGINT", () => shutdown("SIGINT"));
9766
10193
  process.on("SIGTERM", () => shutdown("SIGTERM"));
9767
10194
  process.on("uncaughtException", (err) => {
9768
- log.error(TAG37, `Uncaught exception: ${err.message}`);
10195
+ log.error(TAG38, `Uncaught exception: ${err.message}`);
9769
10196
  exitCode = 1;
9770
10197
  shutdown("uncaughtException");
9771
10198
  });
9772
10199
  process.on("unhandledRejection", (reason) => {
9773
- log.error(TAG37, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
10200
+ log.error(TAG38, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
9774
10201
  exitCode = 1;
9775
10202
  shutdown("unhandledRejection");
9776
10203
  });
@@ -9823,29 +10250,29 @@ async function handleBroadcast(event, client, pool, config, agentId) {
9823
10250
  if (assignedAgentId === undefined)
9824
10251
  return;
9825
10252
  if (assignedAgentId === agentId) {
9826
- log.info(TAG37, `Broadcast: card ${cardId} assigned to agent`);
10253
+ log.info(TAG38, `Broadcast: card ${cardId} assigned to agent`);
9827
10254
  try {
9828
10255
  await pool.resetAttemptsForReassign(cardId);
9829
10256
  await tryEnqueueCard(cardId, client, pool, config, agentId);
9830
10257
  } catch (err) {
9831
- log.error(TAG37, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
10258
+ log.error(TAG38, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
9832
10259
  }
9833
10260
  } else if (pool.isCardKnown(cardId)) {
9834
- log.info(TAG37, `Broadcast: card ${cardId} unassigned from agent`);
10261
+ log.info(TAG38, `Broadcast: card ${cardId} unassigned from agent`);
9835
10262
  await pool.removeCard(cardId);
9836
10263
  }
9837
10264
  }
9838
10265
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9839
10266
  const { card } = await client.getCard(cardId);
9840
10267
  if (card.assigned_agent_id !== agentId) {
9841
- log.debug(TAG37, `Card ${cardId} no longer assigned to agent — skipping`);
10268
+ log.debug(TAG38, `Card ${cardId} no longer assigned to agent — skipping`);
9842
10269
  return;
9843
10270
  }
9844
10271
  const board = await client.getBoard(config.projectId, { summary: true });
9845
10272
  const columns = board.columns;
9846
10273
  const column = columns.find((c) => c.id === card.column_id);
9847
10274
  if (!column) {
9848
- log.warn(TAG37, `Column not found for card ${cardId}`);
10275
+ log.warn(TAG38, `Column not found for card ${cardId}`);
9849
10276
  return;
9850
10277
  }
9851
10278
  const route = classifyPickup(card, column.name, {
@@ -9854,27 +10281,27 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9854
10281
  playbooks: config.agent.playbooks
9855
10282
  });
9856
10283
  if (!route) {
9857
- log.info(TAG37, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
10284
+ log.info(TAG38, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
9858
10285
  return;
9859
10286
  }
9860
10287
  if (route.stage) {
9861
- log.info(TAG37, `Card #${card.short_id} is a playbook stage card (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement pool) regardless of column`);
10288
+ log.info(TAG38, `Card #${card.short_id} is a playbook stage card (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement pool) regardless of column`);
9862
10289
  }
9863
10290
  const mode = route.mode;
9864
10291
  const labelMap = buildLabelMap(board.labels ?? []);
9865
10292
  const cardLabels = resolveCardLabels(card, labelMap);
9866
10293
  const subtasks = card.subtasks ?? [];
9867
10294
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
9868
- log.debug(TAG37, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
10295
+ log.debug(TAG38, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
9869
10296
  return;
9870
10297
  }
9871
10298
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
9872
- log.info(TAG37, `Card #${card.short_id} has no branch reference — skipping auto-review`);
10299
+ log.info(TAG38, `Card #${card.short_id} has no branch reference — skipping auto-review`);
9873
10300
  return;
9874
10301
  }
9875
10302
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
9876
10303
  }
9877
- var TAG37 = "daemon", PKG_VERSION;
10304
+ var TAG38 = "daemon", PKG_VERSION;
9878
10305
  var init_src = __esm(() => {
9879
10306
  init_board_helpers();
9880
10307
  init_config();