@gethmy/agent 1.15.0 → 1.16.1

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 +103 -18
  2. package/dist/index.js +103 -18
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -496,6 +496,7 @@ import {
496
496
  getApiUrl,
497
497
  getUserEmail
498
498
  } from "@gethmy/mcp/src/config.js";
499
+ import { refreshOAuthToken } from "@gethmy/mcp/src/oauth-refresh.js";
499
500
  function getRepoRoot() {
500
501
  return execSync("git rev-parse --show-toplevel", {
501
502
  encoding: "utf-8"
@@ -602,7 +603,11 @@ async function fetchRealtimeCredentials(client) {
602
603
  return result;
603
604
  }
604
605
  function createApiClient(config) {
605
- return new HarmonyApiClient({ apiKey: config.apiKey, apiUrl: config.apiUrl });
606
+ return new HarmonyApiClient({
607
+ apiKey: config.apiKey,
608
+ apiUrl: config.apiUrl,
609
+ refreshCredential: refreshOAuthToken
610
+ });
606
611
  }
607
612
  var init_config = __esm(() => {
608
613
  init_types();
@@ -1289,6 +1294,8 @@ function serializeCommentThread(comments, options = {}) {
1289
1294
  const tags = [];
1290
1295
  if (c.edited_at)
1291
1296
  tags.push("edited");
1297
+ if (c.reply_to_id)
1298
+ tags.push(`reply to ${ref(c.reply_to_id)}`);
1292
1299
  if (c.supersedes_id)
1293
1300
  tags.push(`supersedes ${ref(c.supersedes_id)}`);
1294
1301
  if (c.confirms_id)
@@ -1666,6 +1673,9 @@ function getStageLoop(stage) {
1666
1673
  function isConvergeLoop(loop) {
1667
1674
  return loop !== null && loop.mode === "converge";
1668
1675
  }
1676
+ function resolveLoopExitGate(stage, loop) {
1677
+ return loop.exit_gate ?? stage.gate ?? null;
1678
+ }
1669
1679
  function decideLoopContinuation(args) {
1670
1680
  const { loop, gatePassed, hasExitGate, completedIterations } = args;
1671
1681
  const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -1714,7 +1724,10 @@ function entryActionAllowlist(entryAction) {
1714
1724
  return `mcp__${entryAction}`;
1715
1725
  return null;
1716
1726
  }
1717
- var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1727
+ function stageDisallowedTools() {
1728
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1729
+ }
1730
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1718
1731
  var init_playbookStage = __esm(() => {
1719
1732
  SKILL_TOOL_ALLOWLIST = {
1720
1733
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -1725,6 +1738,11 @@ var init_playbookStage = __esm(() => {
1725
1738
  "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1726
1739
  };
1727
1740
  HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1741
+ STAGE_DAEMON_OWNED_TOOLS = [
1742
+ "mcp__harmony__harmony_end_agent_session",
1743
+ "mcp__harmony__harmony_start_agent_session",
1744
+ "mcp__harmony__harmony_move_card"
1745
+ ];
1728
1746
  });
1729
1747
 
1730
1748
  // ../harmony-shared/dist/projectTemplates.js
@@ -2159,6 +2177,58 @@ function cleanupWorktree(worktreePath, branchName) {
2159
2177
  } catch {}
2160
2178
  }
2161
2179
  }
2180
+ function resolveRepoRoot() {
2181
+ return execFileSync3("git", ["rev-parse", "--show-toplevel"], {
2182
+ encoding: "utf-8"
2183
+ }).trim();
2184
+ }
2185
+ function branchAheadOfItsRemote(branchName, repoRoot = resolveRepoRoot()) {
2186
+ try {
2187
+ const out = execFileSync3("git", ["rev-list", branchName, "--not", "--remotes=origin"], { cwd: repoRoot, encoding: "utf-8" }).trim();
2188
+ return out.length > 0;
2189
+ } catch {
2190
+ return false;
2191
+ }
2192
+ }
2193
+ async function rescueUnpushedBranch(client, cardId, branchName, repoRoot = resolveRepoRoot()) {
2194
+ const { getBranchWebUrl: getBranchWebUrl2, pushBranch: pushBranch2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
2195
+ try {
2196
+ pushBranch2(branchName, repoRoot);
2197
+ } catch (err) {
2198
+ 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}`);
2199
+ return false;
2200
+ }
2201
+ log.warn(TAG5, `push-rescued unpushed branch ${branchName} to origin before teardown`);
2202
+ try {
2203
+ const url = getBranchWebUrl2(branchName, repoRoot);
2204
+ const recover = url ? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\`` : `Recover it locally: \`git fetch && git checkout ${branchName}\``;
2205
+ const body = `⚠ Run ended before completion. Committed work was push-rescued to ` + `\`origin/${branchName}\` so it isn't lost. ${recover}`;
2206
+ await client.addComment(cardId, body, { commentType: "message" });
2207
+ } catch (err) {
2208
+ log.warn(TAG5, `push-rescue comment failed for ${branchName} (work is still safe on origin): ${err instanceof Error ? err.message : err}`);
2209
+ }
2210
+ return true;
2211
+ }
2212
+ async function teardownWorktree(client, cardId, worktreePath, branchName) {
2213
+ let skipBranchDelete = false;
2214
+ if (branchName && cardId) {
2215
+ let repoRoot;
2216
+ try {
2217
+ repoRoot = resolveRepoRoot();
2218
+ } catch {
2219
+ cleanupWorktree(worktreePath, branchName);
2220
+ return;
2221
+ }
2222
+ if (branchAheadOfItsRemote(branchName, repoRoot)) {
2223
+ const ok = await rescueUnpushedBranch(client, cardId, branchName, repoRoot);
2224
+ if (!ok) {
2225
+ skipBranchDelete = true;
2226
+ log.error(TAG5, `Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`);
2227
+ }
2228
+ }
2229
+ }
2230
+ cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
2231
+ }
2162
2232
  function makeBranchName(shortId, title, prefix = "agent-attempts/") {
2163
2233
  const slug = title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2164
2234
  return `${prefix}${shortId}-${slug || "task"}`;
@@ -3509,7 +3579,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3509
3579
  failureSummary,
3510
3580
  ...buildTokenPayload(sessionStats)
3511
3581
  });
3512
- cleanupWorktree(worktreePath, branchName);
3582
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3513
3583
  return false;
3514
3584
  }
3515
3585
  log.info(TAG14, `Pushing branch ${branchName} (pre-verify)...`);
@@ -3591,7 +3661,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3591
3661
  recoveryBranch: branchName,
3592
3662
  ...buildTokenPayload(sessionStats)
3593
3663
  });
3594
- cleanupWorktree(worktreePath, branchName);
3664
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3595
3665
  return false;
3596
3666
  }
3597
3667
  log.info(TAG14, `Verification passed for #${card.short_id}`);
@@ -3647,7 +3717,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3647
3717
  log.warn(TAG14, `onBeforeWorktreeCleanup hook failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3648
3718
  }
3649
3719
  }
3650
- cleanupWorktree(worktreePath, branchName);
3720
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3651
3721
  log.info(TAG14, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
3652
3722
  return true;
3653
3723
  }
@@ -3967,6 +4037,7 @@ class SdkAgentRunner {
3967
4037
  cwd: input.cwd,
3968
4038
  model: input.model ?? this.cfg.model,
3969
4039
  allowedTools: allowed,
4040
+ ...this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0 ? { disallowedTools: this.cfg.disallowedTools } : {},
3970
4041
  tools: builtinTools,
3971
4042
  permissionMode: "dontAsk",
3972
4043
  maxTurns: this.cfg.maxTurns,
@@ -6918,7 +6989,7 @@ async function buildPrompt(enriched, branchName, worktreePath, client, workspace
6918
6989
  variant: "execute",
6919
6990
  customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6920
6991
  Do NOT push to main. All your work stays on \`${branchName}\`.
6921
- When finished, call harmony_end_agent_session with status="completed".`
6992
+ 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
6993
  });
6923
6994
  log.info(TAG27, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6924
6995
  return result.prompt + pastEpisodesSection;
@@ -7028,7 +7099,7 @@ ${subtaskStr}
7028
7099
  Include a brief currentTask description.
7029
7100
  3. Implement the changes on branch \`${branchName}\`
7030
7101
  4. Commit your work with clear, descriptive commit messages
7031
- 5. When finished, call harmony_end_agent_session with status="completed"
7102
+ 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
7103
 
7033
7104
  You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
7034
7105
  Do NOT push to main. All your work stays on \`${branchName}\`.`;
@@ -7077,7 +7148,7 @@ function firstErrorMessage(evaluation) {
7077
7148
  return e ? e.message : null;
7078
7149
  }
7079
7150
  async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7080
- const hasExitGate = loop.exit_gate != null;
7151
+ const hasExitGate = resolveLoopExitGate(stage, loop) != null;
7081
7152
  const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7082
7153
  const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7083
7154
  const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -7330,7 +7401,7 @@ function buildStagePreamble(stage) {
7330
7401
  lines.push(`Hand off when done: ${summary.trim()}`);
7331
7402
  }
7332
7403
  }
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.");
7404
+ 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
7405
  if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7335
7406
  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
7407
  }
@@ -7343,6 +7414,13 @@ function buildSteeringPrompt(messages) {
7343
7414
  return messages.map((m, i) => `${i + 1}. ${m}`).join(`
7344
7415
  `);
7345
7416
  }
7417
+ function computeRunSpawnGating(stageAllowedTools) {
7418
+ const denylist = stageDisallowedTools();
7419
+ return {
7420
+ ...stageAllowedTools ? { allowedTools: stageAllowedTools } : {},
7421
+ ...denylist ? { disallowedTools: denylist } : {}
7422
+ };
7423
+ }
7346
7424
 
7347
7425
  class Worker {
7348
7426
  config;
@@ -7371,6 +7449,7 @@ class Worker {
7371
7449
  timedOut = false;
7372
7450
  verificationFailed = false;
7373
7451
  held = false;
7452
+ activeRunSpawnOpts = null;
7374
7453
  completionStarted = false;
7375
7454
  sessionId = null;
7376
7455
  runId = null;
@@ -7450,6 +7529,7 @@ class Worker {
7450
7529
  this.lastRunText = "";
7451
7530
  this.cliSessionId = null;
7452
7531
  this.lastDrainedSeq = 0;
7532
+ this.activeRunSpawnOpts = null;
7453
7533
  this.cardId = card.id;
7454
7534
  this.startedAt = Date.now();
7455
7535
  this.runId = newRunId();
@@ -7570,9 +7650,10 @@ class Worker {
7570
7650
  this.timedOut = true;
7571
7651
  this.cancel();
7572
7652
  }, this.config.maxTimeout);
7653
+ this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
7573
7654
  await this.spawnClaude(prompt, card, subtasks, {
7574
7655
  model: this.selectImplementModel(card),
7575
- ...stageCtx.kind === "run" ? { allowedTools: stageCtx.allowedTools } : {}
7656
+ ...this.activeRunSpawnOpts ?? {}
7576
7657
  });
7577
7658
  if (this.aborted)
7578
7659
  return;
@@ -7647,7 +7728,7 @@ class Worker {
7647
7728
  }
7648
7729
  if (this.worktreePath) {
7649
7730
  try {
7650
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7731
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7651
7732
  } catch {
7652
7733
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7653
7734
  }
@@ -7691,7 +7772,7 @@ class Worker {
7691
7772
  } else if (this.runId && this.timedOut) {
7692
7773
  if (this.worktreePath) {
7693
7774
  try {
7694
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7775
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7695
7776
  } catch {
7696
7777
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7697
7778
  }
@@ -7781,7 +7862,7 @@ class Worker {
7781
7862
  await this.cliRunner.flushFinal();
7782
7863
  } catch {}
7783
7864
  }
7784
- this.cleanup();
7865
+ await this.cleanup();
7785
7866
  this.state = "idle";
7786
7867
  this.onDone(this);
7787
7868
  }
@@ -7907,7 +7988,7 @@ class Worker {
7907
7988
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7908
7989
  try {
7909
7990
  const loop = getStageLoop(stage);
7910
- const gateSource = isConvergeLoop(loop) ? loop?.exit_gate ?? null : stage.gate;
7991
+ const gateSource = isConvergeLoop(loop) && loop ? resolveLoopExitGate(stage, loop) : stage.gate;
7911
7992
  const gate = normalizeGateSpec(gateSource);
7912
7993
  if (!gate) {
7913
7994
  return null;
@@ -8205,7 +8286,8 @@ class Worker {
8205
8286
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => m.text)), card, subtasks, {
8206
8287
  model: this.selectImplementModel(card),
8207
8288
  maxTurns: STEERING_MAX_TURNS,
8208
- resumeSessionId: this.cliSessionId
8289
+ resumeSessionId: this.cliSessionId,
8290
+ ...this.activeRunSpawnOpts ?? {}
8209
8291
  });
8210
8292
  } catch (err) {
8211
8293
  log.warn(this.tag, `Steering resume failed (non-fatal): ${err instanceof Error ? err.message : err}`);
@@ -8233,6 +8315,7 @@ class Worker {
8233
8315
  String(maxTurns),
8234
8316
  "--allowedTools",
8235
8317
  allowedTools,
8318
+ ...opts.disallowedTools ? ["--disallowedTools", opts.disallowedTools] : [],
8236
8319
  ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
8237
8320
  ...this.config.claude.additionalArgs,
8238
8321
  "--",
@@ -8320,6 +8403,7 @@ class Worker {
8320
8403
  const model = opts.model ?? this.config.claude.model;
8321
8404
  const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
8322
8405
  const allowedTools = (opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS).split(",").map((t) => t.trim()).filter(Boolean);
8406
+ const disallowedTools = opts.disallowedTools ? opts.disallowedTools.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
8323
8407
  const initialPhase = opts.initialPhase ?? "exploring";
8324
8408
  const sdkCfg = this.config.sdk;
8325
8409
  log.info(this.tag, `Spawning Agent SDK runner (model=${model}, maxTurns=${maxTurns}${opts.resumeSessionId ? ", resume" : ""})`);
@@ -8339,6 +8423,7 @@ class Worker {
8339
8423
  model,
8340
8424
  maxTurns,
8341
8425
  allowedTools,
8426
+ ...disallowedTools ? { disallowedTools } : {},
8342
8427
  maxBudgetUsd: sdkCfg?.maxBudgetUsd,
8343
8428
  settingSources: sdkCfg?.settingSources,
8344
8429
  mcpServers: sdkCfg?.mcpServers,
@@ -8405,7 +8490,7 @@ class Worker {
8405
8490
  throw err;
8406
8491
  }
8407
8492
  }
8408
- cleanup() {
8493
+ async cleanup() {
8409
8494
  if (this.progressTracker) {
8410
8495
  this.progressTracker.stop();
8411
8496
  this.progressTracker = null;
@@ -8423,7 +8508,7 @@ class Worker {
8423
8508
  }
8424
8509
  if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8425
8510
  try {
8426
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
8511
+ await teardownWorktree(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8427
8512
  } catch {
8428
8513
  log.warn(this.tag, "Failed to cleanup worktree");
8429
8514
  }
@@ -8904,7 +8989,7 @@ async function recoverRun(run, store, client, config, outcome) {
8904
8989
  }
8905
8990
  if (run.worktreePath) {
8906
8991
  try {
8907
- cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
8992
+ await teardownWorktree(client, run.cardId, run.worktreePath, run.branchName ?? undefined);
8908
8993
  outcome.actions.push("cleaned up worktree");
8909
8994
  } catch (err) {
8910
8995
  const msg = err instanceof Error ? err.message : String(err);
package/dist/index.js CHANGED
@@ -495,6 +495,7 @@ import {
495
495
  getApiUrl,
496
496
  getUserEmail
497
497
  } from "@gethmy/mcp/src/config.js";
498
+ import { refreshOAuthToken } from "@gethmy/mcp/src/oauth-refresh.js";
498
499
  function getRepoRoot() {
499
500
  return execSync("git rev-parse --show-toplevel", {
500
501
  encoding: "utf-8"
@@ -601,7 +602,11 @@ async function fetchRealtimeCredentials(client) {
601
602
  return result;
602
603
  }
603
604
  function createApiClient(config) {
604
- return new HarmonyApiClient({ apiKey: config.apiKey, apiUrl: config.apiUrl });
605
+ return new HarmonyApiClient({
606
+ apiKey: config.apiKey,
607
+ apiUrl: config.apiUrl,
608
+ refreshCredential: refreshOAuthToken
609
+ });
605
610
  }
606
611
  var init_config = __esm(() => {
607
612
  init_types();
@@ -1288,6 +1293,8 @@ function serializeCommentThread(comments, options = {}) {
1288
1293
  const tags = [];
1289
1294
  if (c.edited_at)
1290
1295
  tags.push("edited");
1296
+ if (c.reply_to_id)
1297
+ tags.push(`reply to ${ref(c.reply_to_id)}`);
1291
1298
  if (c.supersedes_id)
1292
1299
  tags.push(`supersedes ${ref(c.supersedes_id)}`);
1293
1300
  if (c.confirms_id)
@@ -1665,6 +1672,9 @@ function getStageLoop(stage) {
1665
1672
  function isConvergeLoop(loop) {
1666
1673
  return loop !== null && loop.mode === "converge";
1667
1674
  }
1675
+ function resolveLoopExitGate(stage, loop) {
1676
+ return loop.exit_gate ?? stage.gate ?? null;
1677
+ }
1668
1678
  function decideLoopContinuation(args) {
1669
1679
  const { loop, gatePassed, hasExitGate, completedIterations } = args;
1670
1680
  const max = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -1713,7 +1723,10 @@ function entryActionAllowlist(entryAction) {
1713
1723
  return `mcp__${entryAction}`;
1714
1724
  return null;
1715
1725
  }
1716
- var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE;
1726
+ function stageDisallowedTools() {
1727
+ return STAGE_DAEMON_OWNED_TOOLS.length > 0 ? STAGE_DAEMON_OWNED_TOOLS.join(",") : null;
1728
+ }
1729
+ var DEFAULT_LOOP_MAX_ITERATIONS = 5, SKILL_TOOL_ALLOWLIST, HARMONY_TOOL_RE, STAGE_DAEMON_OWNED_TOOLS;
1717
1730
  var init_playbookStage = __esm(() => {
1718
1731
  SKILL_TOOL_ALLOWLIST = {
1719
1732
  hmy: "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
@@ -1724,6 +1737,11 @@ var init_playbookStage = __esm(() => {
1724
1737
  "hmy-standup": "Read,Grep,Glob,mcp__harmony__*"
1725
1738
  };
1726
1739
  HARMONY_TOOL_RE = /^harmony_[a-z_]+$/;
1740
+ STAGE_DAEMON_OWNED_TOOLS = [
1741
+ "mcp__harmony__harmony_end_agent_session",
1742
+ "mcp__harmony__harmony_start_agent_session",
1743
+ "mcp__harmony__harmony_move_card"
1744
+ ];
1727
1745
  });
1728
1746
 
1729
1747
  // ../harmony-shared/dist/projectTemplates.js
@@ -2158,6 +2176,58 @@ function cleanupWorktree(worktreePath, branchName) {
2158
2176
  } catch {}
2159
2177
  }
2160
2178
  }
2179
+ function resolveRepoRoot() {
2180
+ return execFileSync3("git", ["rev-parse", "--show-toplevel"], {
2181
+ encoding: "utf-8"
2182
+ }).trim();
2183
+ }
2184
+ function branchAheadOfItsRemote(branchName, repoRoot = resolveRepoRoot()) {
2185
+ try {
2186
+ const out = execFileSync3("git", ["rev-list", branchName, "--not", "--remotes=origin"], { cwd: repoRoot, encoding: "utf-8" }).trim();
2187
+ return out.length > 0;
2188
+ } catch {
2189
+ return false;
2190
+ }
2191
+ }
2192
+ async function rescueUnpushedBranch(client, cardId, branchName, repoRoot = resolveRepoRoot()) {
2193
+ const { getBranchWebUrl: getBranchWebUrl2, pushBranch: pushBranch2 } = await Promise.resolve().then(() => (init_git_pr(), exports_git_pr));
2194
+ try {
2195
+ pushBranch2(branchName, repoRoot);
2196
+ } catch (err) {
2197
+ 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}`);
2198
+ return false;
2199
+ }
2200
+ log.warn(TAG5, `push-rescued unpushed branch ${branchName} to origin before teardown`);
2201
+ try {
2202
+ const url = getBranchWebUrl2(branchName, repoRoot);
2203
+ const recover = url ? `View it at ${url} or recover locally: \`git fetch && git checkout ${branchName}\`` : `Recover it locally: \`git fetch && git checkout ${branchName}\``;
2204
+ const body = `⚠ Run ended before completion. Committed work was push-rescued to ` + `\`origin/${branchName}\` so it isn't lost. ${recover}`;
2205
+ await client.addComment(cardId, body, { commentType: "message" });
2206
+ } catch (err) {
2207
+ log.warn(TAG5, `push-rescue comment failed for ${branchName} (work is still safe on origin): ${err instanceof Error ? err.message : err}`);
2208
+ }
2209
+ return true;
2210
+ }
2211
+ async function teardownWorktree(client, cardId, worktreePath, branchName) {
2212
+ let skipBranchDelete = false;
2213
+ if (branchName && cardId) {
2214
+ let repoRoot;
2215
+ try {
2216
+ repoRoot = resolveRepoRoot();
2217
+ } catch {
2218
+ cleanupWorktree(worktreePath, branchName);
2219
+ return;
2220
+ }
2221
+ if (branchAheadOfItsRemote(branchName, repoRoot)) {
2222
+ const ok = await rescueUnpushedBranch(client, cardId, branchName, repoRoot);
2223
+ if (!ok) {
2224
+ skipBranchDelete = true;
2225
+ log.error(TAG5, `Keeping local branch ${branchName} (push-rescue failed) to avoid orphaning its commit`);
2226
+ }
2227
+ }
2228
+ }
2229
+ cleanupWorktree(worktreePath, skipBranchDelete ? undefined : branchName);
2230
+ }
2161
2231
  function makeBranchName(shortId, title, prefix = "agent-attempts/") {
2162
2232
  const slug = title.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
2163
2233
  return `${prefix}${shortId}-${slug || "task"}`;
@@ -3508,7 +3578,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3508
3578
  failureSummary,
3509
3579
  ...buildTokenPayload(sessionStats)
3510
3580
  });
3511
- cleanupWorktree(worktreePath, branchName);
3581
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3512
3582
  return false;
3513
3583
  }
3514
3584
  log.info(TAG14, `Pushing branch ${branchName} (pre-verify)...`);
@@ -3590,7 +3660,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3590
3660
  recoveryBranch: branchName,
3591
3661
  ...buildTokenPayload(sessionStats)
3592
3662
  });
3593
- cleanupWorktree(worktreePath, branchName);
3663
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3594
3664
  return false;
3595
3665
  }
3596
3666
  log.info(TAG14, `Verification passed for #${card.short_id}`);
@@ -3646,7 +3716,7 @@ async function runCompletion(client, card, branchName, worktreePath, config, wor
3646
3716
  log.warn(TAG14, `onBeforeWorktreeCleanup hook failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3647
3717
  }
3648
3718
  }
3649
- cleanupWorktree(worktreePath, branchName);
3719
+ await teardownWorktree(client, card.id, worktreePath, branchName);
3650
3720
  log.info(TAG14, `Completion done for #${card.short_id}${prUrl ? ` — PR: ${prUrl}` : ""}`);
3651
3721
  return true;
3652
3722
  }
@@ -3966,6 +4036,7 @@ class SdkAgentRunner {
3966
4036
  cwd: input.cwd,
3967
4037
  model: input.model ?? this.cfg.model,
3968
4038
  allowedTools: allowed,
4039
+ ...this.cfg.disallowedTools && this.cfg.disallowedTools.length > 0 ? { disallowedTools: this.cfg.disallowedTools } : {},
3969
4040
  tools: builtinTools,
3970
4041
  permissionMode: "dontAsk",
3971
4042
  maxTurns: this.cfg.maxTurns,
@@ -6917,7 +6988,7 @@ async function buildPrompt(enriched, branchName, worktreePath, client, workspace
6917
6988
  variant: "execute",
6918
6989
  customConstraints: `You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
6919
6990
  Do NOT push to main. All your work stays on \`${branchName}\`.
6920
- When finished, call harmony_end_agent_session with status="completed".`
6991
+ 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
6992
  });
6922
6993
  log.info(TAG27, `Generated prompt for #${card.short_id} — ${result.contextSummary.memoryCount} memories, ${result.tokenEstimate} tokens`);
6923
6994
  return result.prompt + pastEpisodesSection;
@@ -7027,7 +7098,7 @@ ${subtaskStr}
7027
7098
  Include a brief currentTask description.
7028
7099
  3. Implement the changes on branch \`${branchName}\`
7029
7100
  4. Commit your work with clear, descriptive commit messages
7030
- 5. When finished, call harmony_end_agent_session with status="completed"
7101
+ 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
7102
 
7032
7103
  You are working in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.
7033
7104
  Do NOT push to main. All your work stays on \`${branchName}\`.`;
@@ -7076,7 +7147,7 @@ function firstErrorMessage(evaluation) {
7076
7147
  return e ? e.message : null;
7077
7148
  }
7078
7149
  async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loop, deps) {
7079
- const hasExitGate = loop.exit_gate != null;
7150
+ const hasExitGate = resolveLoopExitGate(stage, loop) != null;
7080
7151
  const gatePassed = hasExitGate ? evaluation?.passed ?? false : false;
7081
7152
  const gateResult = !hasExitGate ? "skipped" : gatePassed ? "passed" : "failed";
7082
7153
  const maxIterations = Math.max(1, Math.floor(loop.max_iterations) || 1);
@@ -7329,7 +7400,7 @@ function buildStagePreamble(stage) {
7329
7400
  lines.push(`Hand off when done: ${summary.trim()}`);
7330
7401
  }
7331
7402
  }
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.");
7403
+ 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
7404
  if (normalizeGateSpec(stage.gate)?.kind === "review_passed") {
7334
7405
  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
7406
  }
@@ -7342,6 +7413,13 @@ function buildSteeringPrompt(messages) {
7342
7413
  return messages.map((m, i) => `${i + 1}. ${m}`).join(`
7343
7414
  `);
7344
7415
  }
7416
+ function computeRunSpawnGating(stageAllowedTools) {
7417
+ const denylist = stageDisallowedTools();
7418
+ return {
7419
+ ...stageAllowedTools ? { allowedTools: stageAllowedTools } : {},
7420
+ ...denylist ? { disallowedTools: denylist } : {}
7421
+ };
7422
+ }
7345
7423
 
7346
7424
  class Worker {
7347
7425
  config;
@@ -7370,6 +7448,7 @@ class Worker {
7370
7448
  timedOut = false;
7371
7449
  verificationFailed = false;
7372
7450
  held = false;
7451
+ activeRunSpawnOpts = null;
7373
7452
  completionStarted = false;
7374
7453
  sessionId = null;
7375
7454
  runId = null;
@@ -7449,6 +7528,7 @@ class Worker {
7449
7528
  this.lastRunText = "";
7450
7529
  this.cliSessionId = null;
7451
7530
  this.lastDrainedSeq = 0;
7531
+ this.activeRunSpawnOpts = null;
7452
7532
  this.cardId = card.id;
7453
7533
  this.startedAt = Date.now();
7454
7534
  this.runId = newRunId();
@@ -7569,9 +7649,10 @@ class Worker {
7569
7649
  this.timedOut = true;
7570
7650
  this.cancel();
7571
7651
  }, this.config.maxTimeout);
7652
+ this.activeRunSpawnOpts = computeRunSpawnGating(stageCtx.kind === "run" ? stageCtx.allowedTools : null);
7572
7653
  await this.spawnClaude(prompt, card, subtasks, {
7573
7654
  model: this.selectImplementModel(card),
7574
- ...stageCtx.kind === "run" ? { allowedTools: stageCtx.allowedTools } : {}
7655
+ ...this.activeRunSpawnOpts ?? {}
7575
7656
  });
7576
7657
  if (this.aborted)
7577
7658
  return;
@@ -7646,7 +7727,7 @@ class Worker {
7646
7727
  }
7647
7728
  if (this.worktreePath) {
7648
7729
  try {
7649
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7730
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7650
7731
  } catch {
7651
7732
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7652
7733
  }
@@ -7690,7 +7771,7 @@ class Worker {
7690
7771
  } else if (this.runId && this.timedOut) {
7691
7772
  if (this.worktreePath) {
7692
7773
  try {
7693
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
7774
+ await teardownWorktree(this.client, card.id, this.worktreePath, this.branchName ?? undefined);
7694
7775
  } catch {
7695
7776
  log.warn(this.tag, "Failed to cleanup worktree before requeue");
7696
7777
  }
@@ -7780,7 +7861,7 @@ class Worker {
7780
7861
  await this.cliRunner.flushFinal();
7781
7862
  } catch {}
7782
7863
  }
7783
- this.cleanup();
7864
+ await this.cleanup();
7784
7865
  this.state = "idle";
7785
7866
  this.onDone(this);
7786
7867
  }
@@ -7906,7 +7987,7 @@ class Worker {
7906
7987
  async collectStageGateEvidence(card, stage, worktreePath, subtasks) {
7907
7988
  try {
7908
7989
  const loop = getStageLoop(stage);
7909
- const gateSource = isConvergeLoop(loop) ? loop?.exit_gate ?? null : stage.gate;
7990
+ const gateSource = isConvergeLoop(loop) && loop ? resolveLoopExitGate(stage, loop) : stage.gate;
7910
7991
  const gate = normalizeGateSpec(gateSource);
7911
7992
  if (!gate) {
7912
7993
  return null;
@@ -8204,7 +8285,8 @@ class Worker {
8204
8285
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => m.text)), card, subtasks, {
8205
8286
  model: this.selectImplementModel(card),
8206
8287
  maxTurns: STEERING_MAX_TURNS,
8207
- resumeSessionId: this.cliSessionId
8288
+ resumeSessionId: this.cliSessionId,
8289
+ ...this.activeRunSpawnOpts ?? {}
8208
8290
  });
8209
8291
  } catch (err) {
8210
8292
  log.warn(this.tag, `Steering resume failed (non-fatal): ${err instanceof Error ? err.message : err}`);
@@ -8232,6 +8314,7 @@ class Worker {
8232
8314
  String(maxTurns),
8233
8315
  "--allowedTools",
8234
8316
  allowedTools,
8317
+ ...opts.disallowedTools ? ["--disallowedTools", opts.disallowedTools] : [],
8235
8318
  ...opts.resumeSessionId ? ["--resume", opts.resumeSessionId] : [],
8236
8319
  ...this.config.claude.additionalArgs,
8237
8320
  "--",
@@ -8319,6 +8402,7 @@ class Worker {
8319
8402
  const model = opts.model ?? this.config.claude.model;
8320
8403
  const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
8321
8404
  const allowedTools = (opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS).split(",").map((t) => t.trim()).filter(Boolean);
8405
+ const disallowedTools = opts.disallowedTools ? opts.disallowedTools.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
8322
8406
  const initialPhase = opts.initialPhase ?? "exploring";
8323
8407
  const sdkCfg = this.config.sdk;
8324
8408
  log.info(this.tag, `Spawning Agent SDK runner (model=${model}, maxTurns=${maxTurns}${opts.resumeSessionId ? ", resume" : ""})`);
@@ -8338,6 +8422,7 @@ class Worker {
8338
8422
  model,
8339
8423
  maxTurns,
8340
8424
  allowedTools,
8425
+ ...disallowedTools ? { disallowedTools } : {},
8341
8426
  maxBudgetUsd: sdkCfg?.maxBudgetUsd,
8342
8427
  settingSources: sdkCfg?.settingSources,
8343
8428
  mcpServers: sdkCfg?.mcpServers,
@@ -8404,7 +8489,7 @@ class Worker {
8404
8489
  throw err;
8405
8490
  }
8406
8491
  }
8407
- cleanup() {
8492
+ async cleanup() {
8408
8493
  if (this.progressTracker) {
8409
8494
  this.progressTracker.stop();
8410
8495
  this.progressTracker = null;
@@ -8422,7 +8507,7 @@ class Worker {
8422
8507
  }
8423
8508
  if (this.worktreePath && (this.state === "error" || this.timedOut || this.aborted)) {
8424
8509
  try {
8425
- cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
8510
+ await teardownWorktree(this.client, this.cardId, this.worktreePath, this.branchName ?? undefined);
8426
8511
  } catch {
8427
8512
  log.warn(this.tag, "Failed to cleanup worktree");
8428
8513
  }
@@ -8903,7 +8988,7 @@ async function recoverRun(run, store, client, config, outcome) {
8903
8988
  }
8904
8989
  if (run.worktreePath) {
8905
8990
  try {
8906
- cleanupWorktree(run.worktreePath, run.branchName ?? undefined);
8991
+ await teardownWorktree(client, run.cardId, run.worktreePath, run.branchName ?? undefined);
8907
8992
  outcome.actions.push("cleaned up worktree");
8908
8993
  } catch (err) {
8909
8994
  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.1",
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",
@@ -46,7 +46,7 @@
46
46
  "dependencies": {
47
47
  "@anthropic-ai/claude-agent-sdk": "^0.3.178",
48
48
  "@supabase/supabase-js": "2.95.3",
49
- "@gethmy/mcp": "2.11.1"
49
+ "@gethmy/mcp": "^2.14.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@harmony/shared": "workspace:*",