@gethmy/agent 1.14.3 → 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 +504 -86
  2. package/dist/index.js +454 -86
  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({
@@ -7088,6 +7331,9 @@ function buildStagePreamble(stage) {
7088
7331
  }
7089
7332
  }
7090
7333
  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.");
7334
+ if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7335
+ 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.");
7336
+ }
7091
7337
  return lines.filter(Boolean).join(`
7092
7338
  `);
7093
7339
  }
@@ -7132,6 +7378,7 @@ class Worker {
7132
7378
  lastDrainedSeq = 0;
7133
7379
  runCostCents = 0;
7134
7380
  runTurns = 0;
7381
+ lastRunText = "";
7135
7382
  constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted, onApiError) {
7136
7383
  this.config = config;
7137
7384
  this.client = client;
@@ -7200,6 +7447,7 @@ class Worker {
7200
7447
  this.completionStarted = false;
7201
7448
  this.runCostCents = 0;
7202
7449
  this.runTurns = 0;
7450
+ this.lastRunText = "";
7203
7451
  this.cliSessionId = null;
7204
7452
  this.lastDrainedSeq = 0;
7205
7453
  this.cardId = card.id;
@@ -7288,7 +7536,9 @@ class Worker {
7288
7536
  const basePrompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
7289
7537
  let prompt = basePrompt;
7290
7538
  if (stageCtx.kind === "run") {
7291
- 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 });
7292
7542
  prompt = [buildStagePreamble(stageCtx.stage), inherited, basePrompt].filter(Boolean).join(`
7293
7543
 
7294
7544
  `);
@@ -7297,6 +7547,16 @@ class Worker {
7297
7547
  stageName: stageCtx.stage.name,
7298
7548
  owner: stageCtx.stage.owner
7299
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
+ }
7300
7560
  }
7301
7561
  await this.client.updateAgentProgress(card.id, {
7302
7562
  agentIdentifier: agentIdentifier(this.id),
@@ -7458,15 +7718,19 @@ class Worker {
7458
7718
  } catch {}
7459
7719
  await this.recordOutcome(card.id, "failure");
7460
7720
  } else if (this.runId && this.aborted) {
7461
- try {
7462
- await this.client.updateCard(card.id, { assignedAgentId: null });
7463
- } catch (err) {
7464
- log.warn(this.tag, `failed to release card after stop: ${err instanceof Error ? err.message : err}`);
7465
- }
7466
- try {
7467
- await runTransition(this.client, card, { removeLabels: ["agent"] });
7468
- } catch (tErr) {
7469
- 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)`);
7470
7734
  }
7471
7735
  try {
7472
7736
  await this.stateStore.endRun(this.runId, "paused", {
@@ -7608,15 +7872,13 @@ class Worker {
7608
7872
  } catch {}
7609
7873
  }
7610
7874
  }
7611
- async loadInheritedHandoffSection(cardId, currentStageId) {
7875
+ async loadInheritedHandoffSection(cardId, currentStageId, opts = {}) {
7612
7876
  try {
7613
- const { comments } = await this.client.getComments(cardId, {
7614
- limit: 200
7615
- });
7877
+ const { comments } = await this.client.request("GET", `/cards/${encodeURIComponent(cardId)}/comments?limit=200&order=desc&comment_type=decision`);
7616
7878
  if (!Array.isArray(comments) || comments.length === 0)
7617
7879
  return "";
7618
7880
  const handoff = extractLatestHandoff(comments, {
7619
- excludeStageId: currentStageId
7881
+ excludeStageId: opts.includeOwnStage ? undefined : currentStageId
7620
7882
  });
7621
7883
  return handoff ? renderInheritedHandoffSection(handoff) : "";
7622
7884
  } catch (err) {
@@ -7644,7 +7906,9 @@ class Worker {
7644
7906
  }
7645
7907
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7646
7908
  try {
7647
- 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);
7648
7912
  if (!gate) {
7649
7913
  return null;
7650
7914
  }
@@ -7652,6 +7916,10 @@ class Worker {
7652
7916
  log.info(this.tag, `Stage "${stage.name}" gate "${gate.kind}" is advisory — skipping enforcement`);
7653
7917
  return null;
7654
7918
  }
7919
+ const review = gate.kind === "review_passed" ? parseReviewOutput(this.lastRunText) : undefined;
7920
+ if (review) {
7921
+ log.info(this.tag, `Review-gated stage "${stage.name}" verdict: ${review.verdict} (${review.findings.length} finding(s))`);
7922
+ }
7655
7923
  const registry = buildGateCollectorRegistry({
7656
7924
  build: {
7657
7925
  worktreePath,
@@ -7662,7 +7930,8 @@ class Worker {
7662
7930
  worktreePath,
7663
7931
  artifactType: stage.artifact_type
7664
7932
  },
7665
- checklist: { subtasks, cardDone: card.done ?? false }
7933
+ checklist: { subtasks, cardDone: card.done ?? false },
7934
+ ...review ? { review } : {}
7666
7935
  });
7667
7936
  const context = {
7668
7937
  cardId: card.id,
@@ -7682,11 +7951,8 @@ class Worker {
7682
7951
  }
7683
7952
  }
7684
7953
  async advanceFromGateEvaluation(card, stage, stageIndex, def, evaluation) {
7685
- if (!evaluation) {
7686
- return { kind: "no_advance" };
7687
- }
7688
7954
  try {
7689
- return await advanceStageOnGate(card, stage, stageIndex, def, evaluation, {
7955
+ return await advanceStageRun(card, stage, stageIndex, def, evaluation, {
7690
7956
  client: this.client,
7691
7957
  stateStore: this.stateStore,
7692
7958
  agentId: this.agentId,
@@ -8000,6 +8266,9 @@ class Worker {
8000
8266
  });
8001
8267
  }
8002
8268
  }
8269
+ parser.on("text", (content) => {
8270
+ this.lastRunText += content;
8271
+ });
8003
8272
  parser.on("parse_error", (msg) => {
8004
8273
  log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
8005
8274
  runLog?.stream.write(`
@@ -8093,6 +8362,10 @@ class Worker {
8093
8362
  try {
8094
8363
  for await (const ev of stream) {
8095
8364
  this.progressTracker?.ingest(ev);
8365
+ if (ev.kind === "assistant_text") {
8366
+ this.lastRunText += `${ev.payload.text}
8367
+ `;
8368
+ }
8096
8369
  if (ev.source === "agent")
8097
8370
  this.cliRunner?.record(ev);
8098
8371
  if (ev.kind === "error") {
@@ -8180,6 +8453,8 @@ var init_worker = __esm(() => {
8180
8453
  init_process_group();
8181
8454
  init_progress_tracker();
8182
8455
  init_prompt();
8456
+ init_review_completion();
8457
+ init_review_knowledge();
8183
8458
  init_run_log();
8184
8459
  init_sdk_agent_runner();
8185
8460
  init_stage_advance();
@@ -8653,6 +8928,71 @@ var init_recovery = __esm(() => {
8653
8928
  init_worktree();
8654
8929
  });
8655
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
+
8656
8996
  // src/reconcile.ts
8657
8997
  class Reconciler {
8658
8998
  client;
@@ -8667,6 +9007,7 @@ class Reconciler {
8667
9007
  agentConfig;
8668
9008
  timer = null;
8669
9009
  lastTickAt = null;
9010
+ gitProvider = null;
8670
9011
  get lastTick() {
8671
9012
  return this.lastTickAt;
8672
9013
  }
@@ -8694,7 +9035,7 @@ class Reconciler {
8694
9035
  clearInterval(this.timer);
8695
9036
  this.timer = null;
8696
9037
  }
8697
- log.info(TAG33, "Heartbeat stopped");
9038
+ log.info(TAG34, "Heartbeat stopped");
8698
9039
  }
8699
9040
  async recoverStaleRuns() {
8700
9041
  if (!this.stateStore || !this.agentConfig)
@@ -8711,7 +9052,7 @@ class Reconciler {
8711
9052
  if (!daemonDead && !(heartbeatStale && ourZombie))
8712
9053
  continue;
8713
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`;
8714
- log.warn(TAG33, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
9055
+ log.warn(TAG34, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
8715
9056
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
8716
9057
  runId: run.runId,
8717
9058
  cardId: run.cardId,
@@ -8738,14 +9079,38 @@ class Reconciler {
8738
9079
  const stalledAt = Date.parse(card.updated_at ?? "");
8739
9080
  if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
8740
9081
  continue;
8741
- 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}"`);
8742
9083
  try {
8743
9084
  await this.client.moveCard(card.id, pickupCol.id);
8744
9085
  } catch (err) {
8745
- 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}`);
8746
9087
  }
8747
9088
  }
8748
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;
9098
+ }
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
+ });
9113
+ }
8749
9114
  async releaseStalledApprovals(cards, columns, knownCardIds) {
8750
9115
  const planning = this.agentConfig?.planning;
8751
9116
  if (!planning?.enabled || planning.mode !== "gated" || planning.approvalTtlHours <= 0) {
@@ -8765,11 +9130,11 @@ class Reconciler {
8765
9130
  const parkedAt = Date.parse(card.updated_at ?? "");
8766
9131
  if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
8767
9132
  continue;
8768
- 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}"`);
8769
9134
  try {
8770
9135
  await this.client.moveCard(card.id, pickupCol.id);
8771
9136
  } catch (err) {
8772
- 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}`);
8773
9138
  }
8774
9139
  }
8775
9140
  }
@@ -8812,21 +9177,21 @@ class Reconciler {
8812
9177
  const subtasks = card.subtasks ?? [];
8813
9178
  const mode = route.mode;
8814
9179
  if (route.stage) {
8815
- 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`);
8816
9181
  }
8817
9182
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
8818
- 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`);
8819
9184
  continue;
8820
9185
  }
8821
9186
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
8822
- 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)`);
8823
9188
  continue;
8824
9189
  }
8825
9190
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
8826
- 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)`);
8827
9192
  continue;
8828
9193
  }
8829
- 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`);
8830
9195
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
8831
9196
  }
8832
9197
  }
@@ -8834,25 +9199,28 @@ class Reconciler {
8834
9199
  await this.recoverStaleRuns();
8835
9200
  }
8836
9201
  await this.recoverStrandedInProgress(cards, columns, knownCardIds);
9202
+ await this.recoverStrandedReview(cards, columns, labelMap, knownCardIds);
8837
9203
  for (const knownId of knownCardIds) {
8838
9204
  if (!allAgentCardIds.has(knownId)) {
8839
- log.info(TAG33, `Missed unassign: ${knownId} — removing`);
9205
+ log.info(TAG34, `Missed unassign: ${knownId} — removing`);
8840
9206
  await this.pool.removeCard(knownId);
8841
9207
  }
8842
9208
  }
8843
9209
  await this.releaseStalledApprovals(cards, columns, knownCardIds);
8844
- log.debug(TAG33, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
9210
+ log.debug(TAG34, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
8845
9211
  } catch (err) {
8846
- log.error(TAG33, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
9212
+ log.error(TAG34, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
8847
9213
  }
8848
9214
  }
8849
9215
  }
8850
- var TAG33 = "reconcile";
9216
+ var TAG34 = "reconcile";
8851
9217
  var init_reconcile = __esm(() => {
8852
9218
  init_board_helpers();
9219
+ init_git_pr();
8853
9220
  init_log();
8854
9221
  init_recovery();
8855
9222
  init_review_worktree();
9223
+ init_strand_recovery();
8856
9224
  init_types();
8857
9225
  });
8858
9226
 
@@ -8885,7 +9253,7 @@ function prettyBanner(config, version) {
8885
9253
  checks.push({ kind: "ok", message });
8886
9254
  },
8887
9255
  warn(message) {
8888
- log.warn(TAG34, message);
9256
+ log.warn(TAG35, message);
8889
9257
  checks.push({ kind: "warn", message: message.split(`
8890
9258
  `, 1)[0] });
8891
9259
  },
@@ -8910,25 +9278,25 @@ function prettyBanner(config, version) {
8910
9278
  };
8911
9279
  }
8912
9280
  function jsonBanner(config, version) {
8913
- log.info(TAG34, `Harmony Agent Daemon v${version} starting...`);
8914
- 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(", ")}`);
8915
9283
  if (config.agent.review.enabled) {
8916
- 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}`);
8917
9285
  }
8918
9286
  let failed = false;
8919
9287
  return {
8920
9288
  setProjectName(_name) {},
8921
9289
  setGitProvider(provider) {
8922
- log.info(TAG34, `Git provider: ${provider}`);
9290
+ log.info(TAG35, `Git provider: ${provider}`);
8923
9291
  },
8924
9292
  setHttpPort(port) {
8925
- log.info(TAG34, `HTTP server on port ${port}`);
9293
+ log.info(TAG35, `HTTP server on port ${port}`);
8926
9294
  },
8927
9295
  check(message) {
8928
- log.info(TAG34, message);
9296
+ log.info(TAG35, message);
8929
9297
  },
8930
9298
  warn(message) {
8931
- log.warn(TAG34, message);
9299
+ log.warn(TAG35, message);
8932
9300
  },
8933
9301
  fail() {
8934
9302
  failed = true;
@@ -8936,7 +9304,7 @@ function jsonBanner(config, version) {
8936
9304
  async ready(message) {
8937
9305
  if (failed)
8938
9306
  return;
8939
- log.info(TAG34, message);
9307
+ log.info(TAG35, message);
8940
9308
  }
8941
9309
  };
8942
9310
  }
@@ -9017,7 +9385,7 @@ function cyan(s) {
9017
9385
  function yellow(s) {
9018
9386
  return `${ANSI.yellow}${s}${ANSI.reset}`;
9019
9387
  }
9020
- var TAG34 = "daemon", RULE_WIDTH = 70, ANSI;
9388
+ var TAG35 = "daemon", RULE_WIDTH = 70, ANSI;
9021
9389
  var init_startup_banner = __esm(() => {
9022
9390
  init_log();
9023
9391
  ANSI = {
@@ -9168,13 +9536,13 @@ class Watcher {
9168
9536
  }
9169
9537
  async start() {
9170
9538
  if (!isPretty()) {
9171
- log.info(TAG35, "Connecting to Supabase realtime (broadcast)...");
9539
+ log.info(TAG36, "Connecting to Supabase realtime (broadcast)...");
9172
9540
  }
9173
9541
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
9174
9542
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
9175
9543
  this.subscribeBroadcast();
9176
9544
  presenceChannel.on("presence", { event: "sync" }, () => {
9177
- log.debug(TAG35, "Presence sync");
9545
+ log.debug(TAG36, "Presence sync");
9178
9546
  }).subscribe(async (status) => {
9179
9547
  if (status === "SUBSCRIBED") {
9180
9548
  await presenceChannel.track({
@@ -9187,7 +9555,7 @@ class Watcher {
9187
9555
  agentName: this.identity.agentName
9188
9556
  });
9189
9557
  if (!isPretty() || !this.suppressStartupLogs) {
9190
- log.info(TAG35, "Presence tracked on board-presence channel");
9558
+ log.info(TAG36, "Presence tracked on board-presence channel");
9191
9559
  }
9192
9560
  this.presenceTracked = true;
9193
9561
  this.maybeResolveReady();
@@ -9200,13 +9568,13 @@ class Watcher {
9200
9568
  return;
9201
9569
  const gen = ++this.broadcastGen;
9202
9570
  this.channel = this.supabase.channel(`board-${this.projectId}`).on("broadcast", { event: "card_update" }, (msg) => {
9203
- log.debug(TAG35, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
9571
+ log.debug(TAG36, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
9204
9572
  this.onCardBroadcast({
9205
9573
  event: "card_update",
9206
9574
  payload: msg.payload ?? {}
9207
9575
  });
9208
9576
  }).on("broadcast", { event: "card_created" }, (msg) => {
9209
- log.debug(TAG35, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9577
+ log.debug(TAG36, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
9210
9578
  this.onCardBroadcast({
9211
9579
  event: "card_created",
9212
9580
  payload: msg.payload ?? {}
@@ -9216,7 +9584,7 @@ class Watcher {
9216
9584
  const cardId = payload.card_id;
9217
9585
  const command = payload.command;
9218
9586
  if (cardId && command) {
9219
- log.info(TAG35, `Broadcast: agent_command ${command} for ${cardId}`);
9587
+ log.info(TAG36, `Broadcast: agent_command ${command} for ${cardId}`);
9220
9588
  this.onAgentCommand?.({ cardId, command });
9221
9589
  }
9222
9590
  }).subscribe((status) => {
@@ -9226,13 +9594,13 @@ class Watcher {
9226
9594
  this.connected = true;
9227
9595
  this.reconnectAttempts = 0;
9228
9596
  if (!isPretty() || !this.suppressStartupLogs) {
9229
- log.info(TAG35, "Broadcast subscription active");
9597
+ log.info(TAG36, "Broadcast subscription active");
9230
9598
  }
9231
9599
  this.maybeResolveReady();
9232
9600
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
9233
9601
  this.connected = false;
9234
9602
  if (!this.stopping) {
9235
- log.warn(TAG35, `Broadcast subscription ${status} — scheduling reconnect`);
9603
+ log.warn(TAG36, `Broadcast subscription ${status} — scheduling reconnect`);
9236
9604
  this.scheduleReconnect();
9237
9605
  }
9238
9606
  }
@@ -9251,7 +9619,7 @@ class Watcher {
9251
9619
  async reconnectBroadcast() {
9252
9620
  if (this.stopping || !this.supabase)
9253
9621
  return;
9254
- log.warn(TAG35, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9622
+ log.warn(TAG36, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
9255
9623
  if (this.channel) {
9256
9624
  const old = this.channel;
9257
9625
  this.channel = null;
@@ -9281,10 +9649,10 @@ class Watcher {
9281
9649
  this.supabase = null;
9282
9650
  }
9283
9651
  this.connected = false;
9284
- log.info(TAG35, "Broadcast subscription stopped");
9652
+ log.info(TAG36, "Broadcast subscription stopped");
9285
9653
  }
9286
9654
  }
9287
- var TAG35 = "watcher";
9655
+ var TAG36 = "watcher";
9288
9656
  var init_watcher = __esm(() => {
9289
9657
  init_log();
9290
9658
  });
@@ -9371,10 +9739,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
9371
9739
  });
9372
9740
  } catch {}
9373
9741
  if (result.removed.length > 0) {
9374
- 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(", ")}`);
9375
9743
  }
9376
9744
  if (result.errors.length > 0) {
9377
- 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("; ")}`);
9378
9746
  }
9379
9747
  return result;
9380
9748
  }
@@ -9404,7 +9772,7 @@ function pruneFailedRemoteBranches(opts) {
9404
9772
  } catch (err) {
9405
9773
  const detail = gitErrorDetail2(err);
9406
9774
  if (isTransientGitNetworkError(detail)) {
9407
- log.debug(TAG36, `Remote branch GC skipped — remote unreachable: ${detail}`);
9775
+ log.debug(TAG37, `Remote branch GC skipped — remote unreachable: ${detail}`);
9408
9776
  return result;
9409
9777
  }
9410
9778
  result.errors.push({ ref: "fetch", error: detail });
@@ -9443,7 +9811,7 @@ function pruneFailedRemoteBranches(opts) {
9443
9811
  continue;
9444
9812
  }
9445
9813
  if (clock() > sweepDeadline) {
9446
- 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`);
9447
9815
  break;
9448
9816
  }
9449
9817
  try {
@@ -9456,17 +9824,17 @@ function pruneFailedRemoteBranches(opts) {
9456
9824
  } catch (err) {
9457
9825
  const detail = gitErrorDetail2(err);
9458
9826
  if (isTransientGitNetworkError(detail)) {
9459
- log.debug(TAG36, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9827
+ log.debug(TAG37, `Remote branch GC interrupted — remote unreachable: ${detail}`);
9460
9828
  break;
9461
9829
  }
9462
9830
  result.errors.push({ ref, error: detail });
9463
9831
  }
9464
9832
  }
9465
9833
  if (result.removed.length > 0) {
9466
- 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(", ")}`);
9467
9835
  }
9468
9836
  if (result.errors.length > 0) {
9469
- 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("; ")}`);
9470
9838
  }
9471
9839
  return result;
9472
9840
  }
@@ -9497,13 +9865,13 @@ class WorktreeGc {
9497
9865
  try {
9498
9866
  runWorktreeGc(this.basePath, this.store);
9499
9867
  } catch (err) {
9500
- 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}`);
9501
9869
  }
9502
9870
  if (this.remoteOpts) {
9503
9871
  try {
9504
9872
  pruneFailedRemoteBranches(this.remoteOpts);
9505
9873
  } catch (err) {
9506
- 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}`);
9507
9875
  }
9508
9876
  }
9509
9877
  }
@@ -9517,7 +9885,7 @@ function getRepoRoot2() {
9517
9885
  return null;
9518
9886
  }
9519
9887
  }
9520
- 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;
9521
9889
  var init_worktree_gc = __esm(() => {
9522
9890
  init_log();
9523
9891
  init_worktree();
@@ -9621,7 +9989,7 @@ async function main() {
9621
9989
  } catch (err) {
9622
9990
  if (err instanceof ConfigValidationError) {
9623
9991
  banner.fail();
9624
- log.error(TAG37, err.message);
9992
+ log.error(TAG38, err.message);
9625
9993
  process.exit(1);
9626
9994
  }
9627
9995
  throw err;
@@ -9731,7 +10099,7 @@ async function main() {
9731
10099
  if (shuttingDown)
9732
10100
  return;
9733
10101
  shuttingDown = true;
9734
- log.info(TAG37, `Received ${signal}, shutting down gracefully...`);
10102
+ log.info(TAG38, `Received ${signal}, shutting down gracefully...`);
9735
10103
  reconciler.stop();
9736
10104
  mergeMonitor?.stop();
9737
10105
  worktreeGc.stop();
@@ -9741,18 +10109,18 @@ async function main() {
9741
10109
  }
9742
10110
  await watcher.stop();
9743
10111
  await pool.shutdown();
9744
- log.info(TAG37, "Daemon stopped.");
10112
+ log.info(TAG38, "Daemon stopped.");
9745
10113
  process.exit(exitCode);
9746
10114
  };
9747
10115
  process.on("SIGINT", () => shutdown("SIGINT"));
9748
10116
  process.on("SIGTERM", () => shutdown("SIGTERM"));
9749
10117
  process.on("uncaughtException", (err) => {
9750
- log.error(TAG37, `Uncaught exception: ${err.message}`);
10118
+ log.error(TAG38, `Uncaught exception: ${err.message}`);
9751
10119
  exitCode = 1;
9752
10120
  shutdown("uncaughtException");
9753
10121
  });
9754
10122
  process.on("unhandledRejection", (reason) => {
9755
- 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)}`);
9756
10124
  exitCode = 1;
9757
10125
  shutdown("unhandledRejection");
9758
10126
  });
@@ -9805,29 +10173,29 @@ async function handleBroadcast(event, client, pool, config, agentId) {
9805
10173
  if (assignedAgentId === undefined)
9806
10174
  return;
9807
10175
  if (assignedAgentId === agentId) {
9808
- log.info(TAG37, `Broadcast: card ${cardId} assigned to agent`);
10176
+ log.info(TAG38, `Broadcast: card ${cardId} assigned to agent`);
9809
10177
  try {
9810
10178
  await pool.resetAttemptsForReassign(cardId);
9811
10179
  await tryEnqueueCard(cardId, client, pool, config, agentId);
9812
10180
  } catch (err) {
9813
- 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}`);
9814
10182
  }
9815
10183
  } else if (pool.isCardKnown(cardId)) {
9816
- log.info(TAG37, `Broadcast: card ${cardId} unassigned from agent`);
10184
+ log.info(TAG38, `Broadcast: card ${cardId} unassigned from agent`);
9817
10185
  await pool.removeCard(cardId);
9818
10186
  }
9819
10187
  }
9820
10188
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9821
10189
  const { card } = await client.getCard(cardId);
9822
10190
  if (card.assigned_agent_id !== agentId) {
9823
- log.debug(TAG37, `Card ${cardId} no longer assigned to agent — skipping`);
10191
+ log.debug(TAG38, `Card ${cardId} no longer assigned to agent — skipping`);
9824
10192
  return;
9825
10193
  }
9826
10194
  const board = await client.getBoard(config.projectId, { summary: true });
9827
10195
  const columns = board.columns;
9828
10196
  const column = columns.find((c) => c.id === card.column_id);
9829
10197
  if (!column) {
9830
- log.warn(TAG37, `Column not found for card ${cardId}`);
10198
+ log.warn(TAG38, `Column not found for card ${cardId}`);
9831
10199
  return;
9832
10200
  }
9833
10201
  const route = classifyPickup(card, column.name, {
@@ -9836,27 +10204,27 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
9836
10204
  playbooks: config.agent.playbooks
9837
10205
  });
9838
10206
  if (!route) {
9839
- 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`);
9840
10208
  return;
9841
10209
  }
9842
10210
  if (route.stage) {
9843
- 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`);
9844
10212
  }
9845
10213
  const mode = route.mode;
9846
10214
  const labelMap = buildLabelMap(board.labels ?? []);
9847
10215
  const cardLabels = resolveCardLabels(card, labelMap);
9848
10216
  const subtasks = card.subtasks ?? [];
9849
10217
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
9850
- 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`);
9851
10219
  return;
9852
10220
  }
9853
10221
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
9854
- 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`);
9855
10223
  return;
9856
10224
  }
9857
10225
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
9858
10226
  }
9859
- var TAG37 = "daemon", PKG_VERSION;
10227
+ var TAG38 = "daemon", PKG_VERSION;
9860
10228
  var init_src = __esm(() => {
9861
10229
  init_board_helpers();
9862
10230
  init_config();
@@ -9890,6 +10258,7 @@ Usage:
9890
10258
  harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
9891
10259
  harmony-agent doctor Run preflight checks (don't start)
9892
10260
  harmony-agent gc One-shot worktree garbage collection
10261
+ harmony-agent recover One-shot recovery of stranded Review cards
9893
10262
  harmony-agent help Show this help
9894
10263
 
9895
10264
  Flags:
@@ -10028,6 +10397,53 @@ async function gcCommand() {
10028
10397
  }
10029
10398
  return 0;
10030
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
+ }
10031
10447
  async function dispatch(argv) {
10032
10448
  const args = argv.filter((a) => a !== "--pretty" && a !== "--json");
10033
10449
  const cmd = args[0];
@@ -10045,6 +10461,8 @@ async function dispatch(argv) {
10045
10461
  return doctorCommand();
10046
10462
  case "gc":
10047
10463
  return gcCommand();
10464
+ case "recover":
10465
+ return recoverCommand();
10048
10466
  case "help":
10049
10467
  case "--help":
10050
10468
  case "-h":