@gethmy/agent 1.49.5 → 1.49.6

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.
package/README.md CHANGED
@@ -121,7 +121,7 @@ harmony-agent help # Show usage
121
121
  ## What the daemon does
122
122
 
123
123
  1. **Watches** the board in real time via Supabase Realtime.
124
- 2. **Picks up** cards assigned to your agent user from configured columns.
124
+ 2. **Picks up** cards assigned to the daemon's virtual agent from configured columns. Moving a card to a pickup column is not enough; with `sweep.enabled` the daemon also claims unassigned cards from those columns itself.
125
125
  3. **Implements** changes in an isolated git worktree using a full-tool Claude run.
126
126
  4. **Verifies** the build + lint pass. Failures trigger an auto-fix attempt. Persistent failures move the card back to the `verification.failColumn` (default: `To Do`).
127
127
  5. **Pushes** the branch and moves the card to `Review`.
package/dist/cli.js CHANGED
@@ -735,6 +735,18 @@ function formatDailyCap(cents) {
735
735
  function formatTurnCap(turns) {
736
736
  return turns < 0 ? "no cap" : `${turns} turns`;
737
737
  }
738
+ function describeGuardDenial(reason, detail) {
739
+ switch (reason) {
740
+ case "max_attempts":
741
+ return `Stopped — ${detail} (\`budget.maxAttemptsPerCard\`). Reassign the card to grant a fresh attempt, or raise the limit in the daemon config.`;
742
+ case "card_turns":
743
+ return `Stopped — ${detail} (\`budget.maxTurnsPerCard\`). Reassign the card to clear the count, or raise the limit in the daemon config (-1 removes it).`;
744
+ case "card_runs":
745
+ return `Stopped — ${detail} (\`budget.maxRunsPerCard\`). Reassign the card to clear the count, or raise the limit in the daemon config (-1 removes it).`;
746
+ case "daily_budget":
747
+ return `Daily budget reached — waiting for the UTC reset (${detail}, \`budget.dailyBudgetCents\`). Raise the limit in the daemon config to continue today (-1 removes it).`;
748
+ }
749
+ }
738
750
 
739
751
  class BudgetGuard {
740
752
  config;
@@ -2313,6 +2325,7 @@ var init_plan_phase = __esm(() => {
2313
2325
  mode: "advisory",
2314
2326
  model: "sonnet",
2315
2327
  maxTurns: 40,
2328
+ timeoutMs: 10 * 60000,
2316
2329
  postComment: true,
2317
2330
  awaitingApprovalColumn: "To Do",
2318
2331
  minComplexityScore: 3,
@@ -2373,6 +2386,9 @@ var init_types = __esm(() => {
2373
2386
  },
2374
2387
  scoreBands: DEFAULT_SCORE_BANDS,
2375
2388
  sizingModel: "",
2389
+ sizingMaxTurns: 25,
2390
+ sizingMaxBudgetUsd: 0.75,
2391
+ sizingTimeoutMs: 240000,
2376
2392
  researchRule: "shadow",
2377
2393
  reviewModel: "sonnet",
2378
2394
  deepReviewModel: "sonnet",
@@ -2475,6 +2491,10 @@ var init_types = __esm(() => {
2475
2491
  runRecordDays: 14,
2476
2492
  runLogDays: 30
2477
2493
  },
2494
+ steering: {
2495
+ maxTurns: 15,
2496
+ maxIterations: 10
2497
+ },
2478
2498
  planning: DEFAULT_PLANNING_CONFIG,
2479
2499
  contractFirst: DEFAULT_CONTRACT_CONFIG,
2480
2500
  boardReview: DEFAULT_BOARD_REVIEW_CONFIG,
@@ -2495,6 +2515,7 @@ __export(exports_config_validation, {
2495
2515
  validateVerificationConfig: () => validateVerificationConfig,
2496
2516
  validateSweepConfig: () => validateSweepConfig,
2497
2517
  validateSizingConfig: () => validateSizingConfig,
2518
+ validateRunLimitsConfig: () => validateRunLimitsConfig,
2498
2519
  validateRetentionConfig: () => validateRetentionConfig,
2499
2520
  validateRankingConfig: () => validateRankingConfig,
2500
2521
  validateHttpConfig: () => validateHttpConfig,
@@ -2826,6 +2847,41 @@ function validateRetentionConfig(config) {
2826
2847
  ` + ` Set a positive number of days, or a negative value to keep this store forever.`, [issue]);
2827
2848
  }
2828
2849
  }
2850
+ function validateRunLimitsConfig(config) {
2851
+ const wholeOrOff = [
2852
+ ["planning.timeoutMs", config.planning.timeoutMs],
2853
+ ["steering.maxTurns", config.steering.maxTurns],
2854
+ ["steering.maxIterations", config.steering.maxIterations],
2855
+ ["claude.sizingMaxTurns", config.claude.sizingMaxTurns],
2856
+ ["claude.sizingTimeoutMs", config.claude.sizingTimeoutMs]
2857
+ ];
2858
+ const issues = [];
2859
+ for (const [key, value] of wholeOrOff) {
2860
+ if (value === -1)
2861
+ continue;
2862
+ if (!Number.isInteger(value) || value < 1) {
2863
+ issues.push(`${key}: ${value === 0 ? '0 is ambiguous — it could mean "none allowed" or "no limit"' : `${JSON.stringify(value)} is not a positive whole number`}`);
2864
+ }
2865
+ }
2866
+ const budget = config.claude.sizingMaxBudgetUsd;
2867
+ if (budget !== -1 && !(Number.isFinite(budget) && budget > 0)) {
2868
+ issues.push(`claude.sizingMaxBudgetUsd: ${budget === 0 ? '0 is ambiguous — it could mean "no spend allowed" or "no limit"' : `${JSON.stringify(budget)} is not a positive number`}`);
2869
+ }
2870
+ for (const [key, value] of [
2871
+ ["claude.maxTurns", config.claude.maxTurns],
2872
+ ["claude.reviewMaxTurns", config.claude.reviewMaxTurns]
2873
+ ]) {
2874
+ if (!Number.isInteger(value) || value < 1) {
2875
+ issues.push(`${key}: ${JSON.stringify(value)} is not a positive whole number`);
2876
+ }
2877
+ }
2878
+ if (issues.length > 0) {
2879
+ throw new ConfigValidationError(`Invalid agent config — run limits:
2880
+ - ${issues.join(`
2881
+ - `)}
2882
+ ` + ` Set a positive value, or -1 to remove the limit (not available for claude.maxTurns and claude.reviewMaxTurns).`, issues);
2883
+ }
2884
+ }
2829
2885
  function validateRankingConfig(config) {
2830
2886
  const issues = [];
2831
2887
  const entries = Object.entries(config.ranking);
@@ -7184,9 +7240,11 @@ ${runLogTail}
7184
7240
  } else {
7185
7241
  await breakReviewLoopIfNeeded();
7186
7242
  }
7243
+ const movedTo = handbackVerdict.proceed ? `I moved the card to "${config.review.moveToColumn}" instead of sending it back to "${config.review.failColumn}" again.` : "I left the card where it is instead of sending it back again.";
7187
7244
  const body = [
7188
7245
  "**Review — needs human review.**",
7189
- `Reached max review cycles (${maxCycles}). Please review manually.`,
7246
+ `This card reached the limit of ${maxCycles} review cycles (\`review.maxReviewCycles\`). ${movedTo}`,
7247
+ "Next: read the earlier review comments and fix or merge the branch by hand. To allow more automatic rounds, raise `review.maxReviewCycles` in the daemon config.",
7190
7248
  result.summary || ""
7191
7249
  ].filter(Boolean).join(`
7192
7250
 
@@ -9902,6 +9960,7 @@ var init_workspace_model_config = __esm(() => {
9902
9960
  // src/worker.ts
9903
9961
  import { execFileSync as execFileSync7 } from "node:child_process";
9904
9962
  import {
9963
+ boundedTimerMs,
9905
9964
  chooseImplementModel,
9906
9965
  classifyRunError as classifyRunError2,
9907
9966
  createWorktree,
@@ -10792,7 +10851,10 @@ ${prompt}`;
10792
10851
  title: card.title,
10793
10852
  description: card.description,
10794
10853
  model,
10795
- scoreBands: claude.scoreBands
10854
+ scoreBands: claude.scoreBands,
10855
+ maxTurns: claude.sizingMaxTurns < 0 ? null : claude.sizingMaxTurns,
10856
+ maxBudgetUsd: claude.sizingMaxBudgetUsd < 0 ? null : claude.sizingMaxBudgetUsd,
10857
+ timeoutMs: claude.sizingTimeoutMs < 0 ? null : claude.sizingTimeoutMs
10796
10858
  });
10797
10859
  if (outcome.status === "sized") {
10798
10860
  log33.info(this.tag, `Sized #${card.short_id}: complexity ${outcome.sizing.complexity}/10 -> ${outcome.sizing.tier}`);
@@ -11031,6 +11093,10 @@ ${prompt}`;
11031
11093
  }
11032
11094
  }
11033
11095
  }
11096
+ armPhaseTimer(onExpiry) {
11097
+ const { timeoutMs } = this.config.planning;
11098
+ return setTimeout(onExpiry, boundedTimerMs(timeoutMs < 0 ? this.config.maxTimeout : timeoutMs));
11099
+ }
11034
11100
  async runPlanningPhase(enriched) {
11035
11101
  const planning = this.config.planning;
11036
11102
  const { card } = enriched;
@@ -11045,7 +11111,7 @@ ${prompt}`;
11045
11111
  }).catch(() => {});
11046
11112
  const planPrompt = buildPlanPrompt(enriched, this.worktreePath);
11047
11113
  let planTimedOut = false;
11048
- const planTimeout = setTimeout(() => {
11114
+ const planTimeout = this.armPhaseTimer(() => {
11049
11115
  planTimedOut = true;
11050
11116
  log33.warn(this.tag, "Planning pass exceeded timeout — abandoning, implementing directly");
11051
11117
  if (this.sdkRunner) {
@@ -11056,7 +11122,7 @@ ${prompt}`;
11056
11122
  sigtermTimeoutMs: 5000
11057
11123
  }).catch(() => {});
11058
11124
  }
11059
- }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
11125
+ });
11060
11126
  try {
11061
11127
  await this.spawnClaude(planPrompt, card, [], {
11062
11128
  model: planning.model,
@@ -11156,7 +11222,7 @@ ${prompt}`;
11156
11222
  }).catch(() => {});
11157
11223
  const contractPrompt = buildContractPrompt(enriched, this.worktreePath);
11158
11224
  let contractTimedOut = false;
11159
- const contractTimeout = setTimeout(() => {
11225
+ const contractTimeout = this.armPhaseTimer(() => {
11160
11226
  contractTimedOut = true;
11161
11227
  log33.warn(this.tag, "Contract pass exceeded timeout — abandoning, implementing directly");
11162
11228
  if (this.sdkRunner) {
@@ -11167,7 +11233,7 @@ ${prompt}`;
11167
11233
  sigtermTimeoutMs: 5000
11168
11234
  }).catch(() => {});
11169
11235
  }
11170
- }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
11236
+ });
11171
11237
  try {
11172
11238
  await this.spawnClaude(contractPrompt, card, [], {
11173
11239
  model: contractCfg.model,
@@ -11219,7 +11285,8 @@ ${prompt}`;
11219
11285
  async drainSteeringMessages(card, subtasks) {
11220
11286
  if (!this.cliSessionId || !this.sessionId || !this.cardId)
11221
11287
  return;
11222
- for (let i = 0;i < MAX_STEERING_ITERATIONS && !this.aborted; i++) {
11288
+ const { maxIterations, maxTurns } = this.config.steering;
11289
+ for (let i = 0;(maxIterations < 0 || i < maxIterations) && !this.aborted; i++) {
11223
11290
  let messages;
11224
11291
  try {
11225
11292
  const res = await this.client.getPendingUserMessages(this.cardId, this.sessionId, this.lastDrainedSeq);
@@ -11237,7 +11304,7 @@ ${prompt}`;
11237
11304
  try {
11238
11305
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => frameSteeringQuestion(m.text, m.questionCommentId))), card, subtasks, {
11239
11306
  model: this.selectImplementModel(card),
11240
- maxTurns: STEERING_MAX_TURNS,
11307
+ maxTurns: maxTurns < 0 ? undefined : maxTurns,
11241
11308
  resumeSessionId: this.cliSessionId,
11242
11309
  noCommitNotice: true,
11243
11310
  ...this.activeRunSpawnOpts ?? {}
@@ -11543,7 +11610,7 @@ ${prompt}`;
11543
11610
  this.chargedTokens = emptyTokens();
11544
11611
  }
11545
11612
  }
11546
- var TAG30 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, STEERING_MAX_TURNS = 15, MAX_STEERING_ITERATIONS = 10, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*", PLAN_PHASE_TIMEOUT;
11613
+ var TAG30 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*";
11547
11614
  var init_worker = __esm(() => {
11548
11615
  init_src();
11549
11616
  init_board_helpers();
@@ -11563,7 +11630,6 @@ var init_worker = __esm(() => {
11563
11630
  init_transitions();
11564
11631
  init_types();
11565
11632
  init_workspace_model_config();
11566
- PLAN_PHASE_TIMEOUT = 10 * 60000;
11567
11633
  });
11568
11634
 
11569
11635
  // src/workspace-model-cache.ts
@@ -11725,13 +11791,13 @@ class Pool {
11725
11791
  if (mode === "implement") {
11726
11792
  if (this.authPaused) {
11727
11793
  log35.debug(TAG31, `#${card.short_id} held — agent paused (auth error)`);
11728
- await this.emitWaiting(card.id, "Agent paused — Anthropic auth error, check API credentials");
11794
+ await this.emitWaiting(card.id, "Agent paused — Anthropic auth error. Fix the API credentials, then restart the daemon.");
11729
11795
  return;
11730
11796
  }
11731
11797
  const cooldownMs = this.apiCooldownRemainingMs();
11732
11798
  if (cooldownMs > 0) {
11733
11799
  log35.debug(TAG31, `#${card.short_id} held — API cooldown ${Math.round(cooldownMs / 1000)}s remaining`);
11734
- await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying in ~${Math.round(cooldownMs / 1000)}s`);
11800
+ await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying after ${new Date(this.apiCooldownUntil).toISOString().slice(11, 16)} UTC. Nothing to change: the daemon resumes by itself.`);
11735
11801
  return;
11736
11802
  }
11737
11803
  const decision = this.budget.check(card.id);
@@ -11740,6 +11806,7 @@ class Pool {
11740
11806
  await this.denyDailyBudget(card, decision.detail);
11741
11807
  } else {
11742
11808
  log35.debug(TAG31, `#${card.short_id} skipped (${decision.reason}): ${decision.detail}`);
11809
+ await this.emitWaiting(card.id, describeGuardDenial(decision.reason, decision.detail));
11743
11810
  }
11744
11811
  return;
11745
11812
  }
@@ -11747,6 +11814,7 @@ class Pool {
11747
11814
  chainSuccessors = successors;
11748
11815
  if (blockers === null) {
11749
11816
  log35.warn(TAG31, `#${card.short_id} blocker check failed — deferring to next tick`);
11817
+ await this.emitWaiting(card.id, "Waiting — could not read this card's links to check for blockers. The daemon retries on the next tick.");
11750
11818
  return;
11751
11819
  }
11752
11820
  if (blockers.length > 0) {
@@ -11780,7 +11848,7 @@ class Pool {
11780
11848
  }
11781
11849
  }
11782
11850
  async denyDailyBudget(card, detail) {
11783
- const reason = `Daily budget reached — waiting for reset (${detail})`;
11851
+ const reason = describeGuardDenial("daily_budget", detail);
11784
11852
  const line = `#${card.short_id} skipped (daily_budget): ${detail}`;
11785
11853
  if (this.lastWaitingEmit.get(card.id) === reason)
11786
11854
  log35.debug(TAG31, line);
@@ -11788,6 +11856,11 @@ class Pool {
11788
11856
  log35.warn(TAG31, line);
11789
11857
  await this.emitWaiting(card.id, reason);
11790
11858
  }
11859
+ async noteSkip(cardId, reason) {
11860
+ if (this.isCardKnown(cardId))
11861
+ return;
11862
+ await this.emitWaiting(cardId, reason);
11863
+ }
11791
11864
  lastWaitingEmit = new Map;
11792
11865
  async emitWaiting(cardId, currentTask) {
11793
11866
  if (this.lastWaitingEmit.get(cardId) === currentTask)
@@ -13902,6 +13975,7 @@ var init_startup_model = __esm(() => {
13902
13975
  // src/startup-banner.ts
13903
13976
  var exports_startup_banner = {};
13904
13977
  __export(exports_startup_banner, {
13978
+ limitsLine: () => limitsLine,
13905
13979
  createStartupBanner: () => createStartupBanner,
13906
13980
  contextSourceLine: () => contextSourceLine
13907
13981
  });
@@ -13963,6 +14037,7 @@ function jsonBanner(config, version) {
13963
14037
  log44.info(TAG40, `Harmony Agent Daemon v${version} starting...`);
13964
14038
  log44.info(TAG40, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
13965
14039
  log44.info(TAG40, `Config: ${contextSourceLine(config)}`);
14040
+ log44.info(TAG40, `Limits: ${limitsLine(config)}`);
13966
14041
  if (config.agent.review.enabled) {
13967
14042
  log44.info(TAG40, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
13968
14043
  }
@@ -14042,6 +14117,7 @@ function configRows(config, projectName, gitProvider, httpPort, model) {
14042
14117
  value: `${modelDesc} · ${poolDesc} · ${flowDesc}`
14043
14118
  });
14044
14119
  rows.push({ label: "Runner", value: runnerDesc(config.agent.runner) });
14120
+ rows.push({ label: "Limits", value: limitsLine(config) });
14045
14121
  const tail = [];
14046
14122
  if (gitProvider)
14047
14123
  tail.push(gitProvider);
@@ -14064,6 +14140,21 @@ function contextSourceLine(config) {
14064
14140
  const pinLabel = config.repoAgentPaths.size > 0 ? "repo pin, +auto-merge" : "repo pin";
14065
14141
  return `project+workspace ${path} (${pinLabel}) · rest ${globalPath}`;
14066
14142
  }
14143
+ function limitsLine(config) {
14144
+ const { budget, review, worktree } = config.agent;
14145
+ const cap = (value, unit) => value < 0 ? "unlimited" : `${value} ${unit}`;
14146
+ const parts = [
14147
+ `${budget.maxAttemptsPerCard} attempts/card (budget.maxAttemptsPerCard)`,
14148
+ `${cap(budget.maxTurnsPerCard, "turns")}/card (budget.maxTurnsPerCard)`,
14149
+ `${cap(budget.maxRunsPerCard, "runs")}/card (budget.maxRunsPerCard)`,
14150
+ `${budget.dailyBudgetCents < 0 ? "unlimited" : `$${(budget.dailyBudgetCents / 100).toFixed(2)}`}/day (budget.dailyBudgetCents)`
14151
+ ];
14152
+ if (review.enabled) {
14153
+ parts.push(`${review.maxReviewCycles} review cycles (review.maxReviewCycles)`);
14154
+ }
14155
+ parts.push(`failed branches ${worktree.failedAttemptRetentionDays > 0 ? `${worktree.failedAttemptRetentionDays}d` : "kept"} (worktree.failedAttemptRetentionDays)`);
14156
+ return parts.join(" · ");
14157
+ }
14067
14158
  function titleRule(title) {
14068
14159
  const prefix = "─── ";
14069
14160
  const surround = ` `;
@@ -15020,6 +15111,7 @@ var exports_worktree_gc = {};
15020
15111
  __export(exports_worktree_gc, {
15021
15112
  runWorktreeGc: () => runWorktreeGc,
15022
15113
  pruneFailedRemoteBranches: () => pruneFailedRemoteBranches,
15114
+ noteExpiringBranches: () => noteExpiringBranches,
15023
15115
  isTransientGitNetworkError: () => isTransientGitNetworkError,
15024
15116
  WorktreeGc: () => WorktreeGc
15025
15117
  });
@@ -15151,6 +15243,7 @@ function pruneFailedRemoteBranches(opts) {
15151
15243
  scanned: 0,
15152
15244
  removed: [],
15153
15245
  skipped: [],
15246
+ expiring: [],
15154
15247
  errors: []
15155
15248
  };
15156
15249
  if (!opts.prefix)
@@ -15209,6 +15302,9 @@ function pruneFailedRemoteBranches(opts) {
15209
15302
  result.scanned++;
15210
15303
  if (!Number.isFinite(ts) || ts >= cutoffSecs) {
15211
15304
  result.skipped.push(ref);
15305
+ if (Number.isFinite(ts) && ts < cutoffSecs + EXPIRY_NOTICE_SECS) {
15306
+ result.expiring.push(ref);
15307
+ }
15212
15308
  continue;
15213
15309
  }
15214
15310
  if (clock() > sweepDeadline) {
@@ -15272,7 +15368,10 @@ class WorktreeGc {
15272
15368
  }
15273
15369
  if (this.remoteOpts) {
15274
15370
  try {
15275
- pruneFailedRemoteBranches(this.remoteOpts);
15371
+ const pruned = pruneFailedRemoteBranches(this.remoteOpts);
15372
+ if (pruned.expiring.length > 0) {
15373
+ this.remoteOpts.onExpiring?.(pruned.expiring);
15374
+ }
15276
15375
  } catch (err) {
15277
15376
  log48.warn(TAG44, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
15278
15377
  }
@@ -15284,6 +15383,28 @@ class WorktreeGc {
15284
15383
  }
15285
15384
  }
15286
15385
  }
15386
+ async function noteExpiringBranches(store, client, refs, retentionDays, notified) {
15387
+ for (const ref of refs) {
15388
+ if (notified.has(ref))
15389
+ continue;
15390
+ const owner = store.listCards().find((c) => c.daemonBranch === ref || c.failureHistory?.some((f) => f.recoveryBranch === ref));
15391
+ if (!owner)
15392
+ continue;
15393
+ notified.add(ref);
15394
+ try {
15395
+ await client.addComment(owner.cardId, [
15396
+ "**Failed-attempt branch expires within a day.**",
15397
+ `\`${ref}\` holds this card's unfinished commits. The daemon deletes failed-attempt branches after ${retentionDays} days (\`worktree.failedAttemptRetentionDays\`), and the next sweep removes this one.`,
15398
+ "To keep the work, check it out or open a PR from it now. To keep such branches longer, raise `worktree.failedAttemptRetentionDays` in the daemon config (0 turns the sweep off)."
15399
+ ].join(`
15400
+
15401
+ `), { commentType: "summary" });
15402
+ } catch (err) {
15403
+ notified.delete(ref);
15404
+ log48.warn(TAG44, `Failed to post the expiring-branch note for ${ref}: ${err instanceof Error ? err.message : err}`);
15405
+ }
15406
+ }
15407
+ }
15287
15408
  function getRepoRoot2() {
15288
15409
  try {
15289
15410
  return execFileSync8("git", [...GIT_NO_HOOKS9, "rev-parse", "--show-toplevel"], {
@@ -15293,7 +15414,7 @@ function getRepoRoot2() {
15293
15414
  return null;
15294
15415
  }
15295
15416
  }
15296
- var TAG44 = "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;
15417
+ var TAG44 = "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, EXPIRY_NOTICE_SECS;
15297
15418
  var init_worktree_gc = __esm(() => {
15298
15419
  init_run_gc();
15299
15420
  GIT_NETWORK_EXEC = {
@@ -15318,6 +15439,7 @@ var init_worktree_gc = __esm(() => {
15318
15439
  "enotfound",
15319
15440
  "enetunreach"
15320
15441
  ].join("|"), "i");
15442
+ EXPIRY_NOTICE_SECS = 24 * 60 * 60;
15321
15443
  });
15322
15444
 
15323
15445
  // src/index.ts
@@ -15421,6 +15543,7 @@ async function main() {
15421
15543
  validateSweepConfig(config.agent);
15422
15544
  validateRankingConfig(config.agent);
15423
15545
  validateRetentionConfig(config.agent);
15546
+ validateRunLimitsConfig(config.agent);
15424
15547
  validateVerificationConfig(config.agent);
15425
15548
  validateSizingConfig(config.agent);
15426
15549
  validateHttpConfig(config.agent);
@@ -15518,9 +15641,15 @@ async function main() {
15518
15641
  await settlePlanCriteria(client, "merge-monitor", completedCard);
15519
15642
  };
15520
15643
  }
15644
+ const expiringNotified = new Set;
15521
15645
  const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs, config.agent.worktree.failedAttemptRetentionDays > 0 ? {
15522
15646
  prefix: config.agent.worktree.failedBranchPrefix,
15523
- retentionDays: config.agent.worktree.failedAttemptRetentionDays
15647
+ retentionDays: config.agent.worktree.failedAttemptRetentionDays,
15648
+ onExpiring: (refs) => {
15649
+ noteExpiringBranches(stateStore, client, refs, config.agent.worktree.failedAttemptRetentionDays, expiringNotified).catch((err) => {
15650
+ log49.warn(TAG45, `Expiring-branch notes failed: ${err}`);
15651
+ });
15652
+ }
15524
15653
  } : undefined, config.agent.retention);
15525
15654
  let boardReviewer = null;
15526
15655
  if (config.agent.boardReview.enabled) {
@@ -15715,6 +15844,7 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15715
15844
  const column = columns.find((c) => c.id === card.column_id);
15716
15845
  if (!column) {
15717
15846
  log49.warn(TAG45, `Column not found for card ${cardId}`);
15847
+ await pool.noteSkip(cardId, "Not picked up — the card's column was not found on the board. Check the card is in a column of this project.");
15718
15848
  return;
15719
15849
  }
15720
15850
  const route = classifyPickup(column.name, {
@@ -15723,6 +15853,8 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15723
15853
  });
15724
15854
  if (!route) {
15725
15855
  log49.info(TAG45, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
15856
+ const reviewColumns = config.agent.review.enabled ? config.agent.review.pickupColumns : [];
15857
+ await pool.noteSkip(card.id, `Not picked up — "${column.name}" is not a pickup column. Pickup: ${config.agent.pickupColumns.join(", ")} (\`pickupColumns\`)${reviewColumns.length > 0 ? `; review: ${reviewColumns.join(", ")} (\`review.pickupColumns\`)` : ""}. Move the card into one of them.`);
15726
15858
  return;
15727
15859
  }
15728
15860
  const mode = route.mode;
@@ -15731,14 +15863,17 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15731
15863
  const subtasks = card.subtasks ?? [];
15732
15864
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
15733
15865
  log49.debug(TAG45, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
15866
+ await pool.noteSkip(card.id, `Not reviewed — the card already has the "${config.agent.review.approvedLabel}" label (\`review.approvedLabel\`). Remove the label to queue a review.`);
15734
15867
  return;
15735
15868
  }
15736
15869
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
15737
15870
  log49.debug(TAG45, `Card #${card.short_id} has "${NEED_REVIEW_LABEL}" label (needs human) — skipping review`);
15871
+ await pool.noteSkip(card.id, `Not reviewed — the card has the "${NEED_REVIEW_LABEL}" label, so a person owns the next step. Remove the label to queue another review.`);
15738
15872
  return;
15739
15873
  }
15740
15874
  if (mode === "review" && !qualifiesForAutoReview(card.description)) {
15741
15875
  log49.info(TAG45, `Card #${card.short_id} has no branch or PR reference — skipping auto-review`);
15876
+ await pool.noteSkip(card.id, "Not reviewed — the card description has no `Branch:` line and no PR link, so there is nothing to review. Add one to queue a review.");
15742
15877
  return;
15743
15878
  }
15744
15879
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
@@ -16737,6 +16872,7 @@ async function doctorCommand() {
16737
16872
  validateHttpConfig: validateHttpConfig2,
16738
16873
  validateRankingConfig: validateRankingConfig2,
16739
16874
  validateRetentionConfig: validateRetentionConfig2,
16875
+ validateRunLimitsConfig: validateRunLimitsConfig2,
16740
16876
  validateSizingConfig: validateSizingConfig2,
16741
16877
  validateSweepConfig: validateSweepConfig2,
16742
16878
  validateVerificationConfig: validateVerificationConfig2
@@ -16754,6 +16890,7 @@ async function doctorCommand() {
16754
16890
  validateSweepConfig2(config.agent);
16755
16891
  validateRankingConfig2(config.agent);
16756
16892
  validateRetentionConfig2(config.agent);
16893
+ validateRunLimitsConfig2(config.agent);
16757
16894
  validateVerificationConfig2(config.agent);
16758
16895
  validateSizingConfig2(config.agent);
16759
16896
  validateHttpConfig2(config.agent);
package/dist/index.js CHANGED
@@ -734,6 +734,18 @@ function formatDailyCap(cents) {
734
734
  function formatTurnCap(turns) {
735
735
  return turns < 0 ? "no cap" : `${turns} turns`;
736
736
  }
737
+ function describeGuardDenial(reason, detail) {
738
+ switch (reason) {
739
+ case "max_attempts":
740
+ return `Stopped — ${detail} (\`budget.maxAttemptsPerCard\`). Reassign the card to grant a fresh attempt, or raise the limit in the daemon config.`;
741
+ case "card_turns":
742
+ return `Stopped — ${detail} (\`budget.maxTurnsPerCard\`). Reassign the card to clear the count, or raise the limit in the daemon config (-1 removes it).`;
743
+ case "card_runs":
744
+ return `Stopped — ${detail} (\`budget.maxRunsPerCard\`). Reassign the card to clear the count, or raise the limit in the daemon config (-1 removes it).`;
745
+ case "daily_budget":
746
+ return `Daily budget reached — waiting for the UTC reset (${detail}, \`budget.dailyBudgetCents\`). Raise the limit in the daemon config to continue today (-1 removes it).`;
747
+ }
748
+ }
737
749
 
738
750
  class BudgetGuard {
739
751
  config;
@@ -2312,6 +2324,7 @@ var init_plan_phase = __esm(() => {
2312
2324
  mode: "advisory",
2313
2325
  model: "sonnet",
2314
2326
  maxTurns: 40,
2327
+ timeoutMs: 10 * 60000,
2315
2328
  postComment: true,
2316
2329
  awaitingApprovalColumn: "To Do",
2317
2330
  minComplexityScore: 3,
@@ -2372,6 +2385,9 @@ var init_types = __esm(() => {
2372
2385
  },
2373
2386
  scoreBands: DEFAULT_SCORE_BANDS,
2374
2387
  sizingModel: "",
2388
+ sizingMaxTurns: 25,
2389
+ sizingMaxBudgetUsd: 0.75,
2390
+ sizingTimeoutMs: 240000,
2375
2391
  researchRule: "shadow",
2376
2392
  reviewModel: "sonnet",
2377
2393
  deepReviewModel: "sonnet",
@@ -2474,6 +2490,10 @@ var init_types = __esm(() => {
2474
2490
  runRecordDays: 14,
2475
2491
  runLogDays: 30
2476
2492
  },
2493
+ steering: {
2494
+ maxTurns: 15,
2495
+ maxIterations: 10
2496
+ },
2477
2497
  planning: DEFAULT_PLANNING_CONFIG,
2478
2498
  contractFirst: DEFAULT_CONTRACT_CONFIG,
2479
2499
  boardReview: DEFAULT_BOARD_REVIEW_CONFIG,
@@ -2494,6 +2514,7 @@ __export(exports_config_validation, {
2494
2514
  validateVerificationConfig: () => validateVerificationConfig,
2495
2515
  validateSweepConfig: () => validateSweepConfig,
2496
2516
  validateSizingConfig: () => validateSizingConfig,
2517
+ validateRunLimitsConfig: () => validateRunLimitsConfig,
2497
2518
  validateRetentionConfig: () => validateRetentionConfig,
2498
2519
  validateRankingConfig: () => validateRankingConfig,
2499
2520
  validateHttpConfig: () => validateHttpConfig,
@@ -2825,6 +2846,41 @@ function validateRetentionConfig(config) {
2825
2846
  ` + ` Set a positive number of days, or a negative value to keep this store forever.`, [issue]);
2826
2847
  }
2827
2848
  }
2849
+ function validateRunLimitsConfig(config) {
2850
+ const wholeOrOff = [
2851
+ ["planning.timeoutMs", config.planning.timeoutMs],
2852
+ ["steering.maxTurns", config.steering.maxTurns],
2853
+ ["steering.maxIterations", config.steering.maxIterations],
2854
+ ["claude.sizingMaxTurns", config.claude.sizingMaxTurns],
2855
+ ["claude.sizingTimeoutMs", config.claude.sizingTimeoutMs]
2856
+ ];
2857
+ const issues = [];
2858
+ for (const [key, value] of wholeOrOff) {
2859
+ if (value === -1)
2860
+ continue;
2861
+ if (!Number.isInteger(value) || value < 1) {
2862
+ issues.push(`${key}: ${value === 0 ? '0 is ambiguous — it could mean "none allowed" or "no limit"' : `${JSON.stringify(value)} is not a positive whole number`}`);
2863
+ }
2864
+ }
2865
+ const budget = config.claude.sizingMaxBudgetUsd;
2866
+ if (budget !== -1 && !(Number.isFinite(budget) && budget > 0)) {
2867
+ issues.push(`claude.sizingMaxBudgetUsd: ${budget === 0 ? '0 is ambiguous — it could mean "no spend allowed" or "no limit"' : `${JSON.stringify(budget)} is not a positive number`}`);
2868
+ }
2869
+ for (const [key, value] of [
2870
+ ["claude.maxTurns", config.claude.maxTurns],
2871
+ ["claude.reviewMaxTurns", config.claude.reviewMaxTurns]
2872
+ ]) {
2873
+ if (!Number.isInteger(value) || value < 1) {
2874
+ issues.push(`${key}: ${JSON.stringify(value)} is not a positive whole number`);
2875
+ }
2876
+ }
2877
+ if (issues.length > 0) {
2878
+ throw new ConfigValidationError(`Invalid agent config — run limits:
2879
+ - ${issues.join(`
2880
+ - `)}
2881
+ ` + ` Set a positive value, or -1 to remove the limit (not available for claude.maxTurns and claude.reviewMaxTurns).`, issues);
2882
+ }
2883
+ }
2828
2884
  function validateRankingConfig(config) {
2829
2885
  const issues = [];
2830
2886
  const entries = Object.entries(config.ranking);
@@ -7183,9 +7239,11 @@ ${runLogTail}
7183
7239
  } else {
7184
7240
  await breakReviewLoopIfNeeded();
7185
7241
  }
7242
+ const movedTo = handbackVerdict.proceed ? `I moved the card to "${config.review.moveToColumn}" instead of sending it back to "${config.review.failColumn}" again.` : "I left the card where it is instead of sending it back again.";
7186
7243
  const body = [
7187
7244
  "**Review — needs human review.**",
7188
- `Reached max review cycles (${maxCycles}). Please review manually.`,
7245
+ `This card reached the limit of ${maxCycles} review cycles (\`review.maxReviewCycles\`). ${movedTo}`,
7246
+ "Next: read the earlier review comments and fix or merge the branch by hand. To allow more automatic rounds, raise `review.maxReviewCycles` in the daemon config.",
7189
7247
  result.summary || ""
7190
7248
  ].filter(Boolean).join(`
7191
7249
 
@@ -9901,6 +9959,7 @@ var init_workspace_model_config = __esm(() => {
9901
9959
  // src/worker.ts
9902
9960
  import { execFileSync as execFileSync7 } from "node:child_process";
9903
9961
  import {
9962
+ boundedTimerMs,
9904
9963
  chooseImplementModel,
9905
9964
  classifyRunError as classifyRunError2,
9906
9965
  createWorktree,
@@ -10791,7 +10850,10 @@ ${prompt}`;
10791
10850
  title: card.title,
10792
10851
  description: card.description,
10793
10852
  model,
10794
- scoreBands: claude.scoreBands
10853
+ scoreBands: claude.scoreBands,
10854
+ maxTurns: claude.sizingMaxTurns < 0 ? null : claude.sizingMaxTurns,
10855
+ maxBudgetUsd: claude.sizingMaxBudgetUsd < 0 ? null : claude.sizingMaxBudgetUsd,
10856
+ timeoutMs: claude.sizingTimeoutMs < 0 ? null : claude.sizingTimeoutMs
10795
10857
  });
10796
10858
  if (outcome.status === "sized") {
10797
10859
  log33.info(this.tag, `Sized #${card.short_id}: complexity ${outcome.sizing.complexity}/10 -> ${outcome.sizing.tier}`);
@@ -11030,6 +11092,10 @@ ${prompt}`;
11030
11092
  }
11031
11093
  }
11032
11094
  }
11095
+ armPhaseTimer(onExpiry) {
11096
+ const { timeoutMs } = this.config.planning;
11097
+ return setTimeout(onExpiry, boundedTimerMs(timeoutMs < 0 ? this.config.maxTimeout : timeoutMs));
11098
+ }
11033
11099
  async runPlanningPhase(enriched) {
11034
11100
  const planning = this.config.planning;
11035
11101
  const { card } = enriched;
@@ -11044,7 +11110,7 @@ ${prompt}`;
11044
11110
  }).catch(() => {});
11045
11111
  const planPrompt = buildPlanPrompt(enriched, this.worktreePath);
11046
11112
  let planTimedOut = false;
11047
- const planTimeout = setTimeout(() => {
11113
+ const planTimeout = this.armPhaseTimer(() => {
11048
11114
  planTimedOut = true;
11049
11115
  log33.warn(this.tag, "Planning pass exceeded timeout — abandoning, implementing directly");
11050
11116
  if (this.sdkRunner) {
@@ -11055,7 +11121,7 @@ ${prompt}`;
11055
11121
  sigtermTimeoutMs: 5000
11056
11122
  }).catch(() => {});
11057
11123
  }
11058
- }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
11124
+ });
11059
11125
  try {
11060
11126
  await this.spawnClaude(planPrompt, card, [], {
11061
11127
  model: planning.model,
@@ -11155,7 +11221,7 @@ ${prompt}`;
11155
11221
  }).catch(() => {});
11156
11222
  const contractPrompt = buildContractPrompt(enriched, this.worktreePath);
11157
11223
  let contractTimedOut = false;
11158
- const contractTimeout = setTimeout(() => {
11224
+ const contractTimeout = this.armPhaseTimer(() => {
11159
11225
  contractTimedOut = true;
11160
11226
  log33.warn(this.tag, "Contract pass exceeded timeout — abandoning, implementing directly");
11161
11227
  if (this.sdkRunner) {
@@ -11166,7 +11232,7 @@ ${prompt}`;
11166
11232
  sigtermTimeoutMs: 5000
11167
11233
  }).catch(() => {});
11168
11234
  }
11169
- }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
11235
+ });
11170
11236
  try {
11171
11237
  await this.spawnClaude(contractPrompt, card, [], {
11172
11238
  model: contractCfg.model,
@@ -11218,7 +11284,8 @@ ${prompt}`;
11218
11284
  async drainSteeringMessages(card, subtasks) {
11219
11285
  if (!this.cliSessionId || !this.sessionId || !this.cardId)
11220
11286
  return;
11221
- for (let i = 0;i < MAX_STEERING_ITERATIONS && !this.aborted; i++) {
11287
+ const { maxIterations, maxTurns } = this.config.steering;
11288
+ for (let i = 0;(maxIterations < 0 || i < maxIterations) && !this.aborted; i++) {
11222
11289
  let messages;
11223
11290
  try {
11224
11291
  const res = await this.client.getPendingUserMessages(this.cardId, this.sessionId, this.lastDrainedSeq);
@@ -11236,7 +11303,7 @@ ${prompt}`;
11236
11303
  try {
11237
11304
  await this.spawnClaude(buildSteeringPrompt(messages.map((m) => frameSteeringQuestion(m.text, m.questionCommentId))), card, subtasks, {
11238
11305
  model: this.selectImplementModel(card),
11239
- maxTurns: STEERING_MAX_TURNS,
11306
+ maxTurns: maxTurns < 0 ? undefined : maxTurns,
11240
11307
  resumeSessionId: this.cliSessionId,
11241
11308
  noCommitNotice: true,
11242
11309
  ...this.activeRunSpawnOpts ?? {}
@@ -11542,7 +11609,7 @@ ${prompt}`;
11542
11609
  this.chargedTokens = emptyTokens();
11543
11610
  }
11544
11611
  }
11545
- var TAG30 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, STEERING_MAX_TURNS = 15, MAX_STEERING_ITERATIONS = 10, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*", PLAN_PHASE_TIMEOUT;
11612
+ var TAG30 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*";
11546
11613
  var init_worker = __esm(() => {
11547
11614
  init_src();
11548
11615
  init_board_helpers();
@@ -11562,7 +11629,6 @@ var init_worker = __esm(() => {
11562
11629
  init_transitions();
11563
11630
  init_types();
11564
11631
  init_workspace_model_config();
11565
- PLAN_PHASE_TIMEOUT = 10 * 60000;
11566
11632
  });
11567
11633
 
11568
11634
  // src/workspace-model-cache.ts
@@ -11724,13 +11790,13 @@ class Pool {
11724
11790
  if (mode === "implement") {
11725
11791
  if (this.authPaused) {
11726
11792
  log35.debug(TAG31, `#${card.short_id} held — agent paused (auth error)`);
11727
- await this.emitWaiting(card.id, "Agent paused — Anthropic auth error, check API credentials");
11793
+ await this.emitWaiting(card.id, "Agent paused — Anthropic auth error. Fix the API credentials, then restart the daemon.");
11728
11794
  return;
11729
11795
  }
11730
11796
  const cooldownMs = this.apiCooldownRemainingMs();
11731
11797
  if (cooldownMs > 0) {
11732
11798
  log35.debug(TAG31, `#${card.short_id} held — API cooldown ${Math.round(cooldownMs / 1000)}s remaining`);
11733
- await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying in ~${Math.round(cooldownMs / 1000)}s`);
11799
+ await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying after ${new Date(this.apiCooldownUntil).toISOString().slice(11, 16)} UTC. Nothing to change: the daemon resumes by itself.`);
11734
11800
  return;
11735
11801
  }
11736
11802
  const decision = this.budget.check(card.id);
@@ -11739,6 +11805,7 @@ class Pool {
11739
11805
  await this.denyDailyBudget(card, decision.detail);
11740
11806
  } else {
11741
11807
  log35.debug(TAG31, `#${card.short_id} skipped (${decision.reason}): ${decision.detail}`);
11808
+ await this.emitWaiting(card.id, describeGuardDenial(decision.reason, decision.detail));
11742
11809
  }
11743
11810
  return;
11744
11811
  }
@@ -11746,6 +11813,7 @@ class Pool {
11746
11813
  chainSuccessors = successors;
11747
11814
  if (blockers === null) {
11748
11815
  log35.warn(TAG31, `#${card.short_id} blocker check failed — deferring to next tick`);
11816
+ await this.emitWaiting(card.id, "Waiting — could not read this card's links to check for blockers. The daemon retries on the next tick.");
11749
11817
  return;
11750
11818
  }
11751
11819
  if (blockers.length > 0) {
@@ -11779,7 +11847,7 @@ class Pool {
11779
11847
  }
11780
11848
  }
11781
11849
  async denyDailyBudget(card, detail) {
11782
- const reason = `Daily budget reached — waiting for reset (${detail})`;
11850
+ const reason = describeGuardDenial("daily_budget", detail);
11783
11851
  const line = `#${card.short_id} skipped (daily_budget): ${detail}`;
11784
11852
  if (this.lastWaitingEmit.get(card.id) === reason)
11785
11853
  log35.debug(TAG31, line);
@@ -11787,6 +11855,11 @@ class Pool {
11787
11855
  log35.warn(TAG31, line);
11788
11856
  await this.emitWaiting(card.id, reason);
11789
11857
  }
11858
+ async noteSkip(cardId, reason) {
11859
+ if (this.isCardKnown(cardId))
11860
+ return;
11861
+ await this.emitWaiting(cardId, reason);
11862
+ }
11790
11863
  lastWaitingEmit = new Map;
11791
11864
  async emitWaiting(cardId, currentTask) {
11792
11865
  if (this.lastWaitingEmit.get(cardId) === currentTask)
@@ -13901,6 +13974,7 @@ var init_startup_model = __esm(() => {
13901
13974
  // src/startup-banner.ts
13902
13975
  var exports_startup_banner = {};
13903
13976
  __export(exports_startup_banner, {
13977
+ limitsLine: () => limitsLine,
13904
13978
  createStartupBanner: () => createStartupBanner,
13905
13979
  contextSourceLine: () => contextSourceLine
13906
13980
  });
@@ -13962,6 +14036,7 @@ function jsonBanner(config, version) {
13962
14036
  log44.info(TAG40, `Harmony Agent Daemon v${version} starting...`);
13963
14037
  log44.info(TAG40, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
13964
14038
  log44.info(TAG40, `Config: ${contextSourceLine(config)}`);
14039
+ log44.info(TAG40, `Limits: ${limitsLine(config)}`);
13965
14040
  if (config.agent.review.enabled) {
13966
14041
  log44.info(TAG40, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
13967
14042
  }
@@ -14041,6 +14116,7 @@ function configRows(config, projectName, gitProvider, httpPort, model) {
14041
14116
  value: `${modelDesc} · ${poolDesc} · ${flowDesc}`
14042
14117
  });
14043
14118
  rows.push({ label: "Runner", value: runnerDesc(config.agent.runner) });
14119
+ rows.push({ label: "Limits", value: limitsLine(config) });
14044
14120
  const tail = [];
14045
14121
  if (gitProvider)
14046
14122
  tail.push(gitProvider);
@@ -14063,6 +14139,21 @@ function contextSourceLine(config) {
14063
14139
  const pinLabel = config.repoAgentPaths.size > 0 ? "repo pin, +auto-merge" : "repo pin";
14064
14140
  return `project+workspace ${path} (${pinLabel}) · rest ${globalPath}`;
14065
14141
  }
14142
+ function limitsLine(config) {
14143
+ const { budget, review, worktree } = config.agent;
14144
+ const cap = (value, unit) => value < 0 ? "unlimited" : `${value} ${unit}`;
14145
+ const parts = [
14146
+ `${budget.maxAttemptsPerCard} attempts/card (budget.maxAttemptsPerCard)`,
14147
+ `${cap(budget.maxTurnsPerCard, "turns")}/card (budget.maxTurnsPerCard)`,
14148
+ `${cap(budget.maxRunsPerCard, "runs")}/card (budget.maxRunsPerCard)`,
14149
+ `${budget.dailyBudgetCents < 0 ? "unlimited" : `$${(budget.dailyBudgetCents / 100).toFixed(2)}`}/day (budget.dailyBudgetCents)`
14150
+ ];
14151
+ if (review.enabled) {
14152
+ parts.push(`${review.maxReviewCycles} review cycles (review.maxReviewCycles)`);
14153
+ }
14154
+ parts.push(`failed branches ${worktree.failedAttemptRetentionDays > 0 ? `${worktree.failedAttemptRetentionDays}d` : "kept"} (worktree.failedAttemptRetentionDays)`);
14155
+ return parts.join(" · ");
14156
+ }
14066
14157
  function titleRule(title) {
14067
14158
  const prefix = "─── ";
14068
14159
  const surround = ` `;
@@ -15019,6 +15110,7 @@ var exports_worktree_gc = {};
15019
15110
  __export(exports_worktree_gc, {
15020
15111
  runWorktreeGc: () => runWorktreeGc,
15021
15112
  pruneFailedRemoteBranches: () => pruneFailedRemoteBranches,
15113
+ noteExpiringBranches: () => noteExpiringBranches,
15022
15114
  isTransientGitNetworkError: () => isTransientGitNetworkError,
15023
15115
  WorktreeGc: () => WorktreeGc
15024
15116
  });
@@ -15150,6 +15242,7 @@ function pruneFailedRemoteBranches(opts) {
15150
15242
  scanned: 0,
15151
15243
  removed: [],
15152
15244
  skipped: [],
15245
+ expiring: [],
15153
15246
  errors: []
15154
15247
  };
15155
15248
  if (!opts.prefix)
@@ -15208,6 +15301,9 @@ function pruneFailedRemoteBranches(opts) {
15208
15301
  result.scanned++;
15209
15302
  if (!Number.isFinite(ts) || ts >= cutoffSecs) {
15210
15303
  result.skipped.push(ref);
15304
+ if (Number.isFinite(ts) && ts < cutoffSecs + EXPIRY_NOTICE_SECS) {
15305
+ result.expiring.push(ref);
15306
+ }
15211
15307
  continue;
15212
15308
  }
15213
15309
  if (clock() > sweepDeadline) {
@@ -15271,7 +15367,10 @@ class WorktreeGc {
15271
15367
  }
15272
15368
  if (this.remoteOpts) {
15273
15369
  try {
15274
- pruneFailedRemoteBranches(this.remoteOpts);
15370
+ const pruned = pruneFailedRemoteBranches(this.remoteOpts);
15371
+ if (pruned.expiring.length > 0) {
15372
+ this.remoteOpts.onExpiring?.(pruned.expiring);
15373
+ }
15275
15374
  } catch (err) {
15276
15375
  log48.warn(TAG44, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
15277
15376
  }
@@ -15283,6 +15382,28 @@ class WorktreeGc {
15283
15382
  }
15284
15383
  }
15285
15384
  }
15385
+ async function noteExpiringBranches(store, client, refs, retentionDays, notified) {
15386
+ for (const ref of refs) {
15387
+ if (notified.has(ref))
15388
+ continue;
15389
+ const owner = store.listCards().find((c) => c.daemonBranch === ref || c.failureHistory?.some((f) => f.recoveryBranch === ref));
15390
+ if (!owner)
15391
+ continue;
15392
+ notified.add(ref);
15393
+ try {
15394
+ await client.addComment(owner.cardId, [
15395
+ "**Failed-attempt branch expires within a day.**",
15396
+ `\`${ref}\` holds this card's unfinished commits. The daemon deletes failed-attempt branches after ${retentionDays} days (\`worktree.failedAttemptRetentionDays\`), and the next sweep removes this one.`,
15397
+ "To keep the work, check it out or open a PR from it now. To keep such branches longer, raise `worktree.failedAttemptRetentionDays` in the daemon config (0 turns the sweep off)."
15398
+ ].join(`
15399
+
15400
+ `), { commentType: "summary" });
15401
+ } catch (err) {
15402
+ notified.delete(ref);
15403
+ log48.warn(TAG44, `Failed to post the expiring-branch note for ${ref}: ${err instanceof Error ? err.message : err}`);
15404
+ }
15405
+ }
15406
+ }
15286
15407
  function getRepoRoot2() {
15287
15408
  try {
15288
15409
  return execFileSync8("git", [...GIT_NO_HOOKS9, "rev-parse", "--show-toplevel"], {
@@ -15292,7 +15413,7 @@ function getRepoRoot2() {
15292
15413
  return null;
15293
15414
  }
15294
15415
  }
15295
- var TAG44 = "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;
15416
+ var TAG44 = "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, EXPIRY_NOTICE_SECS;
15296
15417
  var init_worktree_gc = __esm(() => {
15297
15418
  init_run_gc();
15298
15419
  GIT_NETWORK_EXEC = {
@@ -15317,6 +15438,7 @@ var init_worktree_gc = __esm(() => {
15317
15438
  "enotfound",
15318
15439
  "enetunreach"
15319
15440
  ].join("|"), "i");
15441
+ EXPIRY_NOTICE_SECS = 24 * 60 * 60;
15320
15442
  });
15321
15443
 
15322
15444
  // src/index.ts
@@ -15420,6 +15542,7 @@ async function main() {
15420
15542
  validateSweepConfig(config.agent);
15421
15543
  validateRankingConfig(config.agent);
15422
15544
  validateRetentionConfig(config.agent);
15545
+ validateRunLimitsConfig(config.agent);
15423
15546
  validateVerificationConfig(config.agent);
15424
15547
  validateSizingConfig(config.agent);
15425
15548
  validateHttpConfig(config.agent);
@@ -15517,9 +15640,15 @@ async function main() {
15517
15640
  await settlePlanCriteria(client, "merge-monitor", completedCard);
15518
15641
  };
15519
15642
  }
15643
+ const expiringNotified = new Set;
15520
15644
  const worktreeGc = new WorktreeGc(config.agent.worktree.basePath, stateStore, config.agent.timing.worktreeGcIntervalMs, config.agent.worktree.failedAttemptRetentionDays > 0 ? {
15521
15645
  prefix: config.agent.worktree.failedBranchPrefix,
15522
- retentionDays: config.agent.worktree.failedAttemptRetentionDays
15646
+ retentionDays: config.agent.worktree.failedAttemptRetentionDays,
15647
+ onExpiring: (refs) => {
15648
+ noteExpiringBranches(stateStore, client, refs, config.agent.worktree.failedAttemptRetentionDays, expiringNotified).catch((err) => {
15649
+ log49.warn(TAG45, `Expiring-branch notes failed: ${err}`);
15650
+ });
15651
+ }
15523
15652
  } : undefined, config.agent.retention);
15524
15653
  let boardReviewer = null;
15525
15654
  if (config.agent.boardReview.enabled) {
@@ -15714,6 +15843,7 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15714
15843
  const column = columns.find((c) => c.id === card.column_id);
15715
15844
  if (!column) {
15716
15845
  log49.warn(TAG45, `Column not found for card ${cardId}`);
15846
+ await pool.noteSkip(cardId, "Not picked up — the card's column was not found on the board. Check the card is in a column of this project.");
15717
15847
  return;
15718
15848
  }
15719
15849
  const route = classifyPickup(column.name, {
@@ -15722,6 +15852,8 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15722
15852
  });
15723
15853
  if (!route) {
15724
15854
  log49.info(TAG45, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
15855
+ const reviewColumns = config.agent.review.enabled ? config.agent.review.pickupColumns : [];
15856
+ await pool.noteSkip(card.id, `Not picked up — "${column.name}" is not a pickup column. Pickup: ${config.agent.pickupColumns.join(", ")} (\`pickupColumns\`)${reviewColumns.length > 0 ? `; review: ${reviewColumns.join(", ")} (\`review.pickupColumns\`)` : ""}. Move the card into one of them.`);
15725
15857
  return;
15726
15858
  }
15727
15859
  const mode = route.mode;
@@ -15730,14 +15862,17 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
15730
15862
  const subtasks = card.subtasks ?? [];
15731
15863
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
15732
15864
  log49.debug(TAG45, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
15865
+ await pool.noteSkip(card.id, `Not reviewed — the card already has the "${config.agent.review.approvedLabel}" label (\`review.approvedLabel\`). Remove the label to queue a review.`);
15733
15866
  return;
15734
15867
  }
15735
15868
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
15736
15869
  log49.debug(TAG45, `Card #${card.short_id} has "${NEED_REVIEW_LABEL}" label (needs human) — skipping review`);
15870
+ await pool.noteSkip(card.id, `Not reviewed — the card has the "${NEED_REVIEW_LABEL}" label, so a person owns the next step. Remove the label to queue another review.`);
15737
15871
  return;
15738
15872
  }
15739
15873
  if (mode === "review" && !qualifiesForAutoReview(card.description)) {
15740
15874
  log49.info(TAG45, `Card #${card.short_id} has no branch or PR reference — skipping auto-review`);
15875
+ await pool.noteSkip(card.id, "Not reviewed — the card description has no `Branch:` line and no PR link, so there is nothing to review. Add one to queue a review.");
15741
15876
  return;
15742
15877
  }
15743
15878
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.49.5",
3
+ "version": "1.49.6",
4
4
  "description": "Push-based agent daemon for Harmony — picks up assigned cards, builds them in isolated git worktrees, and opens PRs. It runs where you put it.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@anthropic-ai/claude-agent-sdk": "^0.3.278",
48
- "@gethmy/harness": "1.19.12",
48
+ "@gethmy/harness": "1.19.13",
49
49
  "@gethmy/mcp": "3.23.1",
50
50
  "@supabase/supabase-js": "2.95.3"
51
51
  },