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