@gethmy/agent 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +95 -17
  2. package/dist/index.js +95 -17
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1666,6 +1666,9 @@ function getStageLoop(stage) {
1666
1666
  function isConvergeLoop(loop) {
1667
1667
  return loop !== null && loop.mode === "converge";
1668
1668
  }
1669
+ function resolveLoopExitGate(stage, loop) {
1670
+ return loop.exit_gate ?? stage.gate ?? null;
1671
+ }
1669
1672
  function decideLoopContinuation(args) {
1670
1673
  const { loop, gatePassed, hasExitGate, completedIterations } = args;
1671
1674
  const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -1714,7 +1717,10 @@ function entryActionAllowlist(entryAction) {
1714
1717
  return `mcp__${entryAction}`;
1715
1718
  return null;
1716
1719
  }
1717
- var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1720
+ function stageDisallowedTools() {
1721
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1722
+ }
1723
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1718
1724
  var init_playbookStage = __esm(() => {
1719
1725
  SKILL_TOOL_ALLOWLIST = {
1720
1726
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -1725,6 +1731,11 @@ var init_playbookStage = __esm(() => {
1725
1731
  "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1726
1732
  };
1727
1733
  HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1734
+ STAGE_DAEMON_OWNED_TOOLS = [
1735
+ "mcp__harmony__harmony_end_agent_session",
1736
+ "mcp__harmony__harmony_start_agent_session",
1737
+ "mcp__harmony__harmony_move_card"
1738
+ ];
1728
1739
  });
1729
1740
 
1730
1741
  // ../harmony-shared/dist/projectTemplates.js
@@ -2159,6 +2170,58 @@ function cleanupWorktree(worktreePath, branchName) {
2159
2170
  } catch {}
2160
2171
  }
2161
2172
  }
2173
+ function resolveRepoRoot() {
2174
+ return execFileSync3("git", ["rev-parse", "--show-toplevel"], {
2175
+ encoding: "utf-8"
2176
+ }).trim();
2177
+ }
2178
+ function branchAheadOfItsRemote(branchName, repoRoot = resolveRepoRoot()) {
2179
+ try {
2180
+ const out = execFileSync3("git", ["rev-list", branchName, "--not", "--remotes=origin"], { cwd: repoRoot, encoding: "utf-8" }).trim();
2181
+ return out.length > 0;
2182
+ } catch {
2183
+ return false;
2184
+ }
2185
+ }
2186
+ async function rescueUnpushedBranch(client, cardId, branchName, repoRoot = resolveRepoRoot()) {
2187
+ const { getBranchWebUrl: getBranchWebUrl2, pushBranch: pushBranch2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
2188
+ try {
2189
+ pushBranch2(branchName, repoRoot);
2190
+ } catch (err) {
2191
+ log.error(TAG5, `push-rescue failed for ${branchName} — leaving local branch ref intact (recoverable via git reflog / the local branch): ${err instanceof Error ? err.message : err}`);
2192
+ return false;
2193
+ }
2194
+ log.warn(TAG5, `push-rescued unpushed branch ${branchName} to origin before teardown`);
2195
+ try {
2196
+ const url = getBranchWebUrl2(branchName, repoRoot);
2197
+ const recover = url ? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\`` : `Recover it locally: \`git fetch && git checkout ${branchName}\``;
2198
+ const body = `⚠ Run ended before completion. Committed work was push-rescued to ` + `\`origin/${branchName}\` so it isn't lost. ${recover}`;
2199
+ await client.addComment(cardId, body, { commentType: "message" });
2200
+ } catch (err) {
2201
+ log.warn(TAG5, `push-rescue comment failed for ${branchName} (work is still safe on origin): ${err instanceof Error ? err.message : err}`);
2202
+ }
2203
+ return true;
2204
+ }
2205
+ async function teardownWorktree(client, cardId, worktreePath, branchName) {
2206
+ let skipBranchDelete = false;
2207
+ if (branchName && cardId) {
2208
+ let repoRoot;
2209
+ try {
2210
+ repoRoot = resolveRepoRoot();
2211
+ } catch {
2212
+ cleanupWorktree(worktreePath, branchName);
2213
+ return;
2214
+ }
2215
+ if (branchAheadOfItsRemote(branchName, repoRoot)) {
2216
+ const ok = await rescueUnpushedBranch(client, cardId, branchName, repoRoot);
2217
+ if (!ok) {
2218
+ skipBranchDelete = true;
2219
+ log.error(TAG5, `Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`);
2220
+ }
2221
+ }
2222
+ }
2223
+ cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
2224
+ }
2162
2225
  function makeBranchName(shortId, title, prefix = "agent-attempts/") {
2163
2226
  const slug = title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2164
2227
  return `${prefix}${shortId}-${slug || "task"}`;
@@ -3509,7 +3572,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3509
3572
  failureSummary,
3510
3573
  ...buildTokenPayload(sessionStats)
3511
3574
  });
3512
- cleanupWorktree(worktreePath, branchName);
3575
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3513
3576
  return false;
3514
3577
  }
3515
3578
  log.info(TAG14, `Pushing branch ${branchName} (pre-verify)...`);
@@ -3591,7 +3654,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3591
3654
  recoveryBranch: branchName,
3592
3655
  ...buildTokenPayload(sessionStats)
3593
3656
  });
3594
- cleanupWorktree(worktreePath, branchName);
3657
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3595
3658
  return false;
3596
3659
  }
3597
3660
  log.info(TAG14, `Verification passed for #${card.short_id}`);
@@ -3647,7 +3710,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3647
3710
  log.warn(TAG14, `onBeforeWorktreeCleanup hook failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3648
3711
  }
3649
3712
  }
3650
- cleanupWorktree(worktreePath, branchName);
3713
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3651
3714
  log.info(TAG14, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
3652
3715
  return true;
3653
3716
  }
@@ -3967,6 +4030,7 @@ class SdkAgentRunner {
3967
4030
  cwd: input.cwd,
3968
4031
  model: input.model ?? this.cfg.model,
3969
4032
  allowedTools: allowed,
4033
+ ...this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0 ? { disallowedTools: this.cfg.disallowedTools } : {},
3970
4034
  tools: builtinTools,
3971
4035
  permissionMode: "dontAsk",
3972
4036
  maxTurns: this.cfg.maxTurns,
@@ -6918,7 +6982,7 @@ async function buildPrompt(enriched, branchName, worktreePath, client, workspace
6918
6982
  variant: "execute",
6919
6983
  customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6920
6984
  Do NOT push to main. All your work stays on \`${branchName}\`.
6921
- When finished, call harmony_end_agent_session with status="completed".`
6985
+ The daemon owns the run lifecycle: once your work is committed it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this work tells you to move the card or end the session as a final step, SKIP it — it is handled for you (those tools are disabled for this run). Finish the implementation, commit, and stop.`
6922
6986
  });
6923
6987
  log.info(TAG27, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6924
6988
  return result.prompt + pastEpisodesSection;
@@ -7028,7 +7092,7 @@ ${subtaskStr}
7028
7092
  Include a brief currentTask description.
7029
7093
  3. Implement the changes on branch \`${branchName}\`
7030
7094
  4. Commit your work with clear, descriptive commit messages
7031
- 5. When finished, call harmony_end_agent_session with status="completed"
7095
+ 5. When the work is committed, STOP. The daemon owns the run lifecycle: it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself — those tools are disabled for this run.
7032
7096
 
7033
7097
  You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
7034
7098
  Do NOT push to main. All your work stays on \`${branchName}\`.`;
@@ -7077,7 +7141,7 @@ function firstErrorMessage(evaluation) {
7077
7141
  return e ? e.message : null;
7078
7142
  }
7079
7143
  async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7080
- const hasExitGate = loop.exit_gate != null;
7144
+ const hasExitGate = resolveLoopExitGate(stage, loop) != null;
7081
7145
  const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7082
7146
  const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7083
7147
  const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -7330,7 +7394,7 @@ function buildStagePreamble(stage) {
7330
7394
  lines.push(`Hand off when done: ${summary.trim()}`);
7331
7395
  }
7332
7396
  }
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.");
7397
+ lines.push("Do only this stage's work, then stop. The daemon owns the run lifecycle here: it ends the agent session and performs every card move and stage advancement automatically once your stage work is done. Do NOT call `harmony_end_agent_session`, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this stage tells you to move the card (e.g. to Review) or end your session as a final step, SKIP that step — it is handled for you. Those tools are disabled for this run, so attempting them only wastes turns. Finish the stage's work and stop.");
7334
7398
  if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7335
7399
  lines.push("", "**This stage's gate is `review_passed` — your deliverable is a review verdict, not a prose handoff.** Do NOT end the session until you have actually reviewed the change and emitted EXACTLY one JSON block as the LAST thing you output (nothing after it):", "```json", REVIEW_VERDICT_SCHEMA, "```", "Decision rules:", REVIEW_DECISION_RULES, "The board reads this verdict to decide advancement: `approved` advances the card, `rejected` sends it back. A missing or unparseable verdict blocks the card. Do NOT modify any code — this is a read-only review.");
7336
7400
  }
@@ -7343,6 +7407,13 @@ function buildSteeringPrompt(messages) {
7343
7407
  return messages.map((m, i) => `${i + 1}. ${m}`).join(`
7344
7408
  `);
7345
7409
  }
7410
+ function computeRunSpawnGating(stageAllowedTools) {
7411
+ const denylist = stageDisallowedTools();
7412
+ return {
7413
+ ...stageAllowedTools ? { allowedTools: stageAllowedTools } : {},
7414
+ ...denylist ? { disallowedTools: denylist } : {}
7415
+ };
7416
+ }
7346
7417
 
7347
7418
  class Worker {
7348
7419
  config;
@@ -7371,6 +7442,7 @@ class Worker {
7371
7442
  timedOut = false;
7372
7443
  verificationFailed = false;
7373
7444
  held = false;
7445
+ activeRunSpawnOpts = null;
7374
7446
  completionStarted = false;
7375
7447
  sessionId = null;
7376
7448
  runId = null;
@@ -7450,6 +7522,7 @@ class Worker {
7450
7522
  this.lastRunText = "";
7451
7523
  this.cliSessionId = null;
7452
7524
  this.lastDrainedSeq = 0;
7525
+ this.activeRunSpawnOpts = null;
7453
7526
  this.cardId = card.id;
7454
7527
  this.startedAt = Date.now();
7455
7528
  this.runId = newRunId();
@@ -7570,9 +7643,10 @@ class Worker {
7570
7643
  this.timedOut = true;
7571
7644
  this.cancel();
7572
7645
  }, this.config.maxTimeout);
7646
+ this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
7573
7647
  await this.spawnClaude(prompt, card, subtasks, {
7574
7648
  model: this.selectImplementModel(card),
7575
- ...stageCtx.kind === "run" ? { allowedTools: stageCtx.allowedTools } : {}
7649
+ ...this.activeRunSpawnOpts ?? {}
7576
7650
  });
7577
7651
  if (this.aborted)
7578
7652
  return;
@@ -7647,7 +7721,7 @@ class Worker {
7647
7721
  }
7648
7722
  if (this.worktreePath) {
7649
7723
  try {
7650
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7724
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7651
7725
  } catch {
7652
7726
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7653
7727
  }
@@ -7691,7 +7765,7 @@ class Worker {
7691
7765
  } else if (this.runId && this.timedOut) {
7692
7766
  if (this.worktreePath) {
7693
7767
  try {
7694
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7768
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7695
7769
  } catch {
7696
7770
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7697
7771
  }
@@ -7781,7 +7855,7 @@ class Worker {
7781
7855
  await this.cliRunner.flushFinal();
7782
7856
  } catch {}
7783
7857
  }
7784
- this.cleanup();
7858
+ await this.cleanup();
7785
7859
  this.state = "idle";
7786
7860
  this.onDone(this);
7787
7861
  }
@@ -7907,7 +7981,7 @@ class Worker {
7907
7981
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7908
7982
  try {
7909
7983
  const loop = getStageLoop(stage);
7910
- const gateSource = isConvergeLoop(loop) ? loop?.exit_gate ?? null : stage.gate;
7984
+ const gateSource = isConvergeLoop(loop) && loop ? resolveLoopExitGate(stage, loop) : stage.gate;
7911
7985
  const gate = normalizeGateSpec(gateSource);
7912
7986
  if (!gate) {
7913
7987
  return null;
@@ -8205,7 +8279,8 @@ class Worker {
8205
8279
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => m.text)), card, subtasks, {
8206
8280
  model: this.selectImplementModel(card),
8207
8281
  maxTurns: STEERING_MAX_TURNS,
8208
- resumeSessionId: this.cliSessionId
8282
+ resumeSessionId: this.cliSessionId,
8283
+ ...this.activeRunSpawnOpts ?? {}
8209
8284
  });
8210
8285
  } catch (err) {
8211
8286
  log.warn(this.tag, `Steering resume failed (non-fatal): ${err instanceof Error ? err.message : err}`);
@@ -8233,6 +8308,7 @@ class Worker {
8233
8308
  String(maxTurns),
8234
8309
  "--allowedTools",
8235
8310
  allowedTools,
8311
+ ...opts.disallowedTools ? ["--disallowedTools", opts.disallowedTools] : [],
8236
8312
  ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
8237
8313
  ...this.config.claude.additionalArgs,
8238
8314
  "--",
@@ -8320,6 +8396,7 @@ class Worker {
8320
8396
  const model = opts.model ?? this.config.claude.model;
8321
8397
  const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
8322
8398
  const allowedTools = (opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS).split(",").map((t) => t.trim()).filter(Boolean);
8399
+ const disallowedTools = opts.disallowedTools ? opts.disallowedTools.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
8323
8400
  const initialPhase = opts.initialPhase ?? "exploring";
8324
8401
  const sdkCfg = this.config.sdk;
8325
8402
  log.info(this.tag, `Spawning Agent SDK runner (model=${model}, maxTurns=${maxTurns}${opts.resumeSessionId ? ", resume" : ""})`);
@@ -8339,6 +8416,7 @@ class Worker {
8339
8416
  model,
8340
8417
  maxTurns,
8341
8418
  allowedTools,
8419
+ ...disallowedTools ? { disallowedTools } : {},
8342
8420
  maxBudgetUsd: sdkCfg?.maxBudgetUsd,
8343
8421
  settingSources: sdkCfg?.settingSources,
8344
8422
  mcpServers: sdkCfg?.mcpServers,
@@ -8405,7 +8483,7 @@ class Worker {
8405
8483
  throw err;
8406
8484
  }
8407
8485
  }
8408
- cleanup() {
8486
+ async cleanup() {
8409
8487
  if (this.progressTracker) {
8410
8488
  this.progressTracker.stop();
8411
8489
  this.progressTracker = null;
@@ -8423,7 +8501,7 @@ class Worker {
8423
8501
  }
8424
8502
  if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8425
8503
  try {
8426
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
8504
+ await teardownWorktree(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8427
8505
  } catch {
8428
8506
  log.warn(this.tag, "Failed to cleanup worktree");
8429
8507
  }
@@ -8904,7 +8982,7 @@ async function recoverRun(run, store, client, config, outcome) {
8904
8982
  }
8905
8983
  if (run.worktreePath) {
8906
8984
  try {
8907
- cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
8985
+ await teardownWorktree(client, run.cardId, run.worktreePath, run.branchName ?? undefined);
8908
8986
  outcome.actions.push("cleaned up worktree");
8909
8987
  } catch (err) {
8910
8988
  const msg = err instanceof Error ? err.message : String(err);
package/dist/index.js CHANGED
@@ -1665,6 +1665,9 @@ function getStageLoop(stage) {
1665
1665
  function isConvergeLoop(loop) {
1666
1666
  return loop !== null && loop.mode === "converge";
1667
1667
  }
1668
+ function resolveLoopExitGate(stage, loop) {
1669
+ return loop.exit_gate ?? stage.gate ?? null;
1670
+ }
1668
1671
  function decideLoopContinuation(args) {
1669
1672
  const { loop, gatePassed, hasExitGate, completedIterations } = args;
1670
1673
  const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -1713,7 +1716,10 @@ function entryActionAllowlist(entryAction) {
1713
1716
  return `mcp__${entryAction}`;
1714
1717
  return null;
1715
1718
  }
1716
- var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1719
+ function stageDisallowedTools() {
1720
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1721
+ }
1722
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1717
1723
  var init_playbookStage = __esm(() => {
1718
1724
  SKILL_TOOL_ALLOWLIST = {
1719
1725
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -1724,6 +1730,11 @@ var init_playbookStage = __esm(() => {
1724
1730
  "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1725
1731
  };
1726
1732
  HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1733
+ STAGE_DAEMON_OWNED_TOOLS = [
1734
+ "mcp__harmony__harmony_end_agent_session",
1735
+ "mcp__harmony__harmony_start_agent_session",
1736
+ "mcp__harmony__harmony_move_card"
1737
+ ];
1727
1738
  });
1728
1739
 
1729
1740
  // ../harmony-shared/dist/projectTemplates.js
@@ -2158,6 +2169,58 @@ function cleanupWorktree(worktreePath, branchName) {
2158
2169
  } catch {}
2159
2170
  }
2160
2171
  }
2172
+ function resolveRepoRoot() {
2173
+ return execFileSync3("git", ["rev-parse", "--show-toplevel"], {
2174
+ encoding: "utf-8"
2175
+ }).trim();
2176
+ }
2177
+ function branchAheadOfItsRemote(branchName, repoRoot = resolveRepoRoot()) {
2178
+ try {
2179
+ const out = execFileSync3("git", ["rev-list", branchName, "--not", "--remotes=origin"], { cwd: repoRoot, encoding: "utf-8" }).trim();
2180
+ return out.length > 0;
2181
+ } catch {
2182
+ return false;
2183
+ }
2184
+ }
2185
+ async function rescueUnpushedBranch(client, cardId, branchName, repoRoot = resolveRepoRoot()) {
2186
+ const { getBranchWebUrl: getBranchWebUrl2, pushBranch: pushBranch2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
2187
+ try {
2188
+ pushBranch2(branchName, repoRoot);
2189
+ } catch (err) {
2190
+ log.error(TAG5, `push-rescue failed for ${branchName} — leaving local branch ref intact (recoverable via git reflog / the local branch): ${err instanceof Error ? err.message : err}`);
2191
+ return false;
2192
+ }
2193
+ log.warn(TAG5, `push-rescued unpushed branch ${branchName} to origin before teardown`);
2194
+ try {
2195
+ const url = getBranchWebUrl2(branchName, repoRoot);
2196
+ const recover = url ? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\`` : `Recover it locally: \`git fetch && git checkout ${branchName}\``;
2197
+ const body = `⚠ Run ended before completion. Committed work was push-rescued to ` + `\`origin/${branchName}\` so it isn't lost. ${recover}`;
2198
+ await client.addComment(cardId, body, { commentType: "message" });
2199
+ } catch (err) {
2200
+ log.warn(TAG5, `push-rescue comment failed for ${branchName} (work is still safe on origin): ${err instanceof Error ? err.message : err}`);
2201
+ }
2202
+ return true;
2203
+ }
2204
+ async function teardownWorktree(client, cardId, worktreePath, branchName) {
2205
+ let skipBranchDelete = false;
2206
+ if (branchName && cardId) {
2207
+ let repoRoot;
2208
+ try {
2209
+ repoRoot = resolveRepoRoot();
2210
+ } catch {
2211
+ cleanupWorktree(worktreePath, branchName);
2212
+ return;
2213
+ }
2214
+ if (branchAheadOfItsRemote(branchName, repoRoot)) {
2215
+ const ok = await rescueUnpushedBranch(client, cardId, branchName, repoRoot);
2216
+ if (!ok) {
2217
+ skipBranchDelete = true;
2218
+ log.error(TAG5, `Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`);
2219
+ }
2220
+ }
2221
+ }
2222
+ cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
2223
+ }
2161
2224
  function makeBranchName(shortId, title, prefix = "agent-attempts/") {
2162
2225
  const slug = title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2163
2226
  return `${prefix}${shortId}-${slug || "task"}`;
@@ -3508,7 +3571,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3508
3571
  failureSummary,
3509
3572
  ...buildTokenPayload(sessionStats)
3510
3573
  });
3511
- cleanupWorktree(worktreePath, branchName);
3574
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3512
3575
  return false;
3513
3576
  }
3514
3577
  log.info(TAG14, `Pushing branch ${branchName} (pre-verify)...`);
@@ -3590,7 +3653,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3590
3653
  recoveryBranch: branchName,
3591
3654
  ...buildTokenPayload(sessionStats)
3592
3655
  });
3593
- cleanupWorktree(worktreePath, branchName);
3656
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3594
3657
  return false;
3595
3658
  }
3596
3659
  log.info(TAG14, `Verification passed for #${card.short_id}`);
@@ -3646,7 +3709,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3646
3709
  log.warn(TAG14, `onBeforeWorktreeCleanup hook failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3647
3710
  }
3648
3711
  }
3649
- cleanupWorktree(worktreePath, branchName);
3712
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3650
3713
  log.info(TAG14, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
3651
3714
  return true;
3652
3715
  }
@@ -3966,6 +4029,7 @@ class SdkAgentRunner {
3966
4029
  cwd: input.cwd,
3967
4030
  model: input.model ?? this.cfg.model,
3968
4031
  allowedTools: allowed,
4032
+ ...this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0 ? { disallowedTools: this.cfg.disallowedTools } : {},
3969
4033
  tools: builtinTools,
3970
4034
  permissionMode: "dontAsk",
3971
4035
  maxTurns: this.cfg.maxTurns,
@@ -6917,7 +6981,7 @@ async function buildPrompt(enriched, branchName, worktreePath, client, workspace
6917
6981
  variant: "execute",
6918
6982
  customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6919
6983
  Do NOT push to main. All your work stays on \`${branchName}\`.
6920
- When finished, call harmony_end_agent_session with status="completed".`
6984
+ The daemon owns the run lifecycle: once your work is committed it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this work tells you to move the card or end the session as a final step, SKIP it — it is handled for you (those tools are disabled for this run). Finish the implementation, commit, and stop.`
6921
6985
  });
6922
6986
  log.info(TAG27, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6923
6987
  return result.prompt + pastEpisodesSection;
@@ -7027,7 +7091,7 @@ ${subtaskStr}
7027
7091
  Include a brief currentTask description.
7028
7092
  3. Implement the changes on branch \`${branchName}\`
7029
7093
  4. Commit your work with clear, descriptive commit messages
7030
- 5. When finished, call harmony_end_agent_session with status="completed"
7094
+ 5. When the work is committed, STOP. The daemon owns the run lifecycle: it ends the agent session, pushes the branch, and moves the card to Review for you. Do NOT call harmony_end_agent_session, do NOT start a new session, and do NOT move the card or change its column yourself — those tools are disabled for this run.
7031
7095
 
7032
7096
  You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
7033
7097
  Do NOT push to main. All your work stays on \`${branchName}\`.`;
@@ -7076,7 +7140,7 @@ function firstErrorMessage(evaluation) {
7076
7140
  return e ? e.message : null;
7077
7141
  }
7078
7142
  async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7079
- const hasExitGate = loop.exit_gate != null;
7143
+ const hasExitGate = resolveLoopExitGate(stage, loop) != null;
7080
7144
  const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7081
7145
  const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7082
7146
  const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -7329,7 +7393,7 @@ function buildStagePreamble(stage) {
7329
7393
  lines.push(`Hand off when done: ${summary.trim()}`);
7330
7394
  }
7331
7395
  }
7332
- lines.push("Do only this stage's work. When the stage's handoff is met, end your session advancement to the next stage is handled by the board.");
7396
+ lines.push("Do only this stage's work, then stop. The daemon owns the run lifecycle here: it ends the agent session and performs every card move and stage advancement automatically once your stage work is done. Do NOT call `harmony_end_agent_session`, do NOT start a new session, and do NOT move the card or change its column yourself. If the skill driving this stage tells you to move the card (e.g. to Review) or end your session as a final step, SKIP that step — it is handled for you. Those tools are disabled for this run, so attempting them only wastes turns. Finish the stage's work and stop.");
7333
7397
  if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7334
7398
  lines.push("", "**This stage's gate is `review_passed` — your deliverable is a review verdict, not a prose handoff.** Do NOT end the session until you have actually reviewed the change and emitted EXACTLY one JSON block as the LAST thing you output (nothing after it):", "```json", REVIEW_VERDICT_SCHEMA, "```", "Decision rules:", REVIEW_DECISION_RULES, "The board reads this verdict to decide advancement: `approved` advances the card, `rejected` sends it back. A missing or unparseable verdict blocks the card. Do NOT modify any code — this is a read-only review.");
7335
7399
  }
@@ -7342,6 +7406,13 @@ function buildSteeringPrompt(messages) {
7342
7406
  return messages.map((m, i) => `${i + 1}. ${m}`).join(`
7343
7407
  `);
7344
7408
  }
7409
+ function computeRunSpawnGating(stageAllowedTools) {
7410
+ const denylist = stageDisallowedTools();
7411
+ return {
7412
+ ...stageAllowedTools ? { allowedTools: stageAllowedTools } : {},
7413
+ ...denylist ? { disallowedTools: denylist } : {}
7414
+ };
7415
+ }
7345
7416
 
7346
7417
  class Worker {
7347
7418
  config;
@@ -7370,6 +7441,7 @@ class Worker {
7370
7441
  timedOut = false;
7371
7442
  verificationFailed = false;
7372
7443
  held = false;
7444
+ activeRunSpawnOpts = null;
7373
7445
  completionStarted = false;
7374
7446
  sessionId = null;
7375
7447
  runId = null;
@@ -7449,6 +7521,7 @@ class Worker {
7449
7521
  this.lastRunText = "";
7450
7522
  this.cliSessionId = null;
7451
7523
  this.lastDrainedSeq = 0;
7524
+ this.activeRunSpawnOpts = null;
7452
7525
  this.cardId = card.id;
7453
7526
  this.startedAt = Date.now();
7454
7527
  this.runId = newRunId();
@@ -7569,9 +7642,10 @@ class Worker {
7569
7642
  this.timedOut = true;
7570
7643
  this.cancel();
7571
7644
  }, this.config.maxTimeout);
7645
+ this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
7572
7646
  await this.spawnClaude(prompt, card, subtasks, {
7573
7647
  model: this.selectImplementModel(card),
7574
- ...stageCtx.kind === "run" ? { allowedTools: stageCtx.allowedTools } : {}
7648
+ ...this.activeRunSpawnOpts ?? {}
7575
7649
  });
7576
7650
  if (this.aborted)
7577
7651
  return;
@@ -7646,7 +7720,7 @@ class Worker {
7646
7720
  }
7647
7721
  if (this.worktreePath) {
7648
7722
  try {
7649
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7723
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7650
7724
  } catch {
7651
7725
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7652
7726
  }
@@ -7690,7 +7764,7 @@ class Worker {
7690
7764
  } else if (this.runId && this.timedOut) {
7691
7765
  if (this.worktreePath) {
7692
7766
  try {
7693
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7767
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7694
7768
  } catch {
7695
7769
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7696
7770
  }
@@ -7780,7 +7854,7 @@ class Worker {
7780
7854
  await this.cliRunner.flushFinal();
7781
7855
  } catch {}
7782
7856
  }
7783
- this.cleanup();
7857
+ await this.cleanup();
7784
7858
  this.state = "idle";
7785
7859
  this.onDone(this);
7786
7860
  }
@@ -7906,7 +7980,7 @@ class Worker {
7906
7980
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7907
7981
  try {
7908
7982
  const loop = getStageLoop(stage);
7909
- const gateSource = isConvergeLoop(loop) ? loop?.exit_gate ?? null : stage.gate;
7983
+ const gateSource = isConvergeLoop(loop) && loop ? resolveLoopExitGate(stage, loop) : stage.gate;
7910
7984
  const gate = normalizeGateSpec(gateSource);
7911
7985
  if (!gate) {
7912
7986
  return null;
@@ -8204,7 +8278,8 @@ class Worker {
8204
8278
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => m.text)), card, subtasks, {
8205
8279
  model: this.selectImplementModel(card),
8206
8280
  maxTurns: STEERING_MAX_TURNS,
8207
- resumeSessionId: this.cliSessionId
8281
+ resumeSessionId: this.cliSessionId,
8282
+ ...this.activeRunSpawnOpts ?? {}
8208
8283
  });
8209
8284
  } catch (err) {
8210
8285
  log.warn(this.tag, `Steering resume failed (non-fatal): ${err instanceof Error ? err.message : err}`);
@@ -8232,6 +8307,7 @@ class Worker {
8232
8307
  String(maxTurns),
8233
8308
  "--allowedTools",
8234
8309
  allowedTools,
8310
+ ...opts.disallowedTools ? ["--disallowedTools", opts.disallowedTools] : [],
8235
8311
  ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
8236
8312
  ...this.config.claude.additionalArgs,
8237
8313
  "--",
@@ -8319,6 +8395,7 @@ class Worker {
8319
8395
  const model = opts.model ?? this.config.claude.model;
8320
8396
  const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
8321
8397
  const allowedTools = (opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS).split(",").map((t) => t.trim()).filter(Boolean);
8398
+ const disallowedTools = opts.disallowedTools ? opts.disallowedTools.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
8322
8399
  const initialPhase = opts.initialPhase ?? "exploring";
8323
8400
  const sdkCfg = this.config.sdk;
8324
8401
  log.info(this.tag, `Spawning Agent SDK runner (model=${model}, maxTurns=${maxTurns}${opts.resumeSessionId ? ", resume" : ""})`);
@@ -8338,6 +8415,7 @@ class Worker {
8338
8415
  model,
8339
8416
  maxTurns,
8340
8417
  allowedTools,
8418
+ ...disallowedTools ? { disallowedTools } : {},
8341
8419
  maxBudgetUsd: sdkCfg?.maxBudgetUsd,
8342
8420
  settingSources: sdkCfg?.settingSources,
8343
8421
  mcpServers: sdkCfg?.mcpServers,
@@ -8404,7 +8482,7 @@ class Worker {
8404
8482
  throw err;
8405
8483
  }
8406
8484
  }
8407
- cleanup() {
8485
+ async cleanup() {
8408
8486
  if (this.progressTracker) {
8409
8487
  this.progressTracker.stop();
8410
8488
  this.progressTracker = null;
@@ -8422,7 +8500,7 @@ class Worker {
8422
8500
  }
8423
8501
  if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8424
8502
  try {
8425
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
8503
+ await teardownWorktree(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8426
8504
  } catch {
8427
8505
  log.warn(this.tag, "Failed to cleanup worktree");
8428
8506
  }
@@ -8903,7 +8981,7 @@ async function recoverRun(run, store, client, config, outcome) {
8903
8981
  }
8904
8982
  if (run.worktreePath) {
8905
8983
  try {
8906
- cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
8984
+ await teardownWorktree(client, run.cardId, run.worktreePath, run.branchName ?? undefined);
8907
8985
  outcome.actions.push("cleaned up worktree");
8908
8986
  } catch (err) {
8909
8987
  const msg = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",