@gethmy/agent 1.14.4 → 1.15.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 +484 -85
  2. package/dist/index.js +434 -85
  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,48 @@ 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 decideLoopContinuation(args) {
1670
+ const { loop, gatePassed, hasExitGate, completedIterations } = args;
1671
+ const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
1672
+ if (!hasExitGate) {
1673
+ return completedIterations >= max ? "exit" : "iterate";
1674
+ }
1675
+ if (gatePassed)
1676
+ return "exit";
1677
+ if (completedIterations >= max)
1678
+ return "exhausted";
1679
+ return "iterate";
1680
+ }
1605
1681
  function readStageDefs(def) {
1606
1682
  if (def.steps_version !== 2)
1607
1683
  return [];
@@ -1638,7 +1714,7 @@ function entryActionAllowlist(entryAction) {
1638
1714
  return `mcp__${entryAction}`;
1639
1715
  return null;
1640
1716
  }
1641
- var SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1717
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1642
1718
  var init_playbookStage = __esm(() => {
1643
1719
  SKILL_TOOL_ALLOWLIST = {
1644
1720
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -2187,6 +2263,10 @@ var init_review_worktree = __esm(() => {
2187
2263
  });
2188
2264
 
2189
2265
  // src/merge-monitor.ts
2266
+ var exports_merge_monitor = {};
2267
+ __export(exports_merge_monitor, {
2268
+ MergeMonitor: () => MergeMonitor
2269
+ });
2190
2270
  import { execFile as execFile2 } from "node:child_process";
2191
2271
  import { promisify as promisify2 } from "node:util";
2192
2272
 
@@ -2220,6 +2300,9 @@ class MergeMonitor {
2220
2300
  }
2221
2301
  log.info(TAG7, "Merge monitor stopped");
2222
2302
  }
2303
+ async runOnce() {
2304
+ await this.tick();
2305
+ }
2223
2306
  async scheduleNext(delayMs) {
2224
2307
  await new Promise((resolve3) => {
2225
2308
  this.timer = setTimeout(() => resolve3(), delayMs);
@@ -2257,9 +2340,10 @@ class MergeMonitor {
2257
2340
  const batch = candidatesWithLabels.slice(0, 5);
2258
2341
  log.debug(TAG7, `Checking ${batch.length} Ready to Merge card(s)`);
2259
2342
  const results = await Promise.allSettled(batch.map(async ({ card, labels }) => {
2260
- const prUrl = extractPrUrl(card.description ?? null);
2343
+ const branchName = extractBranchFromDescription(card.description);
2344
+ const prUrl = resolvePrUrl(card.description ?? null, branchName, this.cwd, this.provider);
2261
2345
  if (!prUrl) {
2262
- log.debug(TAG7, `#${card.short_id} has no PR URL — skipping`);
2346
+ log.debug(TAG7, `#${card.short_id} has no resolvable PR — skipping`);
2263
2347
  return;
2264
2348
  }
2265
2349
  const state = await checkPrMergeStatus(prUrl, this.cwd, this.provider);
@@ -5575,6 +5659,30 @@ class StateStore {
5575
5659
  rec.attempts = 0;
5576
5660
  await this.persist();
5577
5661
  }
5662
+ getLoopIterations(cardId, stageId) {
5663
+ const rec = this.getCard(cardId);
5664
+ if (!rec || rec.loopStageId !== stageId)
5665
+ return 0;
5666
+ return rec.loopIterations ?? 0;
5667
+ }
5668
+ async incrementLoopIteration(cardId, stageId) {
5669
+ const rec = this.ensureCard(cardId);
5670
+ if (rec.loopStageId !== stageId) {
5671
+ rec.loopStageId = stageId;
5672
+ rec.loopIterations = 0;
5673
+ }
5674
+ rec.loopIterations = (rec.loopIterations ?? 0) + 1;
5675
+ await this.persist();
5676
+ return rec.loopIterations;
5677
+ }
5678
+ async resetLoopIterations(cardId) {
5679
+ const rec = this.getCard(cardId);
5680
+ if (!rec || rec.loopIterations == null && rec.loopStageId == null)
5681
+ return;
5682
+ rec.loopStageId = null;
5683
+ rec.loopIterations = 0;
5684
+ await this.persist();
5685
+ }
5578
5686
  async resetAttempts(cardId) {
5579
5687
  const rec = this.getCard(cardId);
5580
5688
  if (!rec || rec.attempts === 0)
@@ -6708,6 +6816,26 @@ class CliAgentRunner {
6708
6816
  this.enqueue({ kind: "playbook_advanced", source: "system", payload });
6709
6817
  this.startTimer();
6710
6818
  }
6819
+ recordLoopIterationStarted(payload) {
6820
+ this.enqueue({ kind: "loop_iteration_started", source: "system", payload });
6821
+ this.startTimer();
6822
+ }
6823
+ recordLoopIterationEvaluated(payload) {
6824
+ this.enqueue({
6825
+ kind: "loop_iteration_evaluated",
6826
+ source: "system",
6827
+ payload: { ...payload, evidence: truncateOutput(payload.evidence) }
6828
+ });
6829
+ this.startTimer();
6830
+ }
6831
+ recordLoopCompleted(payload) {
6832
+ this.enqueue({ kind: "loop_completed", source: "system", payload });
6833
+ this.startTimer();
6834
+ }
6835
+ recordLoopExhausted(payload) {
6836
+ this.enqueue({ kind: "loop_exhausted", source: "system", payload });
6837
+ this.startTimer();
6838
+ }
6711
6839
  record(body) {
6712
6840
  this.enqueue(body);
6713
6841
  this.startTimer();
@@ -6803,7 +6931,7 @@ When finished, call harmony_end_agent_session with status="completed".`
6803
6931
  }
6804
6932
  async function renderCommentsSection(client, cardId) {
6805
6933
  try {
6806
- const { comments } = await client.getComments(cardId, { limit: 200 });
6934
+ const { comments } = await client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc`);
6807
6935
  if (!Array.isArray(comments) || comments.length === 0)
6808
6936
  return "";
6809
6937
  const section = serializeCommentThread(comments, {
@@ -6935,6 +7063,121 @@ async function resolveStageColumnName(client, card, stage) {
6935
7063
  async function persistStagePointer(client, card, body) {
6936
7064
  await client.request("POST", `/cards/${encodeURIComponent(card.id)}/advance-stage`, body);
6937
7065
  }
7066
+ async function advanceStageRun(card, stage, stageIndex, def, evaluation, deps) {
7067
+ const loop = getStageLoop(stage);
7068
+ if (!isConvergeLoop(loop)) {
7069
+ if (!evaluation)
7070
+ return { kind: "no_advance" };
7071
+ return advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps);
7072
+ }
7073
+ return advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps);
7074
+ }
7075
+ function firstErrorMessage(evaluation) {
7076
+ const e = evaluation?.findings.find((f) => f.level === "error");
7077
+ return e ? e.message : null;
7078
+ }
7079
+ async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7080
+ const hasExitGate = loop.exit_gate != null;
7081
+ const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7082
+ const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7083
+ const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
7084
+ const iteration = await deps.stateStore.incrementLoopIteration(card.id, stage.id);
7085
+ const decision = decideLoopContinuation({
7086
+ loop,
7087
+ gatePassed,
7088
+ hasExitGate,
7089
+ completedIterations: iteration
7090
+ });
7091
+ const evidence = evaluation ? evaluation.findings.map((f) => `[${f.level}] ${f.message}`).join(`
7092
+ `) : undefined;
7093
+ const verdictGloss = !hasExitGate ? "count-only" : gatePassed ? "gate passed" : firstErrorMessage(evaluation) ?? "gate unmet";
7094
+ const summary = `iteration ${iteration}/${maxIterations} — ${verdictGloss}${decision === "iterate" ? " → retrying" : ""}`;
7095
+ deps.sink?.recordLoopIterationEvaluated?.({
7096
+ stageId: stage.id,
7097
+ iteration,
7098
+ maxIterations,
7099
+ gateResult,
7100
+ evidence,
7101
+ summary
7102
+ });
7103
+ log.info(TAG28, `#${card.short_id} converge loop "${stage.name}": ${summary} → ${decision}`);
7104
+ if (decision === "exit") {
7105
+ await deps.stateStore.resetLoopIterations(card.id).catch(() => {});
7106
+ deps.sink?.recordLoopCompleted?.({
7107
+ stageId: stage.id,
7108
+ iterations: iteration,
7109
+ maxIterations,
7110
+ reason: hasExitGate ? "exit gate passed" : `count-only loop completed ${iteration} iteration(s)`
7111
+ });
7112
+ const exitEval = evaluation?.passed ? evaluation : {
7113
+ passed: true,
7114
+ findings: [
7115
+ {
7116
+ level: "info",
7117
+ message: `Converge loop "${stage.name}" complete after ${iteration} iteration(s).`
7118
+ }
7119
+ ],
7120
+ structured: evaluation?.structured ?? {}
7121
+ };
7122
+ return advanceStageOnGate(card, stage, stageIndex, def, exitEval, deps);
7123
+ }
7124
+ if (decision === "exhausted") {
7125
+ await deps.stateStore.resetLoopIterations(card.id).catch(() => {});
7126
+ await deps.stateStore.decrementAttempt(card.id).catch(() => {});
7127
+ 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.`;
7128
+ deps.sink?.recordLoopExhausted?.({
7129
+ stageId: stage.id,
7130
+ iterations: iteration,
7131
+ maxIterations,
7132
+ reason
7133
+ });
7134
+ try {
7135
+ await deps.client.updateAgentProgress(card.id, {
7136
+ agentIdentifier: "claude-code-stage",
7137
+ agentName: "Harmony Agent",
7138
+ status: "waiting",
7139
+ currentTask: reason
7140
+ });
7141
+ } catch {}
7142
+ await holdForHuman(deps.client, card, reason, deps.runId, deps.stateStore, {
7143
+ keepAttempts: true
7144
+ });
7145
+ log.info(TAG28, `#${card.short_id} LoopExhausted: ${reason}`);
7146
+ return { kind: "held_gate_unmet", reason };
7147
+ }
7148
+ await deps.stateStore.decrementAttempt(card.id).catch(() => {});
7149
+ await writeIterationHandoff(card, stage, iteration, maxIterations, evaluation, deps);
7150
+ const toColumn = await resolveStageColumnName(deps.client, card, stage) ?? deps.fallbackColumn;
7151
+ try {
7152
+ await deps.client.addComment(card.id, `Converge loop — ${summary}. Re-running "${stage.name}".`, { commentType: "progress" });
7153
+ } catch {}
7154
+ await runTransition(deps.client, card, {
7155
+ move: { columnName: toColumn },
7156
+ addLabels: [{ name: AGENT_LABEL }],
7157
+ ...isAgentRunnableOwner(stage.owner) ? { assignAgent: deps.agentId } : {}
7158
+ }, { store: deps.stateStore, runId: deps.runId });
7159
+ log.info(TAG28, `#${card.short_id} converge loop "${stage.name}" — requeued to "${toColumn}" for iteration ${iteration + 1}/${maxIterations}`);
7160
+ return { kind: "requeued_gate_unmet", toColumn };
7161
+ }
7162
+ async function writeIterationHandoff(card, stage, iteration, maxIterations, evaluation, deps) {
7163
+ try {
7164
+ const findings = evaluation?.findings.filter((f) => f.level !== "info") ?? [];
7165
+ const produced = findings.length > 0 ? `Iteration ${iteration}/${maxIterations} of the "${stage.name}" converge loop did not pass its exit gate. Findings:
7166
+ ${findings.map((f) => `- [${f.level}] ${f.message}`).join(`
7167
+ `)}` : `Iteration ${iteration}/${maxIterations} of the "${stage.name}" converge loop completed; the loop continues.`;
7168
+ const body = buildHandoffCommentBody({
7169
+ stageId: stage.id,
7170
+ stageName: stage.name,
7171
+ artifactType: stage.artifact_type,
7172
+ produced,
7173
+ decisions: [],
7174
+ nextStageNeeds: "Address the findings above on the same branch and re-run; this stage repeats until its exit gate passes."
7175
+ });
7176
+ await deps.client.addComment(card.id, body, { commentType: "decision" });
7177
+ } catch (err) {
7178
+ log.warn(TAG28, `iteration-handoff write failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
7179
+ }
7180
+ }
6938
7181
  async function advanceStageOnGate(card, stage, stageIndex, def, evaluation, deps) {
6939
7182
  const summary = gateSummary(stage, evaluation);
6940
7183
  deps.sink?.recordStageGateEvaluated({
@@ -7293,7 +7536,9 @@ class Worker {
7293
7536
  const basePrompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
7294
7537
  let prompt = basePrompt;
7295
7538
  if (stageCtx.kind === "run") {
7296
- const inherited = await this.loadInheritedHandoffSection(card.id, stageCtx.stage.id);
7539
+ const loop = getStageLoop(stageCtx.stage);
7540
+ const isLoop = isConvergeLoop(loop);
7541
+ const inherited = await this.loadInheritedHandoffSection(card.id, stageCtx.stage.id, { includeOwnStage: isLoop });
7297
7542
  prompt = [buildStagePreamble(stageCtx.stage), inherited, basePrompt].filter(Boolean).join(`
7298
7543
 
7299
7544
  `);
@@ -7302,6 +7547,16 @@ class Worker {
7302
7547
  stageName: stageCtx.stage.name,
7303
7548
  owner: stageCtx.stage.owner
7304
7549
  });
7550
+ if (isLoop && loop) {
7551
+ const priorIterations = this.stateStore.getLoopIterations(card.id, stageCtx.stage.id);
7552
+ this.cliRunner?.recordLoopIterationStarted({
7553
+ stageId: stageCtx.stage.id,
7554
+ stageName: stageCtx.stage.name,
7555
+ iteration: priorIterations + 1,
7556
+ maxIterations: Math.max(1, Math.floor(loop.max_iterations) || 1),
7557
+ mode: loop.mode
7558
+ });
7559
+ }
7305
7560
  }
7306
7561
  await this.client.updateAgentProgress(card.id, {
7307
7562
  agentIdentifier: agentIdentifier(this.id),
@@ -7463,15 +7718,19 @@ class Worker {
7463
7718
  } catch {}
7464
7719
  await this.recordOutcome(card.id, "failure");
7465
7720
  } 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}`);
7721
+ if (!this.completionStarted) {
7722
+ try {
7723
+ await this.client.updateCard(card.id, { assignedAgentId: null });
7724
+ } catch (err) {
7725
+ log.warn(this.tag, `failed to release card after stop: ${err instanceof Error ? err.message : err}`);
7726
+ }
7727
+ try {
7728
+ await runTransition(this.client, card, { removeLabels: ["agent"] });
7729
+ } catch (tErr) {
7730
+ log.warn(this.tag, `stop label cleanup failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
7731
+ }
7732
+ } else {
7733
+ log.info(this.tag, `cancel arrived after completion on #${card.short_id} — keeping assignment so review picks it up (#585)`);
7475
7734
  }
7476
7735
  try {
7477
7736
  await this.stateStore.endRun(this.runId, "paused", {
@@ -7613,15 +7872,13 @@ class Worker {
7613
7872
  } catch {}
7614
7873
  }
7615
7874
  }
7616
- async loadInheritedHandoffSection(cardId, currentStageId) {
7875
+ async loadInheritedHandoffSection(cardId, currentStageId, opts = {}) {
7617
7876
  try {
7618
- const { comments } = await this.client.getComments(cardId, {
7619
- limit: 200
7620
- });
7877
+ const { comments } = await this.client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc&comment_type=decision`);
7621
7878
  if (!Array.isArray(comments) || comments.length === 0)
7622
7879
  return "";
7623
7880
  const handoff = extractLatestHandoff(comments, {
7624
- excludeStageId: currentStageId
7881
+ excludeStageId: opts.includeOwnStage ? undefined : currentStageId
7625
7882
  });
7626
7883
  return handoff ? renderInheritedHandoffSection(handoff) : "";
7627
7884
  } catch (err) {
@@ -7649,7 +7906,9 @@ class Worker {
7649
7906
  }
7650
7907
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7651
7908
  try {
7652
- const gate = normalizeGateSpec(stage.gate);
7909
+ const loop = getStageLoop(stage);
7910
+ const gateSource = isConvergeLoop(loop) ? loop?.exit_gate ?? null : stage.gate;
7911
+ const gate = normalizeGateSpec(gateSource);
7653
7912
  if (!gate) {
7654
7913
  return null;
7655
7914
  }
@@ -7692,11 +7951,8 @@ class Worker {
7692
7951
  }
7693
7952
  }
7694
7953
  async advanceFromGateEvaluation(card, stage, stageIndex, def, evaluation) {
7695
- if (!evaluation) {
7696
- return { kind: "no_advance" };
7697
- }
7698
7954
  try {
7699
- return await advanceStageOnGate(card, stage, stageIndex, def, evaluation, {
7955
+ return await advanceStageRun(card, stage, stageIndex, def, evaluation, {
7700
7956
  client: this.client,
7701
7957
  stateStore: this.stateStore,
7702
7958
  agentId: this.agentId,
@@ -8672,6 +8928,71 @@ var init_recovery = __esm(() => {
8672
8928
  init_worktree();
8673
8929
  });
8674
8930
 
8931
+ // src/strand-recovery.ts
8932
+ var exports_strand_recovery = {};
8933
+ __export(exports_strand_recovery, {
8934
+ reclaimPreReviewStrands: () => reclaimPreReviewStrands
8935
+ });
8936
+ async function reclaimPreReviewStrands(opts) {
8937
+ const {
8938
+ client,
8939
+ agentId,
8940
+ cards,
8941
+ columns,
8942
+ labelMap,
8943
+ reviewColumns,
8944
+ approvedLabel,
8945
+ graceMs,
8946
+ knownCardIds,
8947
+ cwd,
8948
+ provider
8949
+ } = opts;
8950
+ const now = opts.now ?? Date.now();
8951
+ const maxPerSweep = opts.maxPerSweep ?? 5;
8952
+ const reviewColIds = new Set(columns.filter((c) => reviewColumns.some((n) => n.toLowerCase() === c.name.toLowerCase())).map((c) => c.id));
8953
+ if (reviewColIds.size === 0)
8954
+ return [];
8955
+ const reclaimed = [];
8956
+ let checked = 0;
8957
+ for (const card of cards) {
8958
+ if (checked >= maxPerSweep)
8959
+ break;
8960
+ if (card.archived_at || !reviewColIds.has(card.column_id) || card.assigned_agent_id != null || card.assignee_id != null || knownCardIds.has(card.id)) {
8961
+ continue;
8962
+ }
8963
+ const branch = extractBranchFromDescription(card.description);
8964
+ if (!branch)
8965
+ continue;
8966
+ const labels = resolveCardLabels(card, labelMap);
8967
+ if (hasLabel(labels, approvedLabel) || hasLabel(labels, NEED_REVIEW_LABEL)) {
8968
+ continue;
8969
+ }
8970
+ const stalledAt = Date.parse(card.updated_at ?? "");
8971
+ if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
8972
+ continue;
8973
+ checked++;
8974
+ const prUrl = resolvePrUrl(card.description ?? null, branch, cwd, provider);
8975
+ if (prUrl)
8976
+ continue;
8977
+ log.warn(TAG33, `#${card.short_id} stranded in review (branch pushed, no PR, unowned) — re-asserting daemon assignment`);
8978
+ try {
8979
+ await client.updateCard(card.id, { assignedAgentId: agentId });
8980
+ reclaimed.push(card.id);
8981
+ } catch (err) {
8982
+ log.error(TAG33, `review re-claim failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
8983
+ }
8984
+ }
8985
+ return reclaimed;
8986
+ }
8987
+ var TAG33 = "strand-recovery";
8988
+ var init_strand_recovery = __esm(() => {
8989
+ init_board_helpers();
8990
+ init_git_pr();
8991
+ init_log();
8992
+ init_review_worktree();
8993
+ init_types();
8994
+ });
8995
+
8675
8996
  // src/reconcile.ts
8676
8997
  class Reconciler {
8677
8998
  client;
@@ -8686,6 +9007,7 @@ class Reconciler {
8686
9007
  agentConfig;
8687
9008
  timer = null;
8688
9009
  lastTickAt = null;
9010
+ gitProvider = null;
8689
9011
  get lastTick() {
8690
9012
  return this.lastTickAt;
8691
9013
  }
@@ -8713,7 +9035,7 @@ class Reconciler {
8713
9035
  clearInterval(this.timer);
8714
9036
  this.timer = null;
8715
9037
  }
8716
- log.info(TAG33, "Heartbeat stopped");
9038
+ log.info(TAG34, "Heartbeat stopped");
8717
9039
  }
8718
9040
  async recoverStaleRuns() {
8719
9041
  if (!this.stateStore || !this.agentConfig)
@@ -8730,7 +9052,7 @@ class Reconciler {
8730
9052
  if (!daemonDead && !(heartbeatStale && ourZombie))
8731
9053
  continue;
8732
9054
  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`);
9055
+ log.warn(TAG34, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
8734
9056
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
8735
9057
  runId: run.runId,
8736
9058
  cardId: run.cardId,
@@ -8757,13 +9079,37 @@ class Reconciler {
8757
9079
  const stalledAt = Date.parse(card.updated_at ?? "");
8758
9080
  if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
8759
9081
  continue;
8760
- log.warn(TAG33, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
9082
+ log.warn(TAG34, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
8761
9083
  try {
8762
9084
  await this.client.moveCard(card.id, pickupCol.id);
8763
9085
  } catch (err) {
8764
- log.error(TAG33, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9086
+ log.error(TAG34, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9087
+ }
9088
+ }
9089
+ }
9090
+ async recoverStrandedReview(cards, columns, labelMap, knownCardIds) {
9091
+ if (this.reviewColumns.length === 0)
9092
+ return;
9093
+ if (!this.gitProvider) {
9094
+ try {
9095
+ this.gitProvider = detectGitProvider();
9096
+ } catch {
9097
+ return;
8765
9098
  }
8766
9099
  }
9100
+ await reclaimPreReviewStrands({
9101
+ client: this.client,
9102
+ agentId: this.agentId,
9103
+ cards,
9104
+ columns,
9105
+ labelMap,
9106
+ reviewColumns: this.reviewColumns,
9107
+ approvedLabel: this.approvedLabel,
9108
+ graceMs: this.agentConfig?.timing.staleHeartbeatMs ?? 120000,
9109
+ knownCardIds,
9110
+ cwd: process.cwd(),
9111
+ provider: this.gitProvider
9112
+ });
8767
9113
  }
8768
9114
  async releaseStalledApprovals(cards, columns, knownCardIds) {
8769
9115
  const planning = this.agentConfig?.planning;
@@ -8784,11 +9130,11 @@ class Reconciler {
8784
9130
  const parkedAt = Date.parse(card.updated_at ?? "");
8785
9131
  if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
8786
9132
  continue;
8787
- log.warn(TAG33, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
9133
+ log.warn(TAG34, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
8788
9134
  try {
8789
9135
  await this.client.moveCard(card.id, pickupCol.id);
8790
9136
  } catch (err) {
8791
- log.error(TAG33, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
9137
+ log.error(TAG34, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
8792
9138
  }
8793
9139
  }
8794
9140
  }
@@ -8831,21 +9177,21 @@ class Reconciler {
8831
9177
  const subtasks = card.subtasks ?? [];
8832
9178
  const mode = route.mode;
8833
9179
  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`);
9180
+ 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
9181
  }
8836
9182
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
8837
- log.debug(TAG33, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
9183
+ log.debug(TAG34, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
8838
9184
  continue;
8839
9185
  }
8840
9186
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
8841
- log.debug(TAG33, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
9187
+ log.debug(TAG34, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
8842
9188
  continue;
8843
9189
  }
8844
9190
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
8845
- log.debug(TAG33, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
9191
+ log.debug(TAG34, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
8846
9192
  continue;
8847
9193
  }
8848
- log.info(TAG33, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
9194
+ log.info(TAG34, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
8849
9195
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
8850
9196
  }
8851
9197
  }
@@ -8853,25 +9199,28 @@ class Reconciler {
8853
9199
  await this.recoverStaleRuns();
8854
9200
  }
8855
9201
  await this.recoverStrandedInProgress(cards, columns, knownCardIds);
9202
+ await this.recoverStrandedReview(cards, columns, labelMap, knownCardIds);
8856
9203
  for (const knownId of knownCardIds) {
8857
9204
  if (!allAgentCardIds.has(knownId)) {
8858
- log.info(TAG33, `Missed unassign: ${knownId} — removing`);
9205
+ log.info(TAG34, `Missed unassign: ${knownId} — removing`);
8859
9206
  await this.pool.removeCard(knownId);
8860
9207
  }
8861
9208
  }
8862
9209
  await this.releaseStalledApprovals(cards, columns, knownCardIds);
8863
- log.debug(TAG33, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
9210
+ log.debug(TAG34, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
8864
9211
  } catch (err) {
8865
- log.error(TAG33, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
9212
+ log.error(TAG34, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
8866
9213
  }
8867
9214
  }
8868
9215
  }
8869
- var TAG33 = "reconcile";
9216
+ var TAG34 = "reconcile";
8870
9217
  var init_reconcile = __esm(() => {
8871
9218
  init_board_helpers();
9219
+ init_git_pr();
8872
9220
  init_log();
8873
9221
  init_recovery();
8874
9222
  init_review_worktree();
9223
+ init_strand_recovery();
8875
9224
  init_types();
8876
9225
  });
8877
9226
 
@@ -8904,7 +9253,7 @@ function prettyBanner(config, version) {
8904
9253
  checks.push({ kind: "ok", message });
8905
9254
  },
8906
9255
  warn(message) {
8907
- log.warn(TAG34, message);
9256
+ log.warn(TAG35, message);
8908
9257
  checks.push({ kind: "warn", message: message.split(`
8909
9258
  `, 1)[0] });
8910
9259
  },
@@ -8929,25 +9278,25 @@ function prettyBanner(config, version) {
8929
9278
  };
8930
9279
  }
8931
9280
  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(", ")}`);
9281
+ log.info(TAG35, `Harmony Agent Daemon v${version} starting...`);
9282
+ 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
9283
  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}`);
9284
+ log.info(TAG35, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
8936
9285
  }
8937
9286
  let failed = false;
8938
9287
  return {
8939
9288
  setProjectName(_name) {},
8940
9289
  setGitProvider(provider) {
8941
- log.info(TAG34, `Git provider: ${provider}`);
9290
+ log.info(TAG35, `Git provider: ${provider}`);
8942
9291
  },
8943
9292
  setHttpPort(port) {
8944
- log.info(TAG34, `HTTP server on port ${port}`);
9293
+ log.info(TAG35, `HTTP server on port ${port}`);
8945
9294
  },
8946
9295
  check(message) {
8947
- log.info(TAG34, message);
9296
+ log.info(TAG35, message);
8948
9297
  },
8949
9298
  warn(message) {
8950
- log.warn(TAG34, message);
9299
+ log.warn(TAG35, message);
8951
9300
  },
8952
9301
  fail() {
8953
9302
  failed = true;
@@ -8955,7 +9304,7 @@ function jsonBanner(config, version) {
8955
9304
  async ready(message) {
8956
9305
  if (failed)
8957
9306
  return;
8958
- log.info(TAG34, message);
9307
+ log.info(TAG35, message);
8959
9308
  }
8960
9309
  };
8961
9310
  }
@@ -9036,7 +9385,7 @@ function cyan(s) {
9036
9385
  function yellow(s) {
9037
9386
  return `${ANSI.yellow}${s}${ANSI.reset}`;
9038
9387
  }
9039
- var TAG34 = "daemon", RULE_WIDTH = 70, ANSI;
9388
+ var TAG35 = "daemon", RULE_WIDTH = 70, ANSI;
9040
9389
  var init_startup_banner = __esm(() => {
9041
9390
  init_log();
9042
9391
  ANSI = {
@@ -9187,13 +9536,13 @@ class Watcher {
9187
9536
  }
9188
9537
  async start() {
9189
9538
  if (!isPretty()) {
9190
- log.info(TAG35, "Connecting to Supabase realtime (broadcast)...");
9539
+ log.info(TAG36, "Connecting to Supabase realtime (broadcast)...");
9191
9540
  }
9192
9541
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
9193
9542
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
9194
9543
  this.subscribeBroadcast();
9195
9544
  presenceChannel.on("presence", { event: "sync" }, () => {
9196
- log.debug(TAG35, "Presence sync");
9545
+ log.debug(TAG36, "Presence sync");
9197
9546
  }).subscribe(async (status) => {
9198
9547
  if (status === "SUBSCRIBED") {
9199
9548
  await presenceChannel.track({
@@ -9206,7 +9555,7 @@ class Watcher {
9206
9555
  agentName: this.identity.agentName
9207
9556
  });
9208
9557
  if (!isPretty() || !this.suppressStartupLogs) {
9209
- log.info(TAG35, "Presence tracked on board-presence channel");
9558
+ log.info(TAG36, "Presence tracked on board-presence channel");
9210
9559
  }
9211
9560
  this.presenceTracked = true;
9212
9561
  this.maybeResolveReady();
@@ -9219,13 +9568,13 @@ class Watcher {
9219
9568
  return;
9220
9569
  const gen = ++this.broadcastGen;
9221
9570
  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)}`);
9571
+ log.debug(TAG36, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
9223
9572
  this.onCardBroadcast({
9224
9573
  event: "card_update",
9225
9574
  payload: msg.payload ?? {}
9226
9575
  });
9227
9576
  }).on("broadcast", { event: "card_created" }, (msg) => {
9228
- log.debug(TAG35, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9577
+ log.debug(TAG36, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9229
9578
  this.onCardBroadcast({
9230
9579
  event: "card_created",
9231
9580
  payload: msg.payload ?? {}
@@ -9235,7 +9584,7 @@ class Watcher {
9235
9584
  const cardId = payload.card_id;
9236
9585
  const command = payload.command;
9237
9586
  if (cardId && command) {
9238
- log.info(TAG35, `Broadcast: agent_command ${command} for ${cardId}`);
9587
+ log.info(TAG36, `Broadcast: agent_command ${command} for ${cardId}`);
9239
9588
  this.onAgentCommand?.({ cardId, command });
9240
9589
  }
9241
9590
  }).subscribe((status) => {
@@ -9245,13 +9594,13 @@ class Watcher {
9245
9594
  this.connected = true;
9246
9595
  this.reconnectAttempts = 0;
9247
9596
  if (!isPretty() || !this.suppressStartupLogs) {
9248
- log.info(TAG35, "Broadcast subscription active");
9597
+ log.info(TAG36, "Broadcast subscription active");
9249
9598
  }
9250
9599
  this.maybeResolveReady();
9251
9600
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
9252
9601
  this.connected = false;
9253
9602
  if (!this.stopping) {
9254
- log.warn(TAG35, `Broadcast subscription ${status} — scheduling reconnect`);
9603
+ log.warn(TAG36, `Broadcast subscription ${status} — scheduling reconnect`);
9255
9604
  this.scheduleReconnect();
9256
9605
  }
9257
9606
  }
@@ -9270,7 +9619,7 @@ class Watcher {
9270
9619
  async reconnectBroadcast() {
9271
9620
  if (this.stopping || !this.supabase)
9272
9621
  return;
9273
- log.warn(TAG35, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9622
+ log.warn(TAG36, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9274
9623
  if (this.channel) {
9275
9624
  const old = this.channel;
9276
9625
  this.channel = null;
@@ -9300,10 +9649,10 @@ class Watcher {
9300
9649
  this.supabase = null;
9301
9650
  }
9302
9651
  this.connected = false;
9303
- log.info(TAG35, "Broadcast subscription stopped");
9652
+ log.info(TAG36, "Broadcast subscription stopped");
9304
9653
  }
9305
9654
  }
9306
- var TAG35 = "watcher";
9655
+ var TAG36 = "watcher";
9307
9656
  var init_watcher = __esm(() => {
9308
9657
  init_log();
9309
9658
  });
@@ -9390,10 +9739,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
9390
9739
  });
9391
9740
  } catch {}
9392
9741
  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(", ")}`);
9742
+ log.info(TAG37, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
9394
9743
  }
9395
9744
  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("; ")}`);
9745
+ log.warn(TAG37, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
9397
9746
  }
9398
9747
  return result;
9399
9748
  }
@@ -9423,7 +9772,7 @@ function pruneFailedRemoteBranches(opts) {
9423
9772
  } catch (err) {
9424
9773
  const detail = gitErrorDetail2(err);
9425
9774
  if (isTransientGitNetworkError(detail)) {
9426
- log.debug(TAG36, `Remote branch GC skipped — remote unreachable: ${detail}`);
9775
+ log.debug(TAG37, `Remote branch GC skipped — remote unreachable: ${detail}`);
9427
9776
  return result;
9428
9777
  }
9429
9778
  result.errors.push({ ref: "fetch", error: detail });
@@ -9462,7 +9811,7 @@ function pruneFailedRemoteBranches(opts) {
9462
9811
  continue;
9463
9812
  }
9464
9813
  if (clock() > sweepDeadline) {
9465
- log.debug(TAG36, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
9814
+ log.debug(TAG37, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
9466
9815
  break;
9467
9816
  }
9468
9817
  try {
@@ -9475,17 +9824,17 @@ function pruneFailedRemoteBranches(opts) {
9475
9824
  } catch (err) {
9476
9825
  const detail = gitErrorDetail2(err);
9477
9826
  if (isTransientGitNetworkError(detail)) {
9478
- log.debug(TAG36, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9827
+ log.debug(TAG37, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9479
9828
  break;
9480
9829
  }
9481
9830
  result.errors.push({ ref, error: detail });
9482
9831
  }
9483
9832
  }
9484
9833
  if (result.removed.length > 0) {
9485
- log.info(TAG36, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
9834
+ log.info(TAG37, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
9486
9835
  }
9487
9836
  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("; ")}`);
9837
+ log.warn(TAG37, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
9489
9838
  }
9490
9839
  return result;
9491
9840
  }
@@ -9516,13 +9865,13 @@ class WorktreeGc {
9516
9865
  try {
9517
9866
  runWorktreeGc(this.basePath, this.store);
9518
9867
  } catch (err) {
9519
- log.warn(TAG36, `GC tick failed: ${err instanceof Error ? err.message : err}`);
9868
+ log.warn(TAG37, `GC tick failed: ${err instanceof Error ? err.message : err}`);
9520
9869
  }
9521
9870
  if (this.remoteOpts) {
9522
9871
  try {
9523
9872
  pruneFailedRemoteBranches(this.remoteOpts);
9524
9873
  } catch (err) {
9525
- log.warn(TAG36, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
9874
+ log.warn(TAG37, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
9526
9875
  }
9527
9876
  }
9528
9877
  }
@@ -9536,7 +9885,7 @@ function getRepoRoot2() {
9536
9885
  return null;
9537
9886
  }
9538
9887
  }
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;
9888
+ 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
9889
  var init_worktree_gc = __esm(() => {
9541
9890
  init_log();
9542
9891
  init_worktree();
@@ -9640,7 +9989,7 @@ async function main() {
9640
9989
  } catch (err) {
9641
9990
  if (err instanceof ConfigValidationError) {
9642
9991
  banner.fail();
9643
- log.error(TAG37, err.message);
9992
+ log.error(TAG38, err.message);
9644
9993
  process.exit(1);
9645
9994
  }
9646
9995
  throw err;
@@ -9750,7 +10099,7 @@ async function main() {
9750
10099
  if (shuttingDown)
9751
10100
  return;
9752
10101
  shuttingDown = true;
9753
- log.info(TAG37, `Received ${signal}, shutting down gracefully...`);
10102
+ log.info(TAG38, `Received ${signal}, shutting down gracefully...`);
9754
10103
  reconciler.stop();
9755
10104
  mergeMonitor?.stop();
9756
10105
  worktreeGc.stop();
@@ -9760,18 +10109,18 @@ async function main() {
9760
10109
  }
9761
10110
  await watcher.stop();
9762
10111
  await pool.shutdown();
9763
- log.info(TAG37, "Daemon stopped.");
10112
+ log.info(TAG38, "Daemon stopped.");
9764
10113
  process.exit(exitCode);
9765
10114
  };
9766
10115
  process.on("SIGINT", () => shutdown("SIGINT"));
9767
10116
  process.on("SIGTERM", () => shutdown("SIGTERM"));
9768
10117
  process.on("uncaughtException", (err) => {
9769
- log.error(TAG37, `Uncaught exception: ${err.message}`);
10118
+ log.error(TAG38, `Uncaught exception: ${err.message}`);
9770
10119
  exitCode = 1;
9771
10120
  shutdown("uncaughtException");
9772
10121
  });
9773
10122
  process.on("unhandledRejection", (reason) => {
9774
- log.error(TAG37, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
10123
+ log.error(TAG38, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
9775
10124
  exitCode = 1;
9776
10125
  shutdown("unhandledRejection");
9777
10126
  });
@@ -9824,29 +10173,29 @@ async function handleBroadcast(event, client, pool, config, agentId) {
9824
10173
  if (assignedAgentId === undefined)
9825
10174
  return;
9826
10175
  if (assignedAgentId === agentId) {
9827
- log.info(TAG37, `Broadcast: card ${cardId} assigned to agent`);
10176
+ log.info(TAG38, `Broadcast: card ${cardId} assigned to agent`);
9828
10177
  try {
9829
10178
  await pool.resetAttemptsForReassign(cardId);
9830
10179
  await tryEnqueueCard(cardId, client, pool, config, agentId);
9831
10180
  } catch (err) {
9832
- log.error(TAG37, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
10181
+ log.error(TAG38, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
9833
10182
  }
9834
10183
  } else if (pool.isCardKnown(cardId)) {
9835
- log.info(TAG37, `Broadcast: card ${cardId} unassigned from agent`);
10184
+ log.info(TAG38, `Broadcast: card ${cardId} unassigned from agent`);
9836
10185
  await pool.removeCard(cardId);
9837
10186
  }
9838
10187
  }
9839
10188
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9840
10189
  const { card } = await client.getCard(cardId);
9841
10190
  if (card.assigned_agent_id !== agentId) {
9842
- log.debug(TAG37, `Card ${cardId} no longer assigned to agent — skipping`);
10191
+ log.debug(TAG38, `Card ${cardId} no longer assigned to agent — skipping`);
9843
10192
  return;
9844
10193
  }
9845
10194
  const board = await client.getBoard(config.projectId, { summary: true });
9846
10195
  const columns = board.columns;
9847
10196
  const column = columns.find((c) => c.id === card.column_id);
9848
10197
  if (!column) {
9849
- log.warn(TAG37, `Column not found for card ${cardId}`);
10198
+ log.warn(TAG38, `Column not found for card ${cardId}`);
9850
10199
  return;
9851
10200
  }
9852
10201
  const route = classifyPickup(card, column.name, {
@@ -9855,27 +10204,27 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9855
10204
  playbooks: config.agent.playbooks
9856
10205
  });
9857
10206
  if (!route) {
9858
- log.info(TAG37, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
10207
+ log.info(TAG38, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
9859
10208
  return;
9860
10209
  }
9861
10210
  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`);
10211
+ 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
10212
  }
9864
10213
  const mode = route.mode;
9865
10214
  const labelMap = buildLabelMap(board.labels ?? []);
9866
10215
  const cardLabels = resolveCardLabels(card, labelMap);
9867
10216
  const subtasks = card.subtasks ?? [];
9868
10217
  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`);
10218
+ log.debug(TAG38, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
9870
10219
  return;
9871
10220
  }
9872
10221
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
9873
- log.info(TAG37, `Card #${card.short_id} has no branch reference — skipping auto-review`);
10222
+ log.info(TAG38, `Card #${card.short_id} has no branch reference — skipping auto-review`);
9874
10223
  return;
9875
10224
  }
9876
10225
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
9877
10226
  }
9878
- var TAG37 = "daemon", PKG_VERSION;
10227
+ var TAG38 = "daemon", PKG_VERSION;
9879
10228
  var init_src = __esm(() => {
9880
10229
  init_board_helpers();
9881
10230
  init_config();
@@ -9909,6 +10258,7 @@ Usage:
9909
10258
  harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
9910
10259
  harmony-agent doctor Run preflight checks (don't start)
9911
10260
  harmony-agent gc One-shot worktree garbage collection
10261
+ harmony-agent recover One-shot recovery of stranded Review cards
9912
10262
  harmony-agent help Show this help
9913
10263
 
9914
10264
  Flags:
@@ -10047,6 +10397,53 @@ async function gcCommand() {
10047
10397
  }
10048
10398
  return 0;
10049
10399
  }
10400
+ async function recoverCommand() {
10401
+ const { loadDaemonConfig: loadDaemonConfig2, createApiClient: createApiClient2 } = await Promise.resolve().then(() => (init_config(), exports_config));
10402
+ const { MergeMonitor: MergeMonitor2 } = await Promise.resolve().then(() => (init_merge_monitor(), exports_merge_monitor));
10403
+ const { reclaimPreReviewStrands: reclaimPreReviewStrands2 } = await Promise.resolve().then(() => (init_strand_recovery(), exports_strand_recovery));
10404
+ const { detectGitProvider: detectGitProvider2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
10405
+ const { buildLabelMap: buildLabelMap2 } = await Promise.resolve().then(() => (init_board_helpers(), exports_board_helpers));
10406
+ const config = loadDaemonConfig2();
10407
+ const client = createApiClient2(config);
10408
+ const { agent: registeredAgent } = await client.registerWorkspaceAgent(config.workspaceId, {
10409
+ identifier: config.agentIdentifier,
10410
+ name: config.agentName,
10411
+ color: config.agentColor
10412
+ });
10413
+ const agentId = registeredAgent.id;
10414
+ const monitor = new MergeMonitor2(client, config.projectId, config.agent);
10415
+ await monitor.runOnce();
10416
+ const board = await client.getBoard(config.projectId);
10417
+ const cards = board.cards ?? [];
10418
+ const columns = board.columns ?? [];
10419
+ const labelMap = buildLabelMap2(board.labels ?? []);
10420
+ let provider;
10421
+ try {
10422
+ provider = detectGitProvider2();
10423
+ } catch {
10424
+ provider = "unknown";
10425
+ }
10426
+ const reclaimed = await reclaimPreReviewStrands2({
10427
+ client,
10428
+ agentId,
10429
+ cards,
10430
+ columns,
10431
+ labelMap,
10432
+ reviewColumns: config.agent.review.pickupColumns,
10433
+ approvedLabel: config.agent.review.approvedLabel,
10434
+ graceMs: config.agent.timing.staleHeartbeatMs,
10435
+ knownCardIds: new Set,
10436
+ cwd: process.cwd(),
10437
+ provider
10438
+ });
10439
+ process.stdout.write(`recover: merge-strand pass complete; re-asserted ${reclaimed.length} pre-review strand(s)${reclaimed.length ? `: ${reclaimed.join(", ")}` : ""}
10440
+ `);
10441
+ if (reclaimed.length) {
10442
+ process.stdout.write(` start/keep the daemon running so review picks these up
10443
+ `);
10444
+ }
10445
+ return 0;
10446
+ }
10050
10447
  async function dispatch(argv) {
10051
10448
  const args = argv.filter((a) => a !== "--pretty" && a !== "--json");
10052
10449
  const cmd = args[0];
@@ -10064,6 +10461,8 @@ async function dispatch(argv) {
10064
10461
  return doctorCommand();
10065
10462
  case "gc":
10066
10463
  return gcCommand();
10464
+ case "recover":
10465
+ return recoverCommand();
10067
10466
  case "help":
10068
10467
  case "--help":
10069
10468
  case "-h":