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