@gethmy/agent 1.28.2 → 1.29.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 (4) hide show
  1. package/README.md +14 -5
  2. package/dist/cli.js +987 -160
  3. package/dist/index.js +929 -157
  4. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -584,6 +584,68 @@ var init_board_reviewer = __esm(() => {
584
584
  init_board_review();
585
585
  });
586
586
 
587
+ // src/budget.ts
588
+ function usd(cents) {
589
+ return `$${(cents / 100).toFixed(2)}`;
590
+ }
591
+ function formatDailyCap(cents) {
592
+ return cents < 0 ? "no cap" : usd(cents);
593
+ }
594
+
595
+ class BudgetGuard {
596
+ config;
597
+ store;
598
+ constructor(config, store) {
599
+ this.config = config;
600
+ this.store = store;
601
+ }
602
+ check(cardId) {
603
+ const card = this.store.getCard(cardId);
604
+ if (card && card.attempts >= this.config.maxAttemptsPerCard) {
605
+ return {
606
+ allow: false,
607
+ reason: "max_attempts",
608
+ detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`
609
+ };
610
+ }
611
+ return this.checkDailyBudget();
612
+ }
613
+ checkDailyBudget() {
614
+ const cap = this.config.dailyBudgetCents;
615
+ if (cap < 0)
616
+ return { allow: true };
617
+ const spentCents = this.store.getDailyCostCents();
618
+ if (spentCents < cap)
619
+ return { allow: true };
620
+ return {
621
+ allow: false,
622
+ reason: "daily_budget",
623
+ detail: `${usd(spentCents)} of ${usd(cap)} spent today (UTC)`
624
+ };
625
+ }
626
+ }
627
+ function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
628
+ const wayBackIn = pauseEnabled ? "Continue to grant a fresh attempt, or reassign the card." : "Reassign the card to grant a fresh attempt.";
629
+ const lines = [
630
+ "**Out of attempts — over to you.**",
631
+ `Stopped after ${maxAttempts} failed attempt${maxAttempts === 1 ? "" : "s"}. ${wayBackIn}`
632
+ ];
633
+ if (failures.length > 0) {
634
+ lines.push("", "Recent failures:");
635
+ for (const f of failures) {
636
+ const when = new Date(f.ts).toISOString().replace("T", " ").slice(0, 16);
637
+ const tag = f.reason ? ` [${f.reason}]` : "";
638
+ const branch = f.recoveryBranch ? `
639
+ recover: \`git fetch && git checkout ${f.recoveryBranch}\`` : "";
640
+ lines.push(`- ${when} UTC${tag} — ${f.summary}${branch}`);
641
+ }
642
+ } else {
643
+ lines.push("", "_No prior failure summaries recorded._");
644
+ }
645
+ return lines.join(`
646
+ `);
647
+ }
648
+
587
649
  // ../harmony-shared/dist/agentCommentTrust.js
588
650
  function isDaemonAuthoredComment(comment, identity) {
589
651
  if (comment.author_type !== "agent")
@@ -2103,6 +2165,13 @@ var init_types2 = __esm(() => {
2103
2165
  pickupColumns: ["To Do"],
2104
2166
  priorityLabels: { urgent: 100, critical: 90, bug: 50 },
2105
2167
  columnBoost: true,
2168
+ ranking: {
2169
+ priorityWeight: 100,
2170
+ successorWeight: 25,
2171
+ successorCap: 4,
2172
+ agePerDayWeight: 5,
2173
+ ageCapDays: 30
2174
+ },
2106
2175
  runner: "sdk",
2107
2176
  completion: {
2108
2177
  createPR: false,
@@ -2189,7 +2258,14 @@ var init_types2 = __esm(() => {
2189
2258
  planning: DEFAULT_PLANNING_CONFIG,
2190
2259
  playbooks: { enabled: true, humanStageColumns: [], metrics: {} },
2191
2260
  contractFirst: DEFAULT_CONTRACT_CONFIG,
2192
- boardReview: DEFAULT_BOARD_REVIEW_CONFIG
2261
+ boardReview: DEFAULT_BOARD_REVIEW_CONFIG,
2262
+ sweep: {
2263
+ enabled: false,
2264
+ requireLabel: "",
2265
+ trustedAuthors: [],
2266
+ maxProbesPerTick: 5,
2267
+ maxCardsPerSweep: 10
2268
+ }
2193
2269
  };
2194
2270
  });
2195
2271
 
@@ -2259,6 +2335,10 @@ function loadDaemonConfig() {
2259
2335
  ...DEFAULT_AGENT_CONFIG.completion,
2260
2336
  ...agentOverrides.completion ?? {}
2261
2337
  },
2338
+ ranking: {
2339
+ ...DEFAULT_AGENT_CONFIG.ranking,
2340
+ ...agentOverrides.ranking ?? {}
2341
+ },
2262
2342
  claude: {
2263
2343
  ...DEFAULT_AGENT_CONFIG.claude,
2264
2344
  ...agentOverrides.claude ?? {}
@@ -2306,6 +2386,13 @@ function loadDaemonConfig() {
2306
2386
  boardReview: {
2307
2387
  ...DEFAULT_AGENT_CONFIG.boardReview,
2308
2388
  ...agentOverrides.boardReview ?? {}
2389
+ },
2390
+ sweep: {
2391
+ ...DEFAULT_AGENT_CONFIG.sweep,
2392
+ ...agentOverrides.sweep ?? {},
2393
+ trustedAuthors: [
2394
+ ...agentOverrides.sweep?.trustedAuthors ?? DEFAULT_AGENT_CONFIG.sweep.trustedAuthors
2395
+ ]
2309
2396
  }
2310
2397
  };
2311
2398
  if (agent.runner !== "cli" && agent.runner !== "sdk") {
@@ -2349,6 +2436,67 @@ function validateAutoMergeConfig(config) {
2349
2436
  throw new ConfigValidationError(`Invalid agent config — review.autoMerge.strategy "${s}" must be one of: ${valid.join(", ")}`, [`review.autoMerge.strategy: invalid value "${s}"`]);
2350
2437
  }
2351
2438
  }
2439
+ function validateSweepConfig(config) {
2440
+ const sweep = config.sweep;
2441
+ const issues = [];
2442
+ if (!Number.isInteger(sweep.maxProbesPerTick) || sweep.maxProbesPerTick < 1) {
2443
+ issues.push(`sweep.maxProbesPerTick: must be an integer >= 1, got ${JSON.stringify(sweep.maxProbesPerTick)}`);
2444
+ }
2445
+ if (!Number.isInteger(sweep.maxCardsPerSweep) || sweep.maxCardsPerSweep === 0) {
2446
+ issues.push(sweep.maxCardsPerSweep === 0 ? `sweep.maxCardsPerSweep: 0 is ambiguous — it could mean "no cap" or "claim nothing". Use a positive number of cards, or -1 to run with no card cap.` : `sweep.maxCardsPerSweep: must be a whole number of cards (positive) or -1 for no cap, got ${JSON.stringify(sweep.maxCardsPerSweep)}`);
2447
+ }
2448
+ if (sweep.enabled && sweep.maxCardsPerSweep < 0 && config.budget.dailyBudgetCents < 0) {
2449
+ issues.push("sweep.enabled: true with no ceiling at all — sweep.maxCardsPerSweep and budget.dailyBudgetCents are both opted out (-1). A self-claiming daemon needs at least one: set a card cap (e.g. 10) or a daily spend cap (e.g. 5000 for $50.00/day).");
2450
+ }
2451
+ if (sweep.enabled && !config.http.enabled) {
2452
+ issues.push("sweep.enabled: true but http.enabled is false — the sweep kill switch IS the local HTTP server (`POST /sweep/stop`, `harmony-agent sweep stop`), so this configuration ships a self-claiming daemon with no way to stop it, and no way to clear a latched card cap either. Set http.enabled: true.");
2453
+ }
2454
+ if (sweep.enabled && config.pickupColumns.length === 0) {
2455
+ issues.push("sweep.enabled: true but pickupColumns is empty — the sweep has no column to claim from");
2456
+ }
2457
+ if (sweep.enabled && config.boardReview.enabled) {
2458
+ const digest = config.boardReview.digestColumn;
2459
+ if (!digest) {
2460
+ issues.push("sweep.enabled and boardReview.enabled are both true but boardReview.digestColumn is empty — the digest would fall back to the first pickup column, where the sweep would claim it and run an implement session on card titles written by other members. Set digestColumn to a column the daemon does not pick up from.");
2461
+ } else if (config.pickupColumns.some((c) => c.toLowerCase() === digest.toLowerCase())) {
2462
+ issues.push(`boardReview.digestColumn: "${digest}" is also a sweep pickup column — the daemon would claim its own digest card and run it. Use a column the daemon does not pick up from.`);
2463
+ }
2464
+ }
2465
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2466
+ for (const author of sweep.trustedAuthors) {
2467
+ if (!UUID.test(author)) {
2468
+ issues.push(`sweep.trustedAuthors: "${author}" is not a user id — expected a workspace member's UUID (harmony_get_workspace_members lists them)`);
2469
+ }
2470
+ }
2471
+ if (issues.length > 0) {
2472
+ throw new ConfigValidationError(`Invalid agent config — sweep mode:
2473
+ - ${issues.join(`
2474
+ - `)}`, issues);
2475
+ }
2476
+ }
2477
+ function validateBudgetConfig(config) {
2478
+ const cents = config.budget.dailyBudgetCents;
2479
+ if (Number.isInteger(cents) && cents !== 0)
2480
+ return;
2481
+ const issue = cents === 0 ? `budget.dailyBudgetCents: 0 is ambiguous — it could mean "no cap" or "spend nothing"` : `budget.dailyBudgetCents: ${cents} is not a whole number of cents`;
2482
+ throw new ConfigValidationError(`Invalid agent config — ${issue}.
2483
+ ` + ` Set a positive cap in cents (e.g. 5000 for $50.00/day), or -1 to run with no daily cap.`, [issue]);
2484
+ }
2485
+ function validateRankingConfig(config) {
2486
+ const issues = [];
2487
+ const entries = Object.entries(config.ranking);
2488
+ for (const [key, value] of entries) {
2489
+ if (!Number.isFinite(value) || value < 0) {
2490
+ issues.push(`ranking.${key}: must be a finite number >= 0, got ${JSON.stringify(value)}`);
2491
+ }
2492
+ }
2493
+ if (issues.length > 0) {
2494
+ throw new ConfigValidationError(`Invalid agent config — ranking weights:
2495
+ - ${issues.join(`
2496
+ - `)}
2497
+ ` + ` Set a term's weight to 0 to switch it off; zeroing priorityWeight, successorWeight and agePerDayWeight reproduces the pre-#979 ordering.`, issues);
2498
+ }
2499
+ }
2352
2500
  function columnNames(board) {
2353
2501
  return board.columns.map((c) => c.name);
2354
2502
  }
@@ -2411,6 +2559,13 @@ async function validateColumnReferences(client, projectId, config) {
2411
2559
  where: "boardReview.digestColumn"
2412
2560
  });
2413
2561
  }
2562
+ if (config.sweep.enabled && config.sweep.requireLabel) {
2563
+ const target = config.sweep.requireLabel.toLowerCase();
2564
+ const boardLabels = board.labels ?? [];
2565
+ if (!boardLabels.some((l) => l.name.toLowerCase() === target)) {
2566
+ issues.push(`sweep.requireLabel: label "${config.sweep.requireLabel}" not found on board — the sweep would never claim a card. Known labels: ${boardLabels.map((l) => l.name).join(", ") || "(none)"}`);
2567
+ }
2568
+ }
2414
2569
  for (const { value, where } of required) {
2415
2570
  if (!value)
2416
2571
  continue;
@@ -2420,7 +2575,7 @@ async function validateColumnReferences(client, projectId, config) {
2420
2575
  }
2421
2576
  if (issues.length > 0) {
2422
2577
  const help = `Available columns: ${known.join(", ")}`;
2423
- throw new ConfigValidationError(`Invalid agent config — the following columns are missing:
2578
+ throw new ConfigValidationError(`Invalid agent config — the following board references are invalid:
2424
2579
  - ${issues.join(`
2425
2580
  - `)}
2426
2581
  ${help}`, issues);
@@ -2546,7 +2701,14 @@ class HttpServer {
2546
2701
  if (method === "GET" && path === "/status") {
2547
2702
  return this.respondStatus(res);
2548
2703
  }
2704
+ if (method === "GET" && path === "/sweep") {
2705
+ return this.respondSweep(res);
2706
+ }
2549
2707
  if (method === "POST") {
2708
+ const sweepCmd = parseSweepCommand(path);
2709
+ if (sweepCmd) {
2710
+ return this.respondSweepCommand(res, sweepCmd);
2711
+ }
2550
2712
  const cmd = parseCommand(path);
2551
2713
  if (cmd) {
2552
2714
  return this.respondCommand(res, cmd.command, cmd.cardId);
@@ -2567,6 +2729,45 @@ class HttpServer {
2567
2729
  res.writeHead(200, { "content-type": "application/json" });
2568
2730
  res.end(JSON.stringify(snapshot));
2569
2731
  }
2732
+ respondSweep(res) {
2733
+ const getSweep = this.opts.getSweep;
2734
+ if (!getSweep) {
2735
+ this.respondSweepUnavailable(res);
2736
+ return;
2737
+ }
2738
+ res.writeHead(200, { "content-type": "application/json" });
2739
+ res.end(JSON.stringify(getSweep()));
2740
+ }
2741
+ async respondSweepCommand(res, command) {
2742
+ const handle = this.opts.handleSweepCommand;
2743
+ if (!handle) {
2744
+ this.respondSweepUnavailable(res);
2745
+ return;
2746
+ }
2747
+ try {
2748
+ const sweep = await handle(command);
2749
+ res.writeHead(200, { "content-type": "application/json" });
2750
+ res.end(JSON.stringify({
2751
+ ok: true,
2752
+ command,
2753
+ sweep,
2754
+ inFlight: this.opts.getStatus().workers.filter(busy).length
2755
+ }));
2756
+ } catch (err) {
2757
+ res.writeHead(500, { "content-type": "application/json" });
2758
+ res.end(JSON.stringify({
2759
+ error: "sweep_command_failed",
2760
+ detail: err instanceof Error ? err.message : String(err)
2761
+ }));
2762
+ }
2763
+ }
2764
+ respondSweepUnavailable(res) {
2765
+ res.writeHead(404, { "content-type": "application/json" });
2766
+ res.end(JSON.stringify({
2767
+ error: "sweep_unavailable",
2768
+ detail: "this daemon has no sweep control wired"
2769
+ }));
2770
+ }
2570
2771
  async respondCommand(res, command, cardId) {
2571
2772
  try {
2572
2773
  await this.opts.handleCommand(command, cardId);
@@ -2590,6 +2791,13 @@ function parseCommand(path) {
2590
2791
  return null;
2591
2792
  return { command: match[1], cardId: decodeURIComponent(match[2]) };
2592
2793
  }
2794
+ function parseSweepCommand(path) {
2795
+ const match = path.match(/^\/sweep\/(stop|resume)$/);
2796
+ return match ? match[1] : null;
2797
+ }
2798
+ function busy(w) {
2799
+ return w.cardId !== null;
2800
+ }
2593
2801
  var TAG3 = "http";
2594
2802
  var init_http_server = () => {};
2595
2803
 
@@ -3060,48 +3268,6 @@ var matchesColumn = (columns, columnName) => {
3060
3268
  return columns.some((name) => name.toLowerCase() === lc);
3061
3269
  };
3062
3270
 
3063
- // src/budget.ts
3064
- class BudgetGuard {
3065
- config;
3066
- store;
3067
- constructor(config, store) {
3068
- this.config = config;
3069
- this.store = store;
3070
- }
3071
- check(cardId) {
3072
- const card = this.store.getCard(cardId);
3073
- if (card && card.attempts >= this.config.maxAttemptsPerCard) {
3074
- return {
3075
- allow: false,
3076
- reason: "max_attempts",
3077
- detail: `${card.attempts} of ${this.config.maxAttemptsPerCard} attempts exhausted`
3078
- };
3079
- }
3080
- return { allow: true };
3081
- }
3082
- }
3083
- function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
3084
- const wayBackIn = pauseEnabled ? "Continue to grant a fresh attempt, or reassign the card." : "Reassign the card to grant a fresh attempt.";
3085
- const lines = [
3086
- "**Out of attempts — over to you.**",
3087
- `Stopped after ${maxAttempts} failed attempt${maxAttempts === 1 ? "" : "s"}. ${wayBackIn}`
3088
- ];
3089
- if (failures.length > 0) {
3090
- lines.push("", "Recent failures:");
3091
- for (const f of failures) {
3092
- const when = new Date(f.ts).toISOString().replace("T", " ").slice(0, 16);
3093
- const tag = f.reason ? ` [${f.reason}]` : "";
3094
- const branch = f.recoveryBranch ? `
3095
- recover: \`git fetch && git checkout ${f.recoveryBranch}\`` : "";
3096
- lines.push(`- ${when} UTC${tag} — ${f.summary}${branch}`);
3097
- }
3098
- } else {
3099
- lines.push("", "_No prior failure summaries recorded._");
3100
- }
3101
- return lines.join(`
3102
- `);
3103
- }
3104
-
3105
3271
  // src/budget-pause.ts
3106
3272
  function classifyRunExit(x) {
3107
3273
  if (x.exitCode === 0)
@@ -3257,6 +3423,45 @@ var init_handback = () => {};
3257
3423
 
3258
3424
  // src/queue.ts
3259
3425
  import { log as log8 } from "@gethmy/harness";
3426
+ function scoreCardBreakdown(config, card, column, labels, signals = {}) {
3427
+ const w = config.ranking;
3428
+ const priority = w.priorityWeight * (PRIORITY_STEP[card.priority ?? "medium"] ?? PRIORITY_STEP.medium);
3429
+ let label = 0;
3430
+ for (const l of labels) {
3431
+ const boost = config.priorityLabels[l.name.toLowerCase()] ?? 0;
3432
+ if (boost > label)
3433
+ label = boost;
3434
+ }
3435
+ const columnTerm = config.columnBoost ? Math.max(0, 100 - column.position * 10) : 0;
3436
+ const successorCount = Math.min(Math.max(0, signals.successors ?? 0), Math.max(0, w.successorCap));
3437
+ const successors = w.successorWeight * successorCount;
3438
+ const ageDays = daysInColumn(card, signals.now ?? Date.now());
3439
+ const age = w.agePerDayWeight * Math.min(ageDays, Math.max(0, w.ageCapDays));
3440
+ return {
3441
+ total: priority + label + columnTerm + successors + age,
3442
+ priority,
3443
+ label,
3444
+ column: columnTerm,
3445
+ successors,
3446
+ age,
3447
+ ageDays
3448
+ };
3449
+ }
3450
+ function scoreCard(config, card, column, labels, signals = {}) {
3451
+ return scoreCardBreakdown(config, card, column, labels, signals).total;
3452
+ }
3453
+ function daysInColumn(card, now) {
3454
+ const enteredAt = card.column_entered_at;
3455
+ if (!enteredAt)
3456
+ return 0;
3457
+ const entered = Date.parse(enteredAt);
3458
+ if (Number.isNaN(entered))
3459
+ return 0;
3460
+ return Math.max(0, Math.floor((now - entered) / MS_PER_DAY));
3461
+ }
3462
+ function formatBreakdown(b) {
3463
+ return `score=${b.total} ` + `[priority=${b.priority} label=${b.label} column=${b.column} ` + `successors=${b.successors} age=${b.age} (${b.ageDays}d)]`;
3464
+ }
3260
3465
 
3261
3466
  class PriorityQueue {
3262
3467
  config;
@@ -3264,25 +3469,17 @@ class PriorityQueue {
3264
3469
  constructor(config) {
3265
3470
  this.config = config;
3266
3471
  }
3267
- scoreCard(_card, column, labels) {
3268
- let score = 0;
3269
- for (const label of labels) {
3270
- const boost = this.config.priorityLabels[label.name.toLowerCase()] ?? 0;
3271
- if (boost > score)
3272
- score = boost;
3273
- }
3274
- if (this.config.columnBoost) {
3275
- score += Math.max(0, 100 - column.position * 10);
3276
- }
3277
- return score;
3472
+ scoreCard(card, column, labels, signals = {}) {
3473
+ return scoreCard(this.config, card, column, labels, signals);
3278
3474
  }
3279
- enqueue(card, column, labels, mode = "implement") {
3475
+ enqueue(card, column, labels, mode = "implement", signals = {}) {
3280
3476
  const existing = this.items.findIndex((i) => i.cardId === card.id);
3281
3477
  if (existing !== -1) {
3282
3478
  log8.debug(TAG8, `Card #${card.short_id} already queued, updating priority`);
3283
3479
  this.items.splice(existing, 1);
3284
3480
  }
3285
- const priority = this.scoreCard(card, column, labels);
3481
+ const breakdown = scoreCardBreakdown(this.config, card, column, labels, signals);
3482
+ const priority = breakdown.total;
3286
3483
  const item = {
3287
3484
  cardId: card.id,
3288
3485
  shortId: card.short_id,
@@ -3299,7 +3496,7 @@ class PriorityQueue {
3299
3496
  }
3300
3497
  }
3301
3498
  this.items.splice(insertIdx, 0, item);
3302
- log8.info(TAG8, `Enqueued #${card.short_id} "${card.title}" (priority=${priority}, pos=${insertIdx}, queue=${this.items.length})`);
3499
+ log8.info(TAG8, `Enqueued #${card.short_id} "${card.title}" (${formatBreakdown(breakdown)}, pos=${insertIdx}, queue=${this.items.length})`);
3303
3500
  }
3304
3501
  dequeue() {
3305
3502
  return this.items.shift() ?? null;
@@ -3328,8 +3525,15 @@ class PriorityQueue {
3328
3525
  return this.items.slice();
3329
3526
  }
3330
3527
  }
3331
- var TAG8 = "queue";
3332
- var init_queue = () => {};
3528
+ var TAG8 = "queue", MS_PER_DAY = 86400000, PRIORITY_STEP;
3529
+ var init_queue = __esm(() => {
3530
+ PRIORITY_STEP = {
3531
+ low: 0,
3532
+ medium: 1,
3533
+ high: 2,
3534
+ urgent: 3
3535
+ };
3536
+ });
3333
3537
 
3334
3538
  // src/episode-writer.ts
3335
3539
  import { log as log9 } from "@gethmy/harness";
@@ -5342,6 +5546,9 @@ import {
5342
5546
  import { homedir as homedir3 } from "node:os";
5343
5547
  import { dirname, join as join3 } from "node:path";
5344
5548
  import { log as log16 } from "@gethmy/harness";
5549
+ function emptySweep() {
5550
+ return { claimed: 0, totalClaimed: 0, haltReason: null, haltedAt: null };
5551
+ }
5345
5552
  function emptyState() {
5346
5553
  return {
5347
5554
  version: SCHEMA_VERSION,
@@ -5350,7 +5557,8 @@ function emptyState() {
5350
5557
  daemonStartedAt: null,
5351
5558
  runs: [],
5352
5559
  cards: [],
5353
- daily: []
5560
+ daily: [],
5561
+ sweep: emptySweep()
5354
5562
  };
5355
5563
  }
5356
5564
  function todayUtc() {
@@ -5405,7 +5613,8 @@ class StateStore {
5405
5613
  daemonStartedAt: null,
5406
5614
  runs: [],
5407
5615
  cards: parsed.cards ?? [],
5408
- daily: parsed.daily ?? []
5616
+ daily: parsed.daily ?? [],
5617
+ sweep: parsed.sweep ?? emptySweep()
5409
5618
  };
5410
5619
  }
5411
5620
  return {
@@ -5415,7 +5624,8 @@ class StateStore {
5415
5624
  daemonStartedAt: parsed.daemonStartedAt ?? null,
5416
5625
  runs: parsed.runs ?? [],
5417
5626
  cards: parsed.cards ?? [],
5418
- daily: parsed.daily ?? []
5627
+ daily: parsed.daily ?? [],
5628
+ sweep: parsed.sweep ?? emptySweep()
5419
5629
  };
5420
5630
  } catch (err) {
5421
5631
  log16.error(TAG14, `failed to read state file: ${err instanceof Error ? err.message : err}`);
@@ -5668,6 +5878,27 @@ class StateStore {
5668
5878
  this.state.daily = this.state.daily.filter((d) => d.date >= cutoff);
5669
5879
  await this.persist();
5670
5880
  }
5881
+ getSweep() {
5882
+ return { ...this.state.sweep };
5883
+ }
5884
+ async recordSweepClaim() {
5885
+ this.state.sweep.claimed += 1;
5886
+ this.state.sweep.totalClaimed += 1;
5887
+ await this.persist();
5888
+ }
5889
+ async haltSweep(reason) {
5890
+ if (this.state.sweep.haltReason === reason)
5891
+ return;
5892
+ this.state.sweep.haltReason = reason;
5893
+ this.state.sweep.haltedAt = Date.now();
5894
+ await this.persist();
5895
+ }
5896
+ async resumeSweep() {
5897
+ this.state.sweep.claimed = 0;
5898
+ this.state.sweep.haltReason = null;
5899
+ this.state.sweep.haltedAt = null;
5900
+ await this.persist();
5901
+ }
5671
5902
  getDailyCostCents(date) {
5672
5903
  const key = date ?? todayUtc();
5673
5904
  return this.state.daily.find((d) => d.date === key)?.costCents ?? 0;
@@ -6074,6 +6305,14 @@ class ReviewWorker {
6074
6305
  numTurns: cost.numTurns
6075
6306
  };
6076
6307
  }
6308
+ async chargeDailyLedger(cardId) {
6309
+ const { costCents } = this.endLedger();
6310
+ if (costCents <= 0)
6311
+ return;
6312
+ try {
6313
+ await this.stateStore.addCost(cardId, costCents);
6314
+ } catch {}
6315
+ }
6077
6316
  async run(card, column, labels, subtasks) {
6078
6317
  this.aborted = false;
6079
6318
  this.timedOut = false;
@@ -6370,6 +6609,10 @@ ${userPrompt}`;
6370
6609
  const status = this.timedOut ? "failed" : this.state === "error" || this.aborted || this.sessionConflict ? "paused" : "completed";
6371
6610
  await this.stateStore.endRun(this.runId, status, this.sessionConflict ? { errorMessage: "session_conflict", ...this.endLedger() } : this.endLedger());
6372
6611
  }
6612
+ const settled = this.stateStore.getRun(this.runId);
6613
+ if (this.cardId && settled && settled.endedAt !== null) {
6614
+ await this.chargeDailyLedger(this.cardId);
6615
+ }
6373
6616
  } catch {}
6374
6617
  }
6375
6618
  if (this.cardId && this.timedOut && this.config.budget.pause.enabled && this.state !== "parked" && this.state !== "error") {
@@ -6817,20 +7060,21 @@ function isBlockerResolved(blocker, columns) {
6817
7060
  const blockerColumn = columns.find((c) => c.id === blocker.column_id);
6818
7061
  return blockerColumn?.mark_cards_done === true;
6819
7062
  }
6820
- async function getUnresolvedBlockers(client, card, projectId) {
7063
+ async function getChainSignals(client, card, projectId, knownColumns) {
6821
7064
  const links = await fetchBlocksLinks(client, card.id);
6822
7065
  if (!links)
6823
- return null;
7066
+ return { blockers: null, successors: 0 };
7067
+ const successors = links.filter((l) => l.direction === "outgoing" && !l.target_card.done).length;
6824
7068
  const incoming = links.filter((l) => l.direction === "incoming");
6825
7069
  if (incoming.length === 0)
6826
- return [];
6827
- const board = await client.getBoard(projectId, { summary: true });
6828
- const columns = board.columns ?? [];
6829
- return incoming.filter((l) => !isBlockerResolved(l.target_card, columns)).map((l) => ({
7070
+ return { blockers: [], successors };
7071
+ const columns = knownColumns ?? ((await client.getBoard(projectId, { summary: true })).columns ?? []);
7072
+ const blockers = incoming.filter((l) => !isBlockerResolved(l.target_card, columns)).map((l) => ({
6830
7073
  cardId: l.target_card.id,
6831
7074
  shortId: l.target_card.short_id,
6832
7075
  title: l.target_card.title
6833
7076
  }));
7077
+ return { blockers, successors };
6834
7078
  }
6835
7079
  async function promoteUnblockedSuccessors(completedCard, deps) {
6836
7080
  const links = await fetchBlocksLinks(deps.client, completedCard.id);
@@ -7659,13 +7903,21 @@ async function advanceConvergeLoop(card, stage, stageIndex, def, evaluation, loo
7659
7903
  log24.info(TAG22, `#${card.short_id} LoopExhausted: ${reason}`);
7660
7904
  return { kind: "held_gate_unmet", reason };
7661
7905
  }
7906
+ const guard = await guardStageReclaim(card, `converge-loop iteration of "${stage.name}"`, deps);
7907
+ if (!guard.proceed) {
7908
+ return {
7909
+ kind: "reclaim_refused",
7910
+ reason: guard.reason,
7911
+ released: guard.released
7912
+ };
7913
+ }
7662
7914
  await deps.stateStore.decrementAttempt(card.id).catch(() => {});
7663
7915
  await writeIterationHandoff(card, stage, iteration, maxIterations, evaluation, deps);
7664
7916
  const toColumn = await resolveStageColumnName(deps.client, card, stage) ?? deps.fallbackColumn;
7665
7917
  try {
7666
7918
  await deps.client.addComment(card.id, `Converge loop — ${summary}. Re-running "${stage.name}".`, { commentType: "progress" });
7667
7919
  } catch {}
7668
- await runTransition(deps.client, card, {
7920
+ await runTransition(deps.client, guard.card, {
7669
7921
  move: { columnName: toColumn },
7670
7922
  addLabels: [{ name: AGENT_LABEL }],
7671
7923
  ...isAgentRunnableOwner(stage.owner) ? { assignAgent: deps.agentId } : {}
@@ -7781,11 +8033,19 @@ async function handleGateUnmet(card, stage, summary, deps) {
7781
8033
  log24.info(TAG22, `#${card.short_id} GateUnmetExhausted: ${reason}`);
7782
8034
  return { kind: "held_gate_unmet", reason };
7783
8035
  }
8036
+ const guard = await guardStageReclaim(card, `gate-unmet re-run of "${stage.name}"`, deps);
8037
+ if (!guard.proceed) {
8038
+ return {
8039
+ kind: "reclaim_refused",
8040
+ reason: guard.reason,
8041
+ released: guard.released
8042
+ };
8043
+ }
7784
8044
  const toColumn = await resolveStageColumnName(deps.client, card, stage) ?? deps.fallbackColumn;
7785
8045
  try {
7786
8046
  await deps.client.addComment(card.id, `Stage gate unmet — re-running "${stage.name}". ${summary}.`, { commentType: "progress" });
7787
8047
  } catch {}
7788
- await runTransition(deps.client, card, {
8048
+ await runTransition(deps.client, guard.card, {
7789
8049
  move: { columnName: toColumn },
7790
8050
  addLabels: [{ name: AGENT_LABEL }],
7791
8051
  ...isAgentRunnableOwner(stage.owner) ? { assignAgent: deps.agentId } : {}
@@ -7793,6 +8053,38 @@ async function handleGateUnmet(card, stage, summary, deps) {
7793
8053
  log24.info(TAG22, `#${card.short_id} gate unmet for "${stage.name}" — requeued to "${toColumn}" for re-run (attempt ${attempts}/${deps.maxAttempts})`);
7794
8054
  return { kind: "requeued_gate_unmet", toColumn };
7795
8055
  }
8056
+ async function guardStageReclaim(card, what, deps) {
8057
+ const { verdict, card: fresh } = await guardedHandback(deps.client, card.id, {
8058
+ agentId: deps.closeoutReleasesAssignment ? null : deps.agentId,
8059
+ workingColumnId: null
8060
+ });
8061
+ const foreign = fresh?.assigned_agent_id && fresh.assigned_agent_id !== deps.agentId ? fresh.assigned_agent_id : null;
8062
+ if (verdict.proceed && !foreign) {
8063
+ return { proceed: true, card: fresh ?? card };
8064
+ }
8065
+ const [reason, detail] = verdict.proceed ? [
8066
+ "reassigned",
8067
+ `the card is assigned to agent ${foreign}, not ${deps.agentId}`
8068
+ ] : [verdict.reason, verdict.detail];
8069
+ const stillOurs = !!fresh && !fresh.archived_at && !foreign && fresh.assigned_agent_id === deps.agentId;
8070
+ let released = false;
8071
+ if (fresh && !fresh.archived_at && !foreign) {
8072
+ try {
8073
+ await runTransition(deps.client, fresh, {
8074
+ removeLabels: [AGENT_LABEL],
8075
+ ...stillOurs ? { assignAgent: null } : {}
8076
+ }, { store: deps.stateStore, runId: deps.runId });
8077
+ released = stillOurs;
8078
+ } catch (err) {
8079
+ log24.warn(TAG22, `#${card.short_id} could not hand the card over after refusing the ${what}: ${err instanceof Error ? err.message : err}${stillOurs ? " — the claim is still ours, so the next reconcile tick may re-pick the card" : ""}`);
8080
+ }
8081
+ }
8082
+ log24.info(TAG22, `#${card.short_id} ${what} refused — ${detail} (${reason})${released ? "; released the daemon's claim so the stage router stops here" : ""}`);
8083
+ try {
8084
+ await deps.client.addComment(card.id, `Stopped the ${what} — ${detail}. The card is not the daemon's to reclaim, so this stage will not re-run until it is assigned back.`, { commentType: "decision" });
8085
+ } catch {}
8086
+ return { proceed: false, reason, released };
8087
+ }
7796
8088
  async function holdForHuman(client, card, reason, runId, stateStore, opts = {}) {
7797
8089
  if (!opts.keepAttempts) {
7798
8090
  await stateStore.decrementAttempt(card.id).catch(() => {});
@@ -7820,6 +8112,7 @@ async function holdForHuman(client, card, reason, runId, stateStore, opts = {})
7820
8112
  var TAG22 = "stage-advance", AGENT_LABEL = "agent";
7821
8113
  var init_stage_advance = __esm(() => {
7822
8114
  init_dist();
8115
+ init_handback();
7823
8116
  init_transitions();
7824
8117
  });
7825
8118
 
@@ -8279,6 +8572,10 @@ class Worker {
8279
8572
  case "held_misconfigured":
8280
8573
  this.held = true;
8281
8574
  break;
8575
+ case "reclaim_refused":
8576
+ this.held = true;
8577
+ log25.info(this.tag, `#${card.short_id} stage reclaim refused (${outcome.reason})${outcome.released ? " — released the daemon's claim" : ""}`);
8578
+ break;
8282
8579
  case "advanced":
8283
8580
  case "completed_terminal":
8284
8581
  case "no_advance":
@@ -8942,6 +9239,10 @@ class Worker {
8942
9239
  case "held_misconfigured":
8943
9240
  this.held = true;
8944
9241
  break;
9242
+ case "reclaim_refused":
9243
+ this.held = true;
9244
+ log25.info(this.tag, `#${card.short_id} stage reclaim refused (${outcome.reason})${outcome.released ? " — released the daemon's claim" : ""}`);
9245
+ break;
8945
9246
  case "advanced":
8946
9247
  case "completed_terminal":
8947
9248
  case "no_advance":
@@ -9112,6 +9413,7 @@ ${prompt}`;
9112
9413
  client: this.client,
9113
9414
  stateStore: this.stateStore,
9114
9415
  agentId: this.identity.agentId,
9416
+ closeoutReleasesAssignment: !!this.config.completion.moveToColumn,
9115
9417
  maxAttempts: this.config.budget.maxAttemptsPerCard,
9116
9418
  fallbackColumn: this.config.pickupColumns[0] ?? "To Do",
9117
9419
  sink: this.cliRunner,
@@ -9909,6 +10211,7 @@ class Pool {
9909
10211
  return;
9910
10212
  }
9911
10213
  this.reservations.add(card.id);
10214
+ let chainSuccessors = 0;
9912
10215
  try {
9913
10216
  if (mode === "implement") {
9914
10217
  if (this.authPaused) {
@@ -9925,14 +10228,14 @@ class Pool {
9925
10228
  const decision = this.budget.check(card.id);
9926
10229
  if (!decision.allow) {
9927
10230
  if (decision.reason === "daily_budget") {
9928
- log26.warn(TAG24, `#${card.short_id} skipped (daily_budget): ${decision.detail}`);
9929
- await this.emitWaiting(card.id, `Daily budget reached — waiting for reset (${decision.detail})`);
10231
+ await this.denyDailyBudget(card, decision.detail);
9930
10232
  } else {
9931
10233
  log26.debug(TAG24, `#${card.short_id} gave up: ${decision.detail}`);
9932
10234
  }
9933
10235
  return;
9934
10236
  }
9935
- const blockers = await getUnresolvedBlockers(this.client, card, this.projectId);
10237
+ const { blockers, successors } = await getChainSignals(this.client, card, this.projectId);
10238
+ chainSuccessors = successors;
9936
10239
  if (blockers === null) {
9937
10240
  log26.warn(TAG24, `#${card.short_id} blocker check failed — deferring to next tick`);
9938
10241
  return;
@@ -9943,9 +10246,18 @@ class Pool {
9943
10246
  await this.emitWaiting(card.id, `Blocked by ${list} — waiting for chain`);
9944
10247
  return;
9945
10248
  }
10249
+ } else {
10250
+ const decision = this.budget.checkDailyBudget();
10251
+ if (!decision.allow) {
10252
+ await this.denyDailyBudget(card, decision.detail);
10253
+ return;
10254
+ }
9946
10255
  }
9947
10256
  const queue = mode === "review" ? this.reviewQueue : this.implQueue;
9948
- queue.enqueue(card, column, labels, mode);
10257
+ queue.enqueue(card, column, labels, mode, {
10258
+ successors: chainSuccessors,
10259
+ now: Date.now()
10260
+ });
9949
10261
  this.cardDataCache.set(card.id, { card, column, labels, subtasks, mode });
9950
10262
  const workers = mode === "review" ? this.reviewWorkers : this.implWorkers;
9951
10263
  const dispatched = this.tryDispatchFor(workers, queue, mode);
@@ -9958,6 +10270,15 @@ class Pool {
9958
10270
  this.reservations.delete(card.id);
9959
10271
  }
9960
10272
  }
10273
+ async denyDailyBudget(card, detail) {
10274
+ const reason = `Daily budget reached — waiting for reset (${detail})`;
10275
+ const line = `#${card.short_id} skipped (daily_budget): ${detail}`;
10276
+ if (this.lastWaitingEmit.get(card.id) === reason)
10277
+ log26.debug(TAG24, line);
10278
+ else
10279
+ log26.warn(TAG24, line);
10280
+ await this.emitWaiting(card.id, reason);
10281
+ }
9961
10282
  lastWaitingEmit = new Map;
9962
10283
  async emitWaiting(cardId, currentTask) {
9963
10284
  if (this.lastWaitingEmit.get(cardId) === currentTask)
@@ -10020,6 +10341,24 @@ class Pool {
10020
10341
  isCardActive(cardId) {
10021
10342
  return hasParkedRun(this.stateStore, cardId) || this.implWorkers.some((w) => w.cardId === cardId && w.isActive) || this.reviewWorkers.some((w) => w.cardId === cardId && w.isActive);
10022
10343
  }
10344
+ hasFreeImplementSlot() {
10345
+ if (this.shuttingDown || this.authPaused)
10346
+ return false;
10347
+ if (this.apiCooldownRemainingMs() > 0)
10348
+ return false;
10349
+ if (this.implQueue.length > 0 || this.reservations.size > 0)
10350
+ return false;
10351
+ return this.implWorkers.some((w) => w.isIdle);
10352
+ }
10353
+ checkBudget(cardId) {
10354
+ return this.budget.check(cardId);
10355
+ }
10356
+ scoreCard(card, column, labels, signals = {}) {
10357
+ return this.implQueue.scoreCard(card, column, labels, signals);
10358
+ }
10359
+ scoreCardBreakdown(card, column, labels, signals = {}) {
10360
+ return scoreCardBreakdown(this.config, card, column, labels, signals);
10361
+ }
10023
10362
  isCardKnown(cardId) {
10024
10363
  return this.implQueue.has(cardId) || this.reviewQueue.has(cardId) || this.isCardActive(cardId);
10025
10364
  }
@@ -10630,9 +10969,9 @@ var init_recovery = __esm(() => {
10630
10969
 
10631
10970
  // src/claim.ts
10632
10971
  import { log as log29 } from "@gethmy/harness";
10633
- async function claimReviewCard(client, cardId, agentId) {
10972
+ async function claimUnassignedCard(client, cardId, agentId, opts) {
10634
10973
  try {
10635
- const { claimed } = await client.claimCard(cardId, agentId);
10974
+ const { claimed } = opts ? await client.claimCard(cardId, agentId, opts) : await client.claimCard(cardId, agentId);
10636
10975
  log29.debug(TAG27, `claim ${cardId} → ${claimed ? "won" : "lost"}`);
10637
10976
  return claimed;
10638
10977
  } catch (err) {
@@ -10673,7 +11012,7 @@ async function reclaimPreReviewStrands(opts) {
10673
11012
  for (const card of cards) {
10674
11013
  if (checked >= maxPerSweep)
10675
11014
  break;
10676
- if (card.archived_at || !reviewColIds.has(card.column_id) || card.assigned_agent_id != null || card.assignee_id != null || knownCardIds.has(card.id)) {
11015
+ if (card.archived_at || card.done || !reviewColIds.has(card.column_id) || card.assigned_agent_id != null || card.assignee_id != null || knownCardIds.has(card.id)) {
10677
11016
  continue;
10678
11017
  }
10679
11018
  const branch = extractBranchFromDescription(card.description);
@@ -10690,7 +11029,7 @@ async function reclaimPreReviewStrands(opts) {
10690
11029
  const prUrl = resolvePrUrl2(card.description ?? null, branch, cwd, provider);
10691
11030
  if (prUrl)
10692
11031
  continue;
10693
- const won = await claimReviewCard(client, card.id, agentId);
11032
+ const won = await claimUnassignedCard(client, card.id, agentId);
10694
11033
  if (!won) {
10695
11034
  log30.debug(TAG28, `#${card.short_id} — lost the review claim race, skipping`);
10696
11035
  continue;
@@ -10715,8 +11054,246 @@ var init_strand_recovery = __esm(() => {
10715
11054
  init_types2();
10716
11055
  });
10717
11056
 
11057
+ // src/sweep.ts
11058
+ import { log as log31 } from "@gethmy/harness";
11059
+ function rankSweepCandidates(opts) {
11060
+ const {
11061
+ agentId,
11062
+ cards,
11063
+ columns,
11064
+ labelMap,
11065
+ pickupConfig,
11066
+ approvedLabel,
11067
+ requireLabel,
11068
+ trustedAuthors,
11069
+ knownCardIds,
11070
+ budgetCheck,
11071
+ score
11072
+ } = opts;
11073
+ const now = opts.now ?? Date.now();
11074
+ const columnMap = new Map(columns.map((c) => [c.id, c]));
11075
+ const candidates = [];
11076
+ const skipped = [];
11077
+ const skip = (card, reason, detail) => skipped.push({ cardId: card.id, shortId: card.short_id, reason, detail });
11078
+ for (const card of cards) {
11079
+ if (card.archived_at)
11080
+ continue;
11081
+ const column = columnMap.get(card.column_id);
11082
+ if (!column)
11083
+ continue;
11084
+ if (!matchesColumn(pickupConfig.pickupColumns, column.name))
11085
+ continue;
11086
+ const route = classifyPickup(card, column.name, pickupConfig);
11087
+ if (!route || route.mode !== "implement")
11088
+ continue;
11089
+ if (card.done) {
11090
+ skip(card, "done", "card is marked done");
11091
+ continue;
11092
+ }
11093
+ if (card.assignee_id != null) {
11094
+ skip(card, "human_assignee", "a human is assigned");
11095
+ continue;
11096
+ }
11097
+ if (card.created_by == null || !trustedAuthors.has(card.created_by)) {
11098
+ skip(card, "untrusted_author", card.created_by == null ? "card has no recorded author" : `author ${card.created_by} is not in sweep.trustedAuthors`);
11099
+ continue;
11100
+ }
11101
+ if (card.assigned_agent_id != null) {
11102
+ skip(card, "already_owned", card.assigned_agent_id === agentId ? "already assigned to this agent" : "assigned to another agent");
11103
+ continue;
11104
+ }
11105
+ if (knownCardIds.has(card.id)) {
11106
+ skip(card, "known_to_pool", "already queued or active in this pool");
11107
+ continue;
11108
+ }
11109
+ const labels = resolveCardLabels(card, labelMap);
11110
+ if (requireLabel && !hasLabel(labels, requireLabel)) {
11111
+ skip(card, "require_label", `lacks required label "${requireLabel}"`);
11112
+ continue;
11113
+ }
11114
+ if (hasLabel(labels, NEED_REVIEW_LABEL)) {
11115
+ skip(card, "need_review_label", `has "${NEED_REVIEW_LABEL}"`);
11116
+ continue;
11117
+ }
11118
+ if (approvedLabel && hasLabel(labels, approvedLabel)) {
11119
+ skip(card, "approved_label", `has "${approvedLabel}"`);
11120
+ continue;
11121
+ }
11122
+ const decision = budgetCheck(card.id);
11123
+ if (!decision.allow) {
11124
+ skip(card, "budget", `${decision.reason}: ${decision.detail}`);
11125
+ continue;
11126
+ }
11127
+ const breakdown = score(card, column, labels, {
11128
+ successors: opts.successorCounts?.get(card.id) ?? 0,
11129
+ now
11130
+ });
11131
+ candidates.push({
11132
+ card,
11133
+ column,
11134
+ labels,
11135
+ score: breakdown.total,
11136
+ breakdown
11137
+ });
11138
+ }
11139
+ candidates.sort((a, b) => {
11140
+ if (b.score !== a.score)
11141
+ return b.score - a.score;
11142
+ const pinned = Number(b.card.pinned === true) - Number(a.card.pinned === true);
11143
+ if (pinned !== 0)
11144
+ return pinned;
11145
+ if (a.card.position !== b.card.position) {
11146
+ return a.card.position - b.card.position;
11147
+ }
11148
+ return a.card.short_id - b.card.short_id;
11149
+ });
11150
+ return { candidates, skipped };
11151
+ }
11152
+ async function sweepForCard(opts) {
11153
+ const { client, projectId, agentId, maxProbes } = opts;
11154
+ const chain2 = new Map;
11155
+ let { candidates, skipped } = rankSweepCandidates(opts);
11156
+ if (candidates.length === 0) {
11157
+ log31.debug(TAG29, `no claimable card (${skipped.length} skipped in a pickup column)`);
11158
+ return { claimed: null, skipped, probed: 0 };
11159
+ }
11160
+ const probeWindow = candidates.slice(0, maxProbes);
11161
+ const successorTermCounts = opts.ranking.successorWeight !== 0 && opts.ranking.successorCap !== 0;
11162
+ if (successorTermCounts && probeWindow.length > 1) {
11163
+ for (const c of probeWindow) {
11164
+ chain2.set(c.card.id, await getChainSignals(client, c.card, projectId, opts.columns));
11165
+ }
11166
+ ({ candidates, skipped } = rankSweepCandidates({
11167
+ ...opts,
11168
+ successorCounts: successorCounts(chain2)
11169
+ }));
11170
+ } else if (successorTermCounts && candidates.length > 1) {
11171
+ log31.debug(TAG29, `blocker depth not ranked — maxProbesPerTick=${maxProbes} leaves nothing to reorder among ${candidates.length} candidates`);
11172
+ }
11173
+ let probed = 0;
11174
+ for (const candidate of candidates) {
11175
+ if (probed >= maxProbes) {
11176
+ log31.debug(TAG29, `probe budget ${maxProbes} spent — ${candidates.length - probed} candidate(s) deferred to the next tick`);
11177
+ break;
11178
+ }
11179
+ probed++;
11180
+ const { card } = candidate;
11181
+ if (await hasLiveSession(client, card.id)) {
11182
+ skipped.push({
11183
+ cardId: card.id,
11184
+ shortId: card.short_id,
11185
+ reason: "live_session",
11186
+ detail: "a live agent session already holds this card"
11187
+ });
11188
+ continue;
11189
+ }
11190
+ let signals = chain2.get(card.id);
11191
+ if (!signals) {
11192
+ signals = await getChainSignals(client, card, projectId, opts.columns);
11193
+ chain2.set(card.id, signals);
11194
+ }
11195
+ const { blockers } = signals;
11196
+ if (blockers === null) {
11197
+ skipped.push({
11198
+ cardId: card.id,
11199
+ shortId: card.short_id,
11200
+ reason: "blockers_unknown",
11201
+ detail: "blocker lookup failed — deferring to the next tick"
11202
+ });
11203
+ continue;
11204
+ }
11205
+ if (blockers.length > 0) {
11206
+ skipped.push({
11207
+ cardId: card.id,
11208
+ shortId: card.short_id,
11209
+ reason: "blocked",
11210
+ detail: `blocked by ${blockers.map((b) => `#${b.shortId}`).join(", ")}`
11211
+ });
11212
+ continue;
11213
+ }
11214
+ const fresh = await refetchCard(client, card.id);
11215
+ if (!fresh) {
11216
+ skipped.push({
11217
+ cardId: card.id,
11218
+ shortId: card.short_id,
11219
+ reason: "recheck_failed",
11220
+ detail: "card re-read failed — deferring to the next tick"
11221
+ });
11222
+ continue;
11223
+ }
11224
+ const recheck = rankSweepCandidates({
11225
+ ...opts,
11226
+ cards: [fresh],
11227
+ successorCounts: successorCounts(chain2)
11228
+ });
11229
+ const verified = recheck.candidates[0];
11230
+ if (!verified) {
11231
+ const reason = recheck.skipped[0];
11232
+ skipped.push({
11233
+ cardId: card.id,
11234
+ shortId: card.short_id,
11235
+ reason: reason?.reason ?? "recheck_out_of_scope",
11236
+ detail: reason ? `changed since the board read — ${reason.detail}` : "no longer in a pickup column"
11237
+ });
11238
+ continue;
11239
+ }
11240
+ const won = await claimUnassignedCard(client, card.id, agentId, {
11241
+ requireUnassigned: true
11242
+ });
11243
+ if (!won) {
11244
+ skipped.push({
11245
+ cardId: card.id,
11246
+ shortId: card.short_id,
11247
+ reason: "claim_lost",
11248
+ detail: "another daemon or a person claimed it first"
11249
+ });
11250
+ continue;
11251
+ }
11252
+ verified.card.assigned_agent_id = agentId;
11253
+ log31.info(TAG29, claimLine(verified, probed, candidates, skipped));
11254
+ return { claimed: verified, skipped, probed };
11255
+ }
11256
+ log31.debug(TAG29, `claimed nothing this sweep (${probed} probed, ${skipped.length} skipped)`);
11257
+ return { claimed: null, skipped, probed };
11258
+ }
11259
+ function claimLine(claimed, rank, candidates, skipped) {
11260
+ const below = candidates[rank];
11261
+ const versus = candidates.length === 1 ? "the only candidate" : below ? `ahead of #${below.card.short_id} (score=${below.score})` : "last in the ranking";
11262
+ const passedOver = rank > 1 ? `, ${rank - 1} higher-ranked passed over` : "";
11263
+ return `claimed #${claimed.card.short_id} "${claimed.card.title}" ` + `in "${claimed.column.name}" — ${formatBreakdown(claimed.breakdown)}, ` + `rank ${rank} of ${candidates.length}${passedOver}, ${versus}; ` + `${skipped.length} skipped`;
11264
+ }
11265
+ function successorCounts(chain2) {
11266
+ return new Map([...chain2].map(([id, signals]) => [id, signals.successors]));
11267
+ }
11268
+ async function refetchCard(client, cardId) {
11269
+ try {
11270
+ const { card } = await client.getCard(cardId);
11271
+ return card ?? null;
11272
+ } catch (err) {
11273
+ log31.warn(TAG29, `re-read failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
11274
+ return null;
11275
+ }
11276
+ }
11277
+ async function hasLiveSession(client, cardId) {
11278
+ try {
11279
+ const { session } = await client.getAgentSession(cardId);
11280
+ return session != null;
11281
+ } catch (err) {
11282
+ log31.warn(TAG29, `session lookup failed for ${cardId} — treating as busy: ${err instanceof Error ? err.message : err}`);
11283
+ return true;
11284
+ }
11285
+ }
11286
+ var TAG29 = "sweep";
11287
+ var init_sweep = __esm(() => {
11288
+ init_board_helpers();
11289
+ init_claim();
11290
+ init_queue();
11291
+ init_types2();
11292
+ init_unblock();
11293
+ });
11294
+
10718
11295
  // src/reconcile.ts
10719
- import { detectGitProvider as detectGitProvider5, log as log31 } from "@gethmy/harness";
11296
+ import { detectGitProvider as detectGitProvider5, log as log32 } from "@gethmy/harness";
10720
11297
 
10721
11298
  class Reconciler {
10722
11299
  client;
@@ -10729,16 +11306,19 @@ class Reconciler {
10729
11306
  intervalMs;
10730
11307
  stateStore;
10731
11308
  agentConfig;
11309
+ agentUserId;
11310
+ sweepGuard;
10732
11311
  timer = null;
10733
11312
  lastTickAt = null;
10734
11313
  gitProvider = null;
11314
+ lastSweepStopReported = null;
10735
11315
  get lastTick() {
10736
11316
  return this.lastTickAt;
10737
11317
  }
10738
11318
  get isRunning() {
10739
11319
  return this.timer !== null;
10740
11320
  }
10741
- constructor(client, pool, projectId, agentId, pickupColumns, reviewColumns, approvedLabel, intervalMs = 60000, stateStore, agentConfig) {
11321
+ constructor(client, pool, projectId, agentId, pickupColumns, reviewColumns, approvedLabel, intervalMs = 60000, stateStore, agentConfig, agentUserId, sweepGuard) {
10742
11322
  this.client = client;
10743
11323
  this.pool = pool;
10744
11324
  this.projectId = projectId;
@@ -10749,6 +11329,8 @@ class Reconciler {
10749
11329
  this.intervalMs = intervalMs;
10750
11330
  this.stateStore = stateStore;
10751
11331
  this.agentConfig = agentConfig;
11332
+ this.agentUserId = agentUserId;
11333
+ this.sweepGuard = sweepGuard;
10752
11334
  }
10753
11335
  start() {
10754
11336
  this.tick();
@@ -10759,7 +11341,7 @@ class Reconciler {
10759
11341
  clearInterval(this.timer);
10760
11342
  this.timer = null;
10761
11343
  }
10762
- log31.info(TAG29, "Heartbeat stopped");
11344
+ log32.info(TAG30, "Heartbeat stopped");
10763
11345
  }
10764
11346
  async recoverStaleRuns() {
10765
11347
  if (!this.stateStore || !this.agentConfig)
@@ -10770,7 +11352,7 @@ class Reconciler {
10770
11352
  const pool = this.pool;
10771
11353
  for (const run of active) {
10772
11354
  if (isBudgetHeldRun(run)) {
10773
- log31.info(TAG29, `run ${run.runId} (#${run.cardShortId}) is held for a human budget decision — leaving it`);
11355
+ log32.info(TAG30, `run ${run.runId} (#${run.cardShortId}) is held for a human budget decision — leaving it`);
10774
11356
  continue;
10775
11357
  }
10776
11358
  const foreignDaemon = run.daemonPid !== process.pid;
@@ -10780,7 +11362,7 @@ class Reconciler {
10780
11362
  if (!daemonDead && !(heartbeatStale && ourZombie))
10781
11363
  continue;
10782
11364
  const reason = daemonDead ? `foreign daemon ${run.daemonPid} is dead` : `our worker lost card ${run.cardId} with ${Math.round((now - run.lastHeartbeatAt) / 1000)}s stale heartbeat`;
10783
- log31.warn(TAG29, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
11365
+ log32.warn(TAG30, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
10784
11366
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
10785
11367
  runId: run.runId,
10786
11368
  cardId: run.cardId,
@@ -10807,11 +11389,11 @@ class Reconciler {
10807
11389
  const stalledAt = Date.parse(card.updated_at ?? "");
10808
11390
  if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
10809
11391
  continue;
10810
- log31.warn(TAG29, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
11392
+ log32.warn(TAG30, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
10811
11393
  try {
10812
11394
  await this.client.moveCard(card.id, pickupCol.id);
10813
11395
  } catch (err) {
10814
- log31.error(TAG29, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
11396
+ log32.error(TAG30, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
10815
11397
  }
10816
11398
  }
10817
11399
  }
@@ -10843,7 +11425,7 @@ class Reconciler {
10843
11425
  return;
10844
11426
  const cardLabels = resolveCardLabels(card, labelMap);
10845
11427
  const subtasks = card.subtasks ?? [];
10846
- log31.info(TAG29, `Enqueuing claimed review card #${card.short_id} (agent-agnostic pickup)`);
11428
+ log32.info(TAG30, `Enqueuing claimed review card #${card.short_id} (agent-agnostic pickup)`);
10847
11429
  await this.pool.enqueue(card, column, cardLabels, subtasks, "review");
10848
11430
  }
10849
11431
  });
@@ -10867,14 +11449,63 @@ class Reconciler {
10867
11449
  const parkedAt = Date.parse(card.updated_at ?? "");
10868
11450
  if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
10869
11451
  continue;
10870
- log31.warn(TAG29, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
11452
+ log32.warn(TAG30, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
10871
11453
  try {
10872
11454
  await this.client.moveCard(card.id, pickupCol.id);
10873
11455
  } catch (err) {
10874
- log31.error(TAG29, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
11456
+ log32.error(TAG30, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
10875
11457
  }
10876
11458
  }
10877
11459
  }
11460
+ async sweepForWork(cards, columns, labelMap, pickupConfig) {
11461
+ const sweep = this.agentConfig?.sweep;
11462
+ if (!sweep?.enabled)
11463
+ return;
11464
+ const trustedAuthors = new Set(this.agentUserId ? [...sweep.trustedAuthors, this.agentUserId] : sweep.trustedAuthors);
11465
+ const rankOpts = {
11466
+ agentId: this.agentId,
11467
+ cards,
11468
+ columns,
11469
+ labelMap,
11470
+ pickupConfig,
11471
+ approvedLabel: this.approvedLabel,
11472
+ requireLabel: sweep.requireLabel,
11473
+ trustedAuthors,
11474
+ knownCardIds: this.pool.knownCardIds(),
11475
+ budgetCheck: (cardId) => this.pool.checkBudget(cardId),
11476
+ score: (card, column, labels, signals) => this.pool.scoreCardBreakdown(card, column, labels, signals),
11477
+ now: Date.now()
11478
+ };
11479
+ const verdict = this.sweepGuard?.check() ?? { claiming: true };
11480
+ if (!verdict.claiming) {
11481
+ this.reportSweepStopped(verdict.reason, verdict.detail);
11482
+ return;
11483
+ }
11484
+ this.lastSweepStopReported = null;
11485
+ if (!this.pool.hasFreeImplementSlot()) {
11486
+ log32.debug(TAG30, "sweep skipped — no free implement slot");
11487
+ return;
11488
+ }
11489
+ const { claimed } = await sweepForCard({
11490
+ ...rankOpts,
11491
+ client: this.client,
11492
+ projectId: this.projectId,
11493
+ maxProbes: sweep.maxProbesPerTick,
11494
+ ranking: this.agentConfig?.ranking ?? DEFAULT_AGENT_CONFIG.ranking
11495
+ });
11496
+ if (!claimed)
11497
+ return;
11498
+ await this.sweepGuard?.recordClaim();
11499
+ await this.pool.enqueue(claimed.card, claimed.column, claimed.labels, claimed.card.subtasks ?? [], "implement");
11500
+ }
11501
+ reportSweepStopped(reason, detail) {
11502
+ const line = `sweep stopped claiming — ${detail}`;
11503
+ if (this.lastSweepStopReported === reason)
11504
+ log32.debug(TAG30, line);
11505
+ else
11506
+ log32.warn(TAG30, line);
11507
+ this.lastSweepStopReported = reason;
11508
+ }
10878
11509
  async tick() {
10879
11510
  this.lastTickAt = Date.now();
10880
11511
  try {
@@ -10915,21 +11546,21 @@ class Reconciler {
10915
11546
  const subtasks = card.subtasks ?? [];
10916
11547
  const mode = route.mode;
10917
11548
  if (route.stage) {
10918
- log31.info(TAG29, `Stage card #${card.short_id} (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement) regardless of column`);
11549
+ log32.info(TAG30, `Stage card #${card.short_id} (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement) regardless of column`);
10919
11550
  }
10920
11551
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
10921
- log31.debug(TAG29, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
11552
+ log32.debug(TAG30, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
10922
11553
  continue;
10923
11554
  }
10924
11555
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
10925
- log31.debug(TAG29, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
11556
+ log32.debug(TAG30, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
10926
11557
  continue;
10927
11558
  }
10928
11559
  if (mode === "review" && !qualifiesForAutoReview(card.description)) {
10929
- log31.debug(TAG29, `Skipping #${card.short_id} — no branch or PR reference (not qualified for auto-review)`);
11560
+ log32.debug(TAG30, `Skipping #${card.short_id} — no branch or PR reference (not qualified for auto-review)`);
10930
11561
  continue;
10931
11562
  }
10932
- log31.info(TAG29, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
11563
+ log32.info(TAG30, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
10933
11564
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
10934
11565
  }
10935
11566
  }
@@ -10939,30 +11570,36 @@ class Reconciler {
10939
11570
  try {
10940
11571
  await this.pool.drainBudgetDecisions();
10941
11572
  } catch (err) {
10942
- log31.error(TAG29, `budget decisions were not drained this tick: ${err instanceof Error ? err.message : err}`);
11573
+ log32.error(TAG30, `budget decisions were not drained this tick: ${err instanceof Error ? err.message : err}`);
10943
11574
  }
10944
11575
  await this.recoverStrandedInProgress(cards, columns, knownCardIds);
10945
11576
  await this.recoverStrandedReview(cards, columns, labelMap, knownCardIds);
10946
11577
  for (const knownId of knownCardIds) {
10947
11578
  if (!allAgentCardIds.has(knownId)) {
10948
- log31.info(TAG29, `Missed unassign: ${knownId} — removing`);
11579
+ log32.info(TAG30, `Missed unassign: ${knownId} — removing`);
10949
11580
  await this.pool.removeCard(knownId);
10950
11581
  }
10951
11582
  }
10952
11583
  await this.releaseStalledApprovals(cards, columns, knownCardIds);
10953
- log31.debug(TAG29, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
11584
+ try {
11585
+ await this.sweepForWork(cards, columns, labelMap, pickupConfig);
11586
+ } catch (err) {
11587
+ log32.error(TAG30, `sweep failed this tick: ${err instanceof Error ? err.message : err}`);
11588
+ }
11589
+ log32.debug(TAG30, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
10954
11590
  } catch (err) {
10955
- log31.error(TAG29, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
11591
+ log32.error(TAG30, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
10956
11592
  }
10957
11593
  }
10958
11594
  }
10959
- var TAG29 = "reconcile";
11595
+ var TAG30 = "reconcile";
10960
11596
  var init_reconcile = __esm(() => {
10961
11597
  init_board_helpers();
10962
11598
  init_recovery();
10963
11599
  init_review_worktree();
10964
11600
  init_state_store();
10965
11601
  init_strand_recovery();
11602
+ init_sweep();
10966
11603
  init_types2();
10967
11604
  });
10968
11605
 
@@ -10971,7 +11608,7 @@ var exports_startup_banner = {};
10971
11608
  __export(exports_startup_banner, {
10972
11609
  createStartupBanner: () => createStartupBanner
10973
11610
  });
10974
- import { isPretty, log as log32 } from "@gethmy/harness";
11611
+ import { isPretty, log as log33 } from "@gethmy/harness";
10975
11612
  function createStartupBanner(config, version) {
10976
11613
  return isPretty() ? prettyBanner(config, version) : jsonBanner(config, version);
10977
11614
  }
@@ -10996,7 +11633,7 @@ function prettyBanner(config, version) {
10996
11633
  checks.push({ kind: "ok", message });
10997
11634
  },
10998
11635
  warn(message) {
10999
- log32.warn(TAG30, message);
11636
+ log33.warn(TAG31, message);
11000
11637
  checks.push({ kind: "warn", message: message.split(`
11001
11638
  `, 1)[0] });
11002
11639
  },
@@ -11021,25 +11658,25 @@ function prettyBanner(config, version) {
11021
11658
  };
11022
11659
  }
11023
11660
  function jsonBanner(config, version) {
11024
- log32.info(TAG30, `Harmony Agent Daemon v${version} starting...`);
11025
- log32.info(TAG30, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
11661
+ log33.info(TAG31, `Harmony Agent Daemon v${version} starting...`);
11662
+ log33.info(TAG31, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Runner: ${config.agent.runner} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
11026
11663
  if (config.agent.review.enabled) {
11027
- log32.info(TAG30, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
11664
+ log33.info(TAG31, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
11028
11665
  }
11029
11666
  let failed = false;
11030
11667
  return {
11031
11668
  setProjectName(_name) {},
11032
11669
  setGitProvider(provider) {
11033
- log32.info(TAG30, `Git provider: ${provider}`);
11670
+ log33.info(TAG31, `Git provider: ${provider}`);
11034
11671
  },
11035
11672
  setHttpPort(port) {
11036
- log32.info(TAG30, `HTTP server on port ${port}`);
11673
+ log33.info(TAG31, `HTTP server on port ${port}`);
11037
11674
  },
11038
11675
  check(message) {
11039
- log32.info(TAG30, message);
11676
+ log33.info(TAG31, message);
11040
11677
  },
11041
11678
  warn(message) {
11042
- log32.warn(TAG30, message);
11679
+ log33.warn(TAG31, message);
11043
11680
  },
11044
11681
  fail() {
11045
11682
  failed = true;
@@ -11047,7 +11684,7 @@ function jsonBanner(config, version) {
11047
11684
  async ready(message) {
11048
11685
  if (failed)
11049
11686
  return;
11050
- log32.info(TAG30, message);
11687
+ log33.info(TAG31, message);
11051
11688
  }
11052
11689
  };
11053
11690
  }
@@ -11128,7 +11765,7 @@ function cyan(s) {
11128
11765
  function yellow(s) {
11129
11766
  return `${ANSI.yellow}${s}${ANSI.reset}`;
11130
11767
  }
11131
- var TAG30 = "daemon", RULE_WIDTH = 70, ANSI;
11768
+ var TAG31 = "daemon", RULE_WIDTH = 70, ANSI;
11132
11769
  var init_startup_banner = __esm(() => {
11133
11770
  ANSI = {
11134
11771
  reset: "\x1B[0m",
@@ -11229,9 +11866,118 @@ var init_stream_parser_selftest = __esm(() => {
11229
11866
  init_stream_parser();
11230
11867
  });
11231
11868
 
11869
+ // src/sweep-guard.ts
11870
+ import { log as log34 } from "@gethmy/harness";
11871
+
11872
+ class SweepGuard {
11873
+ config;
11874
+ store;
11875
+ checkDailyBudget;
11876
+ constructor(config, store, checkDailyBudget) {
11877
+ this.config = config;
11878
+ this.store = store;
11879
+ this.checkDailyBudget = checkDailyBudget;
11880
+ }
11881
+ check() {
11882
+ const sweep = this.store.getSweep();
11883
+ if (sweep.haltReason) {
11884
+ return {
11885
+ claiming: false,
11886
+ reason: sweep.haltReason,
11887
+ detail: this.describeStop(sweep.haltReason),
11888
+ latched: true
11889
+ };
11890
+ }
11891
+ const cap = this.config.maxCardsPerSweep;
11892
+ if (cap >= 0 && sweep.claimed >= cap) {
11893
+ return {
11894
+ claiming: false,
11895
+ reason: "card_cap",
11896
+ detail: this.describeStop("card_cap"),
11897
+ latched: true
11898
+ };
11899
+ }
11900
+ const daily = this.checkDailyBudget?.();
11901
+ if (daily && !daily.allow) {
11902
+ return {
11903
+ claiming: false,
11904
+ latched: false,
11905
+ reason: "daily_budget",
11906
+ detail: this.describeStop("daily_budget", daily.detail)
11907
+ };
11908
+ }
11909
+ return { claiming: true };
11910
+ }
11911
+ async recordClaim() {
11912
+ await this.store.recordSweepClaim();
11913
+ const verdict = this.check();
11914
+ if (!verdict.claiming && verdict.latched)
11915
+ await this.halt(verdict.reason);
11916
+ return verdict;
11917
+ }
11918
+ async halt(reason) {
11919
+ const already = this.store.getSweep().haltReason === reason;
11920
+ await this.store.haltSweep(reason);
11921
+ if (!already) {
11922
+ const claimed = this.store.getSweep().claimed;
11923
+ log34.info(TAG32, `claiming stopped (${reason}) after ${claimed} claimed`);
11924
+ }
11925
+ return this.snapshot();
11926
+ }
11927
+ async resume() {
11928
+ const before = this.store.getSweep();
11929
+ const was = before.haltReason ? this.describeStop(before.haltReason) : null;
11930
+ await this.store.resumeSweep();
11931
+ if (was)
11932
+ log34.info(TAG32, `claiming resumed by the operator — was stopped: ${was}`);
11933
+ return this.snapshot();
11934
+ }
11935
+ snapshot() {
11936
+ const sweep = this.store.getSweep();
11937
+ const cap = this.config.maxCardsPerSweep;
11938
+ const verdict = this.check();
11939
+ return {
11940
+ enabled: this.config.enabled,
11941
+ claiming: verdict.claiming,
11942
+ haltReason: verdict.claiming ? null : verdict.reason,
11943
+ haltedAt: sweep.haltedAt,
11944
+ claimed: sweep.claimed,
11945
+ maxCardsPerSweep: cap < 0 ? null : cap,
11946
+ totalClaimed: sweep.totalClaimed,
11947
+ detail: verdict.claiming ? null : verdict.detail
11948
+ };
11949
+ }
11950
+ describeStop(reason, budgetDetail) {
11951
+ const claimed = this.store.getSweep().claimed;
11952
+ const cards = `${claimed} card${claimed === 1 ? "" : "s"}`;
11953
+ const resume = "Resume with `harmony-agent sweep resume`.";
11954
+ switch (reason) {
11955
+ case "card_cap":
11956
+ return `card cap reached — ${cards} claimed this sweep, cap is ${this.config.maxCardsPerSweep}. ${resume}`;
11957
+ case "operator":
11958
+ return `stopped by the operator after ${cards}. ${resume}`;
11959
+ case "daily_budget":
11960
+ return `daily spend cap reached${budgetDetail ? ` (${budgetDetail})` : ""} after ${cards} — claiming starts again on its own when the UTC day rolls over.`;
11961
+ }
11962
+ }
11963
+ }
11964
+ function describeCaps(sweep, dailyBudgetCents) {
11965
+ const cards = sweep.maxCardsPerSweep < 0 ? "no cap" : String(sweep.maxCardsPerSweep);
11966
+ return `cards/sweep ${cards} · spend/day ${formatDailyCap(dailyBudgetCents)}`;
11967
+ }
11968
+ function sweepBannerLine(config) {
11969
+ const sweep = config.sweep;
11970
+ const scope = sweep.requireLabel ? `cards labelled "${sweep.requireLabel}"` : "unassigned cards";
11971
+ const extra = sweep.trustedAuthors.length;
11972
+ const authors = extra ? `you + ${extra} trusted author${extra === 1 ? "" : "s"}` : "you only";
11973
+ return `Sweep ON — claims ${scope} in ${config.pickupColumns.join(", ")}, ` + `authored by ${authors}; caps: ${describeCaps(sweep, config.budget.dailyBudgetCents)}; ` + `${sweep.maxProbesPerTick} probes/tick`;
11974
+ }
11975
+ var TAG32 = "sweep";
11976
+ var init_sweep_guard = () => {};
11977
+
11232
11978
  // src/watcher.ts
11233
11979
  import { randomUUID as randomUUID2 } from "node:crypto";
11234
- import { isPretty as isPretty2, log as log33 } from "@gethmy/harness";
11980
+ import { isPretty as isPretty2, log as log35 } from "@gethmy/harness";
11235
11981
  import { createClient } from "@supabase/supabase-js";
11236
11982
 
11237
11983
  class Watcher {
@@ -11282,7 +12028,7 @@ class Watcher {
11282
12028
  }
11283
12029
  async start() {
11284
12030
  if (!isPretty2()) {
11285
- log33.info(TAG31, "Connecting to Supabase realtime (broadcast)...");
12031
+ log35.info(TAG33, "Connecting to Supabase realtime (broadcast)...");
11286
12032
  }
11287
12033
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
11288
12034
  this.subscribeBroadcast();
@@ -11295,7 +12041,7 @@ class Watcher {
11295
12041
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
11296
12042
  this.presenceChannel = presenceChannel;
11297
12043
  presenceChannel.on("presence", { event: "sync" }, () => {
11298
- log33.debug(TAG31, "Presence sync");
12044
+ log35.debug(TAG33, "Presence sync");
11299
12045
  }).subscribe(async (status) => {
11300
12046
  if (gen !== this.presenceGen)
11301
12047
  return;
@@ -11319,13 +12065,13 @@ class Watcher {
11319
12065
  if (trackStatus !== "ok") {
11320
12066
  this.presenceTracked = false;
11321
12067
  if (!this.stopping) {
11322
- log33.warn(TAG31, `Presence track returned "${trackStatus}" — scheduling reconnect`);
12068
+ log35.warn(TAG33, `Presence track returned "${trackStatus}" — scheduling reconnect`);
11323
12069
  this.schedulePresenceReconnect();
11324
12070
  }
11325
12071
  return;
11326
12072
  }
11327
12073
  if (!isPretty2() || !this.suppressStartupLogs) {
11328
- log33.info(TAG31, "Presence tracked on board-presence channel");
12074
+ log35.info(TAG33, "Presence tracked on board-presence channel");
11329
12075
  }
11330
12076
  this.presenceTracked = true;
11331
12077
  this.presenceReconnectAttempts = 0;
@@ -11333,7 +12079,7 @@ class Watcher {
11333
12079
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
11334
12080
  this.presenceTracked = false;
11335
12081
  if (!this.stopping) {
11336
- log33.warn(TAG31, `Presence subscription ${status} — scheduling reconnect`);
12082
+ log35.warn(TAG33, `Presence subscription ${status} — scheduling reconnect`);
11337
12083
  this.schedulePresenceReconnect();
11338
12084
  }
11339
12085
  }
@@ -11352,7 +12098,7 @@ class Watcher {
11352
12098
  async reconnectPresence() {
11353
12099
  if (this.stopping || !this.supabase)
11354
12100
  return;
11355
- log33.warn(TAG31, `Reconnecting presence subscription (attempt ${this.presenceReconnectAttempts})`);
12101
+ log35.warn(TAG33, `Reconnecting presence subscription (attempt ${this.presenceReconnectAttempts})`);
11356
12102
  if (this.presenceChannel) {
11357
12103
  const old = this.presenceChannel;
11358
12104
  this.presenceChannel = null;
@@ -11370,13 +12116,13 @@ class Watcher {
11370
12116
  return;
11371
12117
  const gen = ++this.broadcastGen;
11372
12118
  this.channel = this.supabase.channel(`board-${this.projectId}`).on("broadcast", { event: "card_update" }, (msg) => {
11373
- log33.debug(TAG31, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
12119
+ log35.debug(TAG33, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
11374
12120
  this.onCardBroadcast({
11375
12121
  event: "card_update",
11376
12122
  payload: msg.payload ?? {}
11377
12123
  });
11378
12124
  }).on("broadcast", { event: "card_created" }, (msg) => {
11379
- log33.debug(TAG31, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
12125
+ log35.debug(TAG33, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
11380
12126
  this.onCardBroadcast({
11381
12127
  event: "card_created",
11382
12128
  payload: msg.payload ?? {}
@@ -11386,7 +12132,7 @@ class Watcher {
11386
12132
  const cardId = payload.card_id;
11387
12133
  const command = payload.command;
11388
12134
  if (cardId && command) {
11389
- log33.info(TAG31, `Broadcast: agent_command ${command} for ${cardId}`);
12135
+ log35.info(TAG33, `Broadcast: agent_command ${command} for ${cardId}`);
11390
12136
  this.onAgentCommand?.({ cardId, command });
11391
12137
  }
11392
12138
  }).subscribe((status) => {
@@ -11396,13 +12142,13 @@ class Watcher {
11396
12142
  this.connected = true;
11397
12143
  this.reconnectAttempts = 0;
11398
12144
  if (!isPretty2() || !this.suppressStartupLogs) {
11399
- log33.info(TAG31, "Broadcast subscription active");
12145
+ log35.info(TAG33, "Broadcast subscription active");
11400
12146
  }
11401
12147
  this.maybeResolveReady();
11402
12148
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || status === "CLOSED") {
11403
12149
  this.connected = false;
11404
12150
  if (!this.stopping) {
11405
- log33.warn(TAG31, `Broadcast subscription ${status} — scheduling reconnect`);
12151
+ log35.warn(TAG33, `Broadcast subscription ${status} — scheduling reconnect`);
11406
12152
  this.scheduleReconnect();
11407
12153
  }
11408
12154
  }
@@ -11421,7 +12167,7 @@ class Watcher {
11421
12167
  async reconnectBroadcast() {
11422
12168
  if (this.stopping || !this.supabase)
11423
12169
  return;
11424
- log33.warn(TAG31, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
12170
+ log35.warn(TAG33, `Reconnecting broadcast subscription (attempt ${this.reconnectAttempts})`);
11425
12171
  if (this.channel) {
11426
12172
  const old = this.channel;
11427
12173
  this.channel = null;
@@ -11458,10 +12204,10 @@ class Watcher {
11458
12204
  }
11459
12205
  this.connected = false;
11460
12206
  this.presenceTracked = false;
11461
- log33.info(TAG31, "Broadcast subscription stopped");
12207
+ log35.info(TAG33, "Broadcast subscription stopped");
11462
12208
  }
11463
12209
  }
11464
- var TAG31 = "watcher";
12210
+ var TAG33 = "watcher";
11465
12211
  var init_watcher = () => {};
11466
12212
 
11467
12213
  // src/worktree-gc.ts
@@ -11475,7 +12221,7 @@ __export(exports_worktree_gc, {
11475
12221
  import { execFileSync as execFileSync6 } from "node:child_process";
11476
12222
  import { existsSync as existsSync4, readdirSync, statSync as statSync2 } from "node:fs";
11477
12223
  import { resolve as resolve2 } from "node:path";
11478
- import { cleanupWorktree as cleanupWorktree4, log as log34 } from "@gethmy/harness";
12224
+ import { cleanupWorktree as cleanupWorktree4, log as log36 } from "@gethmy/harness";
11479
12225
  function isTransientGitNetworkError(message) {
11480
12226
  return TRANSIENT_GIT_NETWORK_ERROR.test(message);
11481
12227
  }
@@ -11588,10 +12334,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
11588
12334
  });
11589
12335
  } catch {}
11590
12336
  if (result.removed.length > 0) {
11591
- log34.info(TAG32, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
12337
+ log36.info(TAG34, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
11592
12338
  }
11593
12339
  if (result.errors.length > 0) {
11594
- log34.warn(TAG32, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
12340
+ log36.warn(TAG34, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
11595
12341
  }
11596
12342
  return result;
11597
12343
  }
@@ -11621,7 +12367,7 @@ function pruneFailedRemoteBranches(opts) {
11621
12367
  } catch (err) {
11622
12368
  const detail = gitErrorDetail2(err);
11623
12369
  if (isTransientGitNetworkError(detail)) {
11624
- log34.debug(TAG32, `Remote branch GC skipped — remote unreachable: ${detail}`);
12370
+ log36.debug(TAG34, `Remote branch GC skipped — remote unreachable: ${detail}`);
11625
12371
  return result;
11626
12372
  }
11627
12373
  result.errors.push({ ref: "fetch", error: detail });
@@ -11660,7 +12406,7 @@ function pruneFailedRemoteBranches(opts) {
11660
12406
  continue;
11661
12407
  }
11662
12408
  if (clock() > sweepDeadline) {
11663
- log34.debug(TAG32, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
12409
+ log36.debug(TAG34, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
11664
12410
  break;
11665
12411
  }
11666
12412
  try {
@@ -11673,17 +12419,17 @@ function pruneFailedRemoteBranches(opts) {
11673
12419
  } catch (err) {
11674
12420
  const detail = gitErrorDetail2(err);
11675
12421
  if (isTransientGitNetworkError(detail)) {
11676
- log34.debug(TAG32, `Remote branch GC interrupted — remote unreachable: ${detail}`);
12422
+ log36.debug(TAG34, `Remote branch GC interrupted — remote unreachable: ${detail}`);
11677
12423
  break;
11678
12424
  }
11679
12425
  result.errors.push({ ref, error: detail });
11680
12426
  }
11681
12427
  }
11682
12428
  if (result.removed.length > 0) {
11683
- log34.info(TAG32, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
12429
+ log36.info(TAG34, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
11684
12430
  }
11685
12431
  if (result.errors.length > 0) {
11686
- log34.warn(TAG32, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
12432
+ log36.warn(TAG34, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
11687
12433
  }
11688
12434
  return result;
11689
12435
  }
@@ -11714,13 +12460,13 @@ class WorktreeGc {
11714
12460
  try {
11715
12461
  runWorktreeGc(this.basePath, this.store);
11716
12462
  } catch (err) {
11717
- log34.warn(TAG32, `GC tick failed: ${err instanceof Error ? err.message : err}`);
12463
+ log36.warn(TAG34, `GC tick failed: ${err instanceof Error ? err.message : err}`);
11718
12464
  }
11719
12465
  if (this.remoteOpts) {
11720
12466
  try {
11721
12467
  pruneFailedRemoteBranches(this.remoteOpts);
11722
12468
  } catch (err) {
11723
- log34.warn(TAG32, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
12469
+ log36.warn(TAG34, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
11724
12470
  }
11725
12471
  }
11726
12472
  }
@@ -11734,7 +12480,7 @@ function getRepoRoot2() {
11734
12480
  return null;
11735
12481
  }
11736
12482
  }
11737
- var TAG32 = "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;
12483
+ var TAG34 = "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;
11738
12484
  var init_worktree_gc = __esm(() => {
11739
12485
  GIT_NETWORK_EXEC = {
11740
12486
  timeout: GIT_NETWORK_TIMEOUT_MS,
@@ -11771,7 +12517,7 @@ import { randomUUID as randomUUID3 } from "node:crypto";
11771
12517
  import { createRequire as createRequire3 } from "node:module";
11772
12518
  import {
11773
12519
  detectGitProvider as detectGitProvider6,
11774
- log as log35,
12520
+ log as log37,
11775
12521
  validateGitProviderCli
11776
12522
  } from "@gethmy/harness";
11777
12523
  async function validatePrerequisites(config, banner) {
@@ -11845,21 +12591,36 @@ async function main() {
11845
12591
  } catch (err) {
11846
12592
  if (err instanceof ConfigValidationError) {
11847
12593
  banner.fail();
11848
- log35.error(TAG33, err.message);
12594
+ log37.error(TAG35, err.message);
11849
12595
  process.exit(1);
11850
12596
  }
11851
12597
  throw err;
11852
12598
  }
11853
12599
  try {
11854
12600
  validateAutoMergeConfig(config.agent);
12601
+ validateBudgetConfig(config.agent);
12602
+ validateSweepConfig(config.agent);
12603
+ validateRankingConfig(config.agent);
11855
12604
  } catch (err) {
11856
12605
  if (err instanceof ConfigValidationError) {
11857
12606
  banner.fail();
11858
- log35.error(TAG33, err.message);
12607
+ log37.error(TAG35, err.message);
11859
12608
  process.exit(1);
11860
12609
  }
11861
12610
  throw err;
11862
12611
  }
12612
+ if (config.agent.sweep.enabled) {
12613
+ banner.check(sweepBannerLine(config.agent));
12614
+ try {
12615
+ const { members } = await client.getWorkspaceMembers(config.workspaceId);
12616
+ const count = Array.isArray(members) ? members.length : 0;
12617
+ if (count > 1) {
12618
+ banner.warn(`Sweep is on and this workspace has ${count} members. Any member can edit a trusted author's card or comment on it, and the swept run executes that text with unrestricted Bash on this machine. Enable sweep only where every member is someone you would hand a shell.`);
12619
+ }
12620
+ } catch (err) {
12621
+ log37.debug(TAG35, `workspace member count unavailable for the sweep warning: ${err instanceof Error ? err.message : err}`);
12622
+ }
12623
+ }
11863
12624
  const { agent: registeredAgent } = await client.registerWorkspaceAgent(config.workspaceId, {
11864
12625
  identifier: config.agentIdentifier,
11865
12626
  name: config.agentName,
@@ -11880,6 +12641,13 @@ async function main() {
11880
12641
  const errored = outcomes.filter((o) => o.errors.length).length;
11881
12642
  banner.check(`Recovery: ${outcomes.length} orphan(s) handled${errored > 0 ? `, ${errored} with errors` : ""}`);
11882
12643
  }
12644
+ const sweepGuard = new SweepGuard(config.agent.sweep, stateStore, () => new BudgetGuard(config.agent.budget, stateStore).checkDailyBudget());
12645
+ if (config.agent.sweep.enabled) {
12646
+ const sweepState = sweepGuard.snapshot();
12647
+ if (!sweepState.claiming && sweepState.detail) {
12648
+ banner.warn(`Sweep is NOT claiming — ${sweepState.detail}`);
12649
+ }
12650
+ }
11883
12651
  try {
11884
12652
  const undeclared = await findUndeclaredGateMetrics(client, config.projectId, config.agent);
11885
12653
  for (const finding of undeclared) {
@@ -11900,7 +12668,7 @@ async function main() {
11900
12668
  pool.onCardCompleted = promoteSuccessors;
11901
12669
  const reviewColumns = config.agent.review.enabled ? config.agent.review.pickupColumns : [];
11902
12670
  const approvedLabel = config.agent.review.enabled ? config.agent.review.approvedLabel : "";
11903
- const reconciler = new Reconciler(client, pool, config.projectId, agentId, config.agent.pickupColumns, reviewColumns, approvedLabel, config.agent.timing.reconcileIntervalMs, stateStore, config.agent);
12671
+ const reconciler = new Reconciler(client, pool, config.projectId, agentId, config.agent.pickupColumns, reviewColumns, approvedLabel, config.agent.timing.reconcileIntervalMs, stateStore, config.agent, agentUserId, sweepGuard);
11904
12672
  let mergeMonitor = null;
11905
12673
  if (config.agent.review.enabled && config.agent.review.mergeMonitor) {
11906
12674
  mergeMonitor = new MergeMonitor(client, config.projectId, config.agent);
@@ -11957,10 +12725,13 @@ async function main() {
11957
12725
  budget: {
11958
12726
  todayCents: stateStore.getDailyCostCents(),
11959
12727
  dailyCapCents: config.agent.budget.dailyBudgetCents
11960
- }
12728
+ },
12729
+ sweep: sweepGuard.snapshot()
11961
12730
  };
11962
12731
  },
11963
- handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd)
12732
+ handleCommand: (cmd, cardId) => pool.handleAgentCommand(cardId, cmd),
12733
+ handleSweepCommand: (cmd) => cmd === "stop" ? sweepGuard.halt("operator") : sweepGuard.resume(),
12734
+ getSweep: () => sweepGuard.snapshot()
11964
12735
  }) : null;
11965
12736
  const watcher = new Watcher(realtimeCreds, config.projectId, {
11966
12737
  userId: agentUserId,
@@ -11979,7 +12750,7 @@ async function main() {
11979
12750
  if (shuttingDown)
11980
12751
  return;
11981
12752
  shuttingDown = true;
11982
- log35.info(TAG33, `Received ${signal}, shutting down gracefully...`);
12753
+ log37.info(TAG35, `Received ${signal}, shutting down gracefully...`);
11983
12754
  reconciler.stop();
11984
12755
  mergeMonitor?.stop();
11985
12756
  worktreeGc.stop();
@@ -11990,18 +12761,18 @@ async function main() {
11990
12761
  }
11991
12762
  await watcher.stop();
11992
12763
  await pool.shutdown();
11993
- log35.info(TAG33, "Daemon stopped.");
12764
+ log37.info(TAG35, "Daemon stopped.");
11994
12765
  process.exit(exitCode);
11995
12766
  };
11996
12767
  process.on("SIGINT", () => shutdown("SIGINT"));
11997
12768
  process.on("SIGTERM", () => shutdown("SIGTERM"));
11998
12769
  process.on("uncaughtException", (err) => {
11999
- log35.error(TAG33, `Uncaught exception: ${err.message}`);
12770
+ log37.error(TAG35, `Uncaught exception: ${err.message}`);
12000
12771
  exitCode = 1;
12001
12772
  shutdown("uncaughtException");
12002
12773
  });
12003
12774
  process.on("unhandledRejection", (reason) => {
12004
- log35.error(TAG33, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
12775
+ log37.error(TAG35, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
12005
12776
  exitCode = 1;
12006
12777
  shutdown("unhandledRejection");
12007
12778
  });
@@ -12060,29 +12831,29 @@ async function handleBroadcast(event, client, pool, config, agentId) {
12060
12831
  if (assignedAgentId === undefined)
12061
12832
  return;
12062
12833
  if (assignedAgentId === agentId) {
12063
- log35.info(TAG33, `Broadcast: card ${cardId} assigned to agent`);
12834
+ log37.info(TAG35, `Broadcast: card ${cardId} assigned to agent`);
12064
12835
  try {
12065
12836
  await pool.resetAttemptsForReassign(cardId);
12066
12837
  await tryEnqueueCard(cardId, client, pool, config, agentId);
12067
12838
  } catch (err) {
12068
- log35.error(TAG33, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
12839
+ log37.error(TAG35, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
12069
12840
  }
12070
12841
  } else if (pool.isCardKnown(cardId)) {
12071
- log35.info(TAG33, `Broadcast: card ${cardId} unassigned from agent`);
12842
+ log37.info(TAG35, `Broadcast: card ${cardId} unassigned from agent`);
12072
12843
  await pool.removeCard(cardId);
12073
12844
  }
12074
12845
  }
12075
12846
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
12076
12847
  const { card } = await client.getCard(cardId);
12077
12848
  if (card.assigned_agent_id !== agentId) {
12078
- log35.debug(TAG33, `Card ${cardId} no longer assigned to agent — skipping`);
12849
+ log37.debug(TAG35, `Card ${cardId} no longer assigned to agent — skipping`);
12079
12850
  return;
12080
12851
  }
12081
12852
  const board = await client.getBoard(config.projectId, { summary: true });
12082
12853
  const columns = board.columns;
12083
12854
  const column = columns.find((c) => c.id === card.column_id);
12084
12855
  if (!column) {
12085
- log35.warn(TAG33, `Column not found for card ${cardId}`);
12856
+ log37.warn(TAG35, `Column not found for card ${cardId}`);
12086
12857
  return;
12087
12858
  }
12088
12859
  const route = classifyPickup(card, column.name, {
@@ -12091,31 +12862,31 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
12091
12862
  playbooks: config.agent.playbooks
12092
12863
  });
12093
12864
  if (!route) {
12094
- log35.info(TAG33, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
12865
+ log37.info(TAG35, `Card #${card.short_id} is in "${column.name}", not a pickup/review/stage column — skipping`);
12095
12866
  return;
12096
12867
  }
12097
12868
  if (route.stage) {
12098
- log35.info(TAG33, `Card #${card.short_id} is a playbook stage card (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement pool) regardless of column`);
12869
+ log37.info(TAG35, `Card #${card.short_id} is a playbook stage card (stage "${card.current_stage}") in "${column.name}" — routing to the stage executor (implement pool) regardless of column`);
12099
12870
  }
12100
12871
  const mode = route.mode;
12101
12872
  const labelMap = buildLabelMap(board.labels ?? []);
12102
12873
  const cardLabels = resolveCardLabels(card, labelMap);
12103
12874
  const subtasks = card.subtasks ?? [];
12104
12875
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
12105
- log35.debug(TAG33, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
12876
+ log37.debug(TAG35, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
12106
12877
  return;
12107
12878
  }
12108
12879
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
12109
- log35.debug(TAG33, `Card #${card.short_id} has "${NEED_REVIEW_LABEL}" label (needs human) — skipping review`);
12880
+ log37.debug(TAG35, `Card #${card.short_id} has "${NEED_REVIEW_LABEL}" label (needs human) — skipping review`);
12110
12881
  return;
12111
12882
  }
12112
12883
  if (mode === "review" && !qualifiesForAutoReview(card.description)) {
12113
- log35.info(TAG33, `Card #${card.short_id} has no branch or PR reference — skipping auto-review`);
12884
+ log37.info(TAG35, `Card #${card.short_id} has no branch or PR reference — skipping auto-review`);
12114
12885
  return;
12115
12886
  }
12116
12887
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
12117
12888
  }
12118
- var TAG33 = "daemon", BASE_REMOTE = "origin", PKG_VERSION;
12889
+ var TAG35 = "daemon", BASE_REMOTE = "origin", PKG_VERSION;
12119
12890
  var init_src = __esm(() => {
12120
12891
  init_base_branch();
12121
12892
  init_board_helpers();
@@ -12133,6 +12904,7 @@ var init_src = __esm(() => {
12133
12904
  init_startup_banner();
12134
12905
  init_state_store();
12135
12906
  init_stream_parser_selftest();
12907
+ init_sweep_guard();
12136
12908
  init_types2();
12137
12909
  init_unblock();
12138
12910
  init_watcher();
@@ -12403,13 +13175,17 @@ var init_run_stats = () => {};
12403
13175
  // src/cli.ts
12404
13176
  import { realpathSync } from "node:fs";
12405
13177
  import { fileURLToPath } from "node:url";
12406
- import { log as log36 } from "@gethmy/harness";
13178
+ import { log as log38 } from "@gethmy/harness";
12407
13179
  var USAGE = `
12408
13180
  Harmony Agent — push-based daemon + ops toolkit.
12409
13181
 
12410
13182
  Usage:
12411
13183
  harmony-agent [run] Start the daemon (default)
12412
13184
  harmony-agent status Fetch status from the running daemon
13185
+ harmony-agent sweep [status] Sweep caps + whether it is claiming
13186
+ harmony-agent sweep stop Kill switch: halt sweep CLAIMING within one
13187
+ heartbeat. In-flight runs are NOT cancelled.
13188
+ harmony-agent sweep resume Resume claiming and reset the card cap
12413
13189
  harmony-agent health Exit 0 if daemon is healthy, 1 otherwise
12414
13190
  harmony-agent doctor Run preflight checks (don't start)
12415
13191
  harmony-agent gc One-shot worktree garbage collection
@@ -12446,13 +13222,21 @@ async function httpCall(path, init) {
12446
13222
  const url = `http://${bindAddr}:${port}${path}`;
12447
13223
  return fetch(url, init);
12448
13224
  }
13225
+ function formatSweep(sweep) {
13226
+ if (!sweep.enabled)
13227
+ return "off";
13228
+ const cap = sweep.maxCardsPerSweep === null ? "no cap" : String(sweep.maxCardsPerSweep);
13229
+ const counts = `${sweep.claimed}/${cap} claimed this sweep · ${sweep.totalClaimed} total`;
13230
+ return sweep.claiming ? `claiming · ${counts}` : `STOPPED (${sweep.haltReason}) · ${counts}
13231
+ ${sweep.detail ?? ""}`;
13232
+ }
12449
13233
  function printStatus(body) {
12450
13234
  const out = process.stdout;
12451
13235
  const uptime = formatDuration(body.uptimeMs);
12452
13236
  out.write(`daemon ${body.daemonId} (pid ${body.daemonPid}, up ${uptime})
12453
13237
  `);
12454
13238
  const activeCents = body.workers.reduce((sum, w) => sum + (w.costCents ?? 0), 0);
12455
- out.write(`budget $${(body.budget.todayCents / 100).toFixed(2)} / $${(body.budget.dailyCapCents / 100).toFixed(2)} today · $${(activeCents / 100).toFixed(2)} in-flight
13239
+ out.write(`budget $${(body.budget.todayCents / 100).toFixed(2)} / ${formatDailyCap(body.budget.dailyCapCents)} today · $${(activeCents / 100).toFixed(2)} in-flight
12456
13240
  `);
12457
13241
  out.write(`workers (${body.workers.length})
12458
13242
  `);
@@ -12461,6 +13245,10 @@ function printStatus(body) {
12461
13245
  const br = w.branchName ? ` branch=${w.branchName}` : "";
12462
13246
  const spend = w.costCents ? ` $${(w.costCents / 100).toFixed(2)}` : "";
12463
13247
  out.write(` #${w.id} ${w.pipeline.padEnd(9)} ${w.state}${card}${br}${spend}
13248
+ `);
13249
+ }
13250
+ if (body.sweep) {
13251
+ out.write(`sweep ${formatSweep(body.sweep)}
12464
13252
  `);
12465
13253
  }
12466
13254
  out.write(`impl queue (${body.implQueue.length})
@@ -12504,6 +13292,43 @@ async function statusCommand() {
12504
13292
  return 1;
12505
13293
  }
12506
13294
  }
13295
+ async function sweepCommand(sub) {
13296
+ const action = sub ?? "status";
13297
+ if (!["status", "stop", "resume"].includes(action)) {
13298
+ process.stderr.write(`unknown sweep subcommand: ${action}
13299
+ usage: harmony-agent sweep <status|stop|resume>
13300
+ `);
13301
+ return 1;
13302
+ }
13303
+ try {
13304
+ const res = action === "status" ? await httpCall("/sweep") : await httpCall(`/sweep/${action}`, { method: "POST" });
13305
+ if (!res.ok) {
13306
+ const body2 = await res.json().catch(() => null);
13307
+ process.stderr.write(`daemon responded ${res.status} ${res.statusText}${body2?.detail ? ` — ${body2.detail}` : ""}
13308
+ `);
13309
+ return 1;
13310
+ }
13311
+ if (action === "status") {
13312
+ const sweep = await res.json();
13313
+ process.stdout.write(`sweep ${formatSweep(sweep)}
13314
+ `);
13315
+ return 0;
13316
+ }
13317
+ const body = await res.json();
13318
+ process.stdout.write(`sweep ${formatSweep(body.sweep)}
13319
+ `);
13320
+ if (action === "stop") {
13321
+ process.stdout.write(body.inFlight > 0 ? ` ${body.inFlight} run(s) already in flight continue to completion — nothing was cancelled.
13322
+ ` : ` No run was in flight; nothing was cancelled.
13323
+ `);
13324
+ }
13325
+ return 0;
13326
+ } catch (err) {
13327
+ process.stderr.write(`could not reach daemon: ${err instanceof Error ? err.message : err}
13328
+ `);
13329
+ return 1;
13330
+ }
13331
+ }
12507
13332
  async function healthCommand() {
12508
13333
  try {
12509
13334
  const res = await httpCall("/health");
@@ -12884,6 +13709,8 @@ async function dispatch(argv) {
12884
13709
  return 0;
12885
13710
  case "status":
12886
13711
  return statusCommand();
13712
+ case "sweep":
13713
+ return sweepCommand(args[1]);
12887
13714
  case "health":
12888
13715
  return healthCommand();
12889
13716
  case "doctor":
@@ -12924,7 +13751,7 @@ if (isMainModule()) {
12924
13751
  if (code !== 0)
12925
13752
  process.exit(code);
12926
13753
  }).catch((err) => {
12927
- log36.error("cli", err instanceof Error ? err.message : String(err));
13754
+ log38.error("cli", err instanceof Error ? err.message : String(err));
12928
13755
  process.exit(1);
12929
13756
  });
12930
13757
  }