@gethmy/agent 1.37.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +644 -57
  2. package/dist/index.js +602 -56
  3. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -754,6 +754,9 @@ class BudgetGuard {
754
754
  const turns = this.checkCardTurns(cardId);
755
755
  if (!turns.allow)
756
756
  return turns;
757
+ const runs = this.checkCardRuns(cardId);
758
+ if (!runs.allow)
759
+ return runs;
757
760
  return this.checkDailyBudget();
758
761
  }
759
762
  checkCardTurns(cardId) {
@@ -769,6 +772,19 @@ class BudgetGuard {
769
772
  detail: `${spent} of ${cap} turns spent on this card`
770
773
  };
771
774
  }
775
+ checkCardRuns(cardId) {
776
+ const cap = this.config.maxRunsPerCard;
777
+ if (cap < 0)
778
+ return { allow: true };
779
+ const started = this.store.getCardRunStarts(cardId);
780
+ if (started < cap)
781
+ return { allow: true };
782
+ return {
783
+ allow: false,
784
+ reason: "card_runs",
785
+ detail: `${started} of ${cap} run starts spent on this card`
786
+ };
787
+ }
772
788
  checkDailyBudget() {
773
789
  const cap = this.config.dailyBudgetCents;
774
790
  if (cap < 0)
@@ -798,6 +814,17 @@ function buildTurnCapComment(spentTurns, cap, legs) {
798
814
  ].join(`
799
815
  `);
800
816
  }
817
+ function buildRunCapComment(runStarts, cap) {
818
+ return [
819
+ "**Run backstop reached — over to you.**",
820
+ `This card has cost ${runStarts} run starts, which is its ceiling of ${cap} (\`budget.maxRunsPerCard\`). I will not pick it up again, and a Continue on it would be refused.`,
821
+ "",
822
+ "This ceiling exists as a backstop against a retry loop the attempt budget cannot see — a card that reaches it has been failing repeatedly for reasons worth a look (the run log and the failure history on this card say what killed each run).",
823
+ "",
824
+ "Two ways on: reassign the card, which clears the count and hands it back to me fresh, or raise `budget.maxRunsPerCard` in the daemon config if this card genuinely needs the room."
825
+ ].join(`
826
+ `);
827
+ }
801
828
  function buildGaveUpComment(maxAttempts, failures, pauseEnabled) {
802
829
  const wayBackIn = pauseEnabled ? "Continue to grant a fresh attempt, or reassign the card." : "Reassign the card to grant a fresh attempt.";
803
830
  const lines = [
@@ -1064,6 +1091,22 @@ function validateAutoMergeConfig(config) {
1064
1091
  issues.push(`review.autoMerge.waitMinutes: must be an integer between 1 and 55 (got ${w}) — an interactive agent session goes stale at about an hour, and a command that outlives its session reports into nothing`);
1065
1092
  }
1066
1093
  }
1094
+ if (typeof autoMerge.findingsLoop !== "boolean") {
1095
+ issues.push(`review.autoMerge.findingsLoop: must be true or false (got ${JSON.stringify(autoMerge.findingsLoop)}) — an absent value would switch unattended code editing on or off without saying so`);
1096
+ }
1097
+ const rounds = autoMerge.maxFindingRounds;
1098
+ if (!Number.isInteger(rounds) || rounds < 1) {
1099
+ issues.push(`review.autoMerge.maxFindingRounds: must be an integer >= 1 (got ${rounds}) — the cap is the safety property and has no opt-out`);
1100
+ }
1101
+ if (autoMerge.findingsLoop) {
1102
+ if (!autoMerge.interactive) {
1103
+ issues.push("review.autoMerge.findingsLoop: needs review.autoMerge.interactive — the loop only runs on the interactive merge path, so armed alone it is inert rather than weaker");
1104
+ }
1105
+ const findingAuthors = independent?.findingAuthors;
1106
+ if (!Array.isArray(findingAuthors) || findingAuthors.filter((a) => typeof a === "string" && a.trim()).length === 0) {
1107
+ issues.push("review.autoMerge.independentReview.findingAuthors: must name at least one login while review.autoMerge.findingsLoop is on — it is the only thing separating the review's findings from every other commenter's text, and an empty list parks every round");
1108
+ }
1109
+ }
1067
1110
  if (issues.length > 0) {
1068
1111
  throw new ConfigValidationError(`Invalid agent config — ${issues.join("; ")}`, issues);
1069
1112
  }
@@ -1143,6 +1186,12 @@ function validateBudgetConfig(config) {
1143
1186
  const issue = turns === 0 ? `budget.maxTurnsPerCard: 0 is ambiguous — it could mean "no cap" or "run nothing"` : `budget.maxTurnsPerCard: ${JSON.stringify(turns)} is not a whole number of turns`;
1144
1187
  throw new ConfigValidationError(`Invalid agent config — ${issue}.
1145
1188
  ` + ` Set a positive cap in turns (e.g. 300, ~2.5x the measured median card), or -1 to run with no per-card cap.`, [issue]);
1189
+ }
1190
+ const runs = config.budget.maxRunsPerCard;
1191
+ if (!Number.isInteger(runs) || runs === 0) {
1192
+ const issue = runs === 0 ? `budget.maxRunsPerCard: 0 is ambiguous — it could mean "no cap" or "run nothing"` : `budget.maxRunsPerCard: ${JSON.stringify(runs)} is not a whole number of runs`;
1193
+ throw new ConfigValidationError(`Invalid agent config — ${issue}.
1194
+ ` + ` Set a positive cap in run starts (e.g. 30, 10x the give-up budget), or -1 to run with no per-card run backstop.`, [issue]);
1146
1195
  }
1147
1196
  const perSpawn = config.sdk?.maxBudgetUsd;
1148
1197
  if (!Number.isFinite(perSpawn)) {
@@ -2461,6 +2510,229 @@ var init_reviewTools = __esm(() => {
2461
2510
  "mcp__harmony__harmony_toggle_subtask"
2462
2511
  ];
2463
2512
  });
2513
+
2514
+ // ../harmony-shared/dist/runEventPayloadLimit.js
2515
+ function runEventPayloadExceedsLimit(payload, maxBytes = MAX_RUN_EVENT_PAYLOAD_BYTES) {
2516
+ if (payload === null || payload === undefined)
2517
+ return false;
2518
+ let serialized;
2519
+ try {
2520
+ serialized = JSON.stringify(payload);
2521
+ } catch {
2522
+ return true;
2523
+ }
2524
+ if (serialized === undefined)
2525
+ return true;
2526
+ return serialized.length > maxBytes;
2527
+ }
2528
+ var MAX_RUN_EVENT_PAYLOAD_BYTES = 16000;
2529
+
2530
+ // ../harmony-shared/dist/runRedaction.js
2531
+ function isSensitivePath(rawPath) {
2532
+ if (typeof rawPath !== "string" || rawPath.length === 0)
2533
+ return false;
2534
+ const path = rawPath.trim().toLowerCase();
2535
+ const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
2536
+ if (segments.length === 0)
2537
+ return false;
2538
+ for (let i = 0;i < segments.length; i++) {
2539
+ const segment = segments[i];
2540
+ if (!SENSITIVE_SEGMENTS.includes(segment))
2541
+ continue;
2542
+ if (CONFIG_SCOPED_SEGMENTS.has(segment)) {
2543
+ if (i > 0 && segments[i - 1] === ".config")
2544
+ return true;
2545
+ continue;
2546
+ }
2547
+ return true;
2548
+ }
2549
+ const basename = segments[segments.length - 1];
2550
+ if (SENSITIVE_BASENAMES.has(basename))
2551
+ return true;
2552
+ if (basename === ".env" || basename.startsWith(".env."))
2553
+ return true;
2554
+ if (basename.endsWith(".env"))
2555
+ return true;
2556
+ if (SENSITIVE_EXTENSIONS.some((ext) => basename.endsWith(ext)))
2557
+ return true;
2558
+ if (/service[-_]?account.*\.json$/.test(basename))
2559
+ return true;
2560
+ return false;
2561
+ }
2562
+ function sensitivePathsIn(input, depth = 0) {
2563
+ if (depth > 6)
2564
+ return [];
2565
+ if (typeof input === "string") {
2566
+ return isSensitivePath(input) ? [input] : [];
2567
+ }
2568
+ if (Array.isArray(input)) {
2569
+ return input.flatMap((item) => sensitivePathsIn(item, depth + 1));
2570
+ }
2571
+ if (input !== null && typeof input === "object") {
2572
+ return Object.values(input).flatMap((value) => sensitivePathsIn(value, depth + 1));
2573
+ }
2574
+ return [];
2575
+ }
2576
+ function redactSecrets(text) {
2577
+ if (typeof text !== "string" || text.length === 0)
2578
+ return text;
2579
+ let out = text;
2580
+ for (const { pattern, replace } of SECRET_PATTERNS) {
2581
+ pattern.lastIndex = 0;
2582
+ out = out.replace(pattern, replace);
2583
+ }
2584
+ return out;
2585
+ }
2586
+ function truncate(text, max, originalLength) {
2587
+ const total = originalLength ?? text.length;
2588
+ if (total <= max)
2589
+ return text;
2590
+ return `${text.slice(0, max)}… [+${total - max} chars]`;
2591
+ }
2592
+ function redactThenTruncate(text, max) {
2593
+ const preCap = max * 4 + 64;
2594
+ const scanned = text.length > preCap ? text.slice(0, preCap) : text;
2595
+ return truncate(redactSecrets(scanned), max, text.length);
2596
+ }
2597
+ function redactStructure(value, depth = 0) {
2598
+ if (depth > 6)
2599
+ return REDACTION_MARK;
2600
+ if (typeof value === "string") {
2601
+ return redactThenTruncate(value, MAX_INPUT_STRING_CHARS);
2602
+ }
2603
+ if (Array.isArray(value)) {
2604
+ return value.slice(0, 20).map((item) => redactStructure(item, depth + 1));
2605
+ }
2606
+ if (value !== null && typeof value === "object") {
2607
+ const out = {};
2608
+ for (const [key, item] of Object.entries(value)) {
2609
+ out[key] = redactStructure(item, depth + 1);
2610
+ }
2611
+ return out;
2612
+ }
2613
+ return value;
2614
+ }
2615
+ function redactToolCall(args) {
2616
+ const sensitive = sensitivePathsIn(args.input);
2617
+ if (sensitive.length > 0) {
2618
+ return { withheld: "sensitive-path" };
2619
+ }
2620
+ const result = {};
2621
+ if (args.input !== undefined) {
2622
+ let input = redactStructure(args.input);
2623
+ let serialized;
2624
+ try {
2625
+ serialized = JSON.stringify(input) ?? "";
2626
+ } catch {
2627
+ serialized = "";
2628
+ input = REDACTION_MARK;
2629
+ }
2630
+ if (serialized.length > MAX_INPUT_CHARS) {
2631
+ input = truncate(serialized, MAX_INPUT_CHARS);
2632
+ }
2633
+ result.input = input;
2634
+ }
2635
+ if (typeof args.output === "string" && args.output.length > 0) {
2636
+ result.output = redactThenTruncate(args.output, MAX_OUTPUT_CHARS);
2637
+ }
2638
+ return result;
2639
+ }
2640
+ var MAX_INPUT_CHARS = 2000, MAX_OUTPUT_CHARS = 4000, MAX_INPUT_STRING_CHARS = 600, REDACTION_MARK = "«redacted»", SENSITIVE_SEGMENTS, CONFIG_SCOPED_SEGMENTS, SENSITIVE_BASENAMES, SENSITIVE_EXTENSIONS, SECRET_PATTERNS;
2641
+ var init_runRedaction = __esm(() => {
2642
+ SENSITIVE_SEGMENTS = [
2643
+ ".ssh",
2644
+ ".gnupg",
2645
+ ".aws",
2646
+ ".codex",
2647
+ ".gemini",
2648
+ ".docker",
2649
+ ".kube",
2650
+ ".hmy",
2651
+ ".harmony-mcp",
2652
+ ".password-store",
2653
+ ".claude",
2654
+ "gh",
2655
+ "gcloud",
2656
+ "op",
2657
+ "anthropic"
2658
+ ];
2659
+ CONFIG_SCOPED_SEGMENTS = new Set([
2660
+ "gh",
2661
+ "gcloud",
2662
+ "op",
2663
+ "anthropic"
2664
+ ]);
2665
+ SENSITIVE_BASENAMES = new Set([
2666
+ ".netrc",
2667
+ "_netrc",
2668
+ ".npmrc",
2669
+ ".pgpass",
2670
+ ".git-credentials",
2671
+ ".htpasswd",
2672
+ ".claude.json",
2673
+ "credentials",
2674
+ ".credentials",
2675
+ "credentials.json",
2676
+ ".credentials.json",
2677
+ "credentials.yml",
2678
+ "credentials.yaml",
2679
+ "auth.json",
2680
+ ".auth.json",
2681
+ "secrets",
2682
+ "secrets.json",
2683
+ "secrets.yaml",
2684
+ "secrets.yml",
2685
+ "id_rsa",
2686
+ "id_dsa",
2687
+ "id_ecdsa",
2688
+ "id_ed25519",
2689
+ "known_hosts"
2690
+ ]);
2691
+ SENSITIVE_EXTENSIONS = [
2692
+ ".pem",
2693
+ ".key",
2694
+ ".p12",
2695
+ ".pfx",
2696
+ ".keystore",
2697
+ ".jks",
2698
+ ".asc",
2699
+ ".gpg"
2700
+ ];
2701
+ SECRET_PATTERNS = [
2702
+ {
2703
+ pattern: /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*-----/g,
2704
+ replace: REDACTION_MARK
2705
+ },
2706
+ { pattern: /\bhmy_at_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
2707
+ { pattern: /\bhmy_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
2708
+ { pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replace: REDACTION_MARK },
2709
+ { pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}/g, replace: REDACTION_MARK },
2710
+ { pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, replace: REDACTION_MARK },
2711
+ { pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: REDACTION_MARK },
2712
+ { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: REDACTION_MARK },
2713
+ { pattern: /\bAIza[0-9A-Za-z_-]{20,}/g, replace: REDACTION_MARK },
2714
+ {
2715
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
2716
+ replace: REDACTION_MARK
2717
+ },
2718
+ {
2719
+ pattern: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
2720
+ replace: `$1 ${REDACTION_MARK}`
2721
+ },
2722
+ {
2723
+ pattern: /(\w{1,32}:\/\/)[^/\s:@]+:[^/\s@]+@/g,
2724
+ replace: `$1${REDACTION_MARK}@`
2725
+ },
2726
+ {
2727
+ pattern: /\b([A-Za-z0-9_]{0,40}(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTH)[A-Za-z0-9_]{0,40})\s*[=:]\s*(?:"[^"]*"|'[^']*'|`[^`]*`|[^\s,;)}\]]+)/gi,
2728
+ replace: `$1=${REDACTION_MARK}`
2729
+ },
2730
+ {
2731
+ pattern: /(--?(?:password|passwd|token|api-?key|secret|auth)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
2732
+ replace: `$1${REDACTION_MARK}`
2733
+ }
2734
+ ];
2735
+ });
2464
2736
  // ../harmony-shared/dist/stageHandoff.js
2465
2737
  function isFanoutHandoffItem(value) {
2466
2738
  if (typeof value !== "object" || value === null)
@@ -2655,6 +2927,7 @@ var init_dist = __esm(() => {
2655
2927
  init_projectTemplates();
2656
2928
  init_realtimeChannel();
2657
2929
  init_reviewTools();
2930
+ init_runRedaction();
2658
2931
  init_stageHandoff();
2659
2932
  init_types();
2660
2933
  });
@@ -2967,11 +3240,15 @@ function agentIdentifier(workerId) {
2967
3240
  function endStatusForCancel(reason) {
2968
3241
  return reason === "human_stop" ? "cancelled" : "paused";
2969
3242
  }
2970
- var DEFAULT_AGENT_CONFIG, IN_PROGRESS_COLUMN = "In Progress", NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent", DAEMON_IDENTIFIER_BASE = "harmony-daemon", NOTICE_IDENTIFIER;
3243
+ var DEFAULT_FINDING_AUTHORS, DEFAULT_AGENT_CONFIG, IN_PROGRESS_COLUMN = "In Progress", NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent", DAEMON_IDENTIFIER_BASE = "harmony-daemon", NOTICE_IDENTIFIER;
2971
3244
  var init_types2 = __esm(() => {
2972
3245
  init_board_review();
2973
3246
  init_contract_phase();
2974
3247
  init_plan_phase();
3248
+ DEFAULT_FINDING_AUTHORS = [
3249
+ "claude",
3250
+ "github-actions[bot]"
3251
+ ];
2975
3252
  DEFAULT_AGENT_CONFIG = {
2976
3253
  poolSize: 6,
2977
3254
  maxTimeout: 5400000,
@@ -3050,6 +3327,8 @@ var init_types2 = __esm(() => {
3050
3327
  enabled: false,
3051
3328
  interactive: false,
3052
3329
  waitMinutes: 20,
3330
+ findingsLoop: false,
3331
+ maxFindingRounds: 2,
3053
3332
  strategy: "squash",
3054
3333
  deleteBranch: true,
3055
3334
  requireGreenCi: true,
@@ -3069,7 +3348,8 @@ var init_types2 = __esm(() => {
3069
3348
  enabled: true,
3070
3349
  label: "ci-review",
3071
3350
  checkName: "review",
3072
- verdict: { enabled: true, checkName: "review-verdict" }
3351
+ verdict: { enabled: true, checkName: "review-verdict" },
3352
+ findingAuthors: [...DEFAULT_FINDING_AUTHORS]
3073
3353
  }
3074
3354
  }
3075
3355
  },
@@ -3077,6 +3357,7 @@ var init_types2 = __esm(() => {
3077
3357
  maxAttemptsPerCard: 3,
3078
3358
  dailyBudgetCents: 40000,
3079
3359
  maxTurnsPerCard: 300,
3360
+ maxRunsPerCard: 30,
3080
3361
  pause: {
3081
3362
  enabled: true,
3082
3363
  waitHours: 24,
@@ -3290,6 +3571,7 @@ var init_config = __esm(() => {
3290
3571
  // src/config-dir-migration.ts
3291
3572
  import {
3292
3573
  existsSync,
3574
+ lstatSync,
3293
3575
  mkdirSync,
3294
3576
  readdirSync,
3295
3577
  renameSync,
@@ -3324,27 +3606,84 @@ function migrateConfigDir(opts = {}) {
3324
3606
  log6.info(TAG6, `Moved ${from} to ${to}`);
3325
3607
  return { outcome: "moved", ...empty };
3326
3608
  }
3327
- for (const entry of readdirSync(from)) {
3328
- const target = join2(to, entry);
3329
- if (existsSync(target)) {
3330
- skipped.push(entry);
3331
- continue;
3332
- }
3333
- renameSync(join2(from, entry), target);
3334
- moved.push(entry);
3335
- }
3609
+ mergeDirInto(from, to, "", moved, skipped, 0);
3336
3610
  if (moved.length === 0 && skipped.length === 0) {
3337
3611
  return { outcome: "nothing-to-do", moved, skipped };
3338
3612
  }
3339
- log6.info(TAG6, `Merged ${from} into ${to}: ${moved.length} moved` + (skipped.length > 0 ? `, ${skipped.length} left in place (already present at the target): ${skipped.join(", ")}` : ""));
3613
+ log6.info(TAG6, `Merged ${from} into ${to}: ${moved.length} moved` + (skipped.length > 0 ? `, ${skipped.length} left in place (already present at the target): ${nameSample(skipped)}` : ""));
3340
3614
  return { outcome: "merged", moved, skipped };
3341
3615
  } catch (err) {
3342
3616
  const error = err instanceof Error ? err.message : String(err);
3343
- log6.warn(TAG6, `Could not migrate ${from} to ${to}: ${error}.` + (moved.length > 0 ? ` ${moved.length} entr${moved.length === 1 ? "y" : "ies"} already moved: ${moved.join(", ")}. Re-run to finish; nothing is deleted.` : ` Nothing was moved; the daemon reads the old config through the fallback.`));
3617
+ log6.warn(TAG6, `Could not migrate ${from} to ${to}: ${error}.` + (moved.length > 0 ? ` ${moved.length} entr${moved.length === 1 ? "y" : "ies"} already moved: ${nameSample(moved)}. Re-run to finish; nothing is deleted.` : ` Nothing was moved; the daemon reads the old config through the fallback.`));
3344
3618
  return { outcome: "failed", moved, skipped, error };
3345
3619
  }
3346
3620
  }
3347
- var TAG6 = "config-migration";
3621
+ function mergeDirInto(fromDir, toDir, rel, moved, skipped, depth) {
3622
+ for (const entry of readdirSync(fromDir, { withFileTypes: true })) {
3623
+ const source = join2(fromDir, entry.name);
3624
+ const target = join2(toDir, entry.name);
3625
+ const relPath = join2(rel, entry.name);
3626
+ if (entry.isDirectory()) {
3627
+ if (!lexists(target)) {
3628
+ renameSync(source, target);
3629
+ if (recordMovedLeaves(target, relPath, moved) === 0) {
3630
+ moved.push(`${relPath}/`);
3631
+ }
3632
+ continue;
3633
+ }
3634
+ const targetIsDir = isRealDirectory(target);
3635
+ if (!targetIsDir || depth >= MAX_MERGE_DEPTH) {
3636
+ skipped.push(`${relPath}/`);
3637
+ continue;
3638
+ }
3639
+ mergeDirInto(source, target, relPath, moved, skipped, depth + 1);
3640
+ continue;
3641
+ }
3642
+ if (lexists(target)) {
3643
+ skipped.push(relPath);
3644
+ continue;
3645
+ }
3646
+ renameSync(source, target);
3647
+ moved.push(relPath);
3648
+ }
3649
+ }
3650
+ function recordMovedLeaves(dir, rel, moved) {
3651
+ let leaves = 0;
3652
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
3653
+ const relPath = join2(rel, entry.name);
3654
+ if (entry.isDirectory()) {
3655
+ const below = recordMovedLeaves(join2(dir, entry.name), relPath, moved);
3656
+ if (below === 0)
3657
+ moved.push(`${relPath}/`);
3658
+ leaves += below;
3659
+ continue;
3660
+ }
3661
+ moved.push(relPath);
3662
+ leaves += 1;
3663
+ }
3664
+ return leaves;
3665
+ }
3666
+ function lexists(path) {
3667
+ try {
3668
+ lstatSync(path);
3669
+ return true;
3670
+ } catch {
3671
+ return false;
3672
+ }
3673
+ }
3674
+ function isRealDirectory(path) {
3675
+ try {
3676
+ return lstatSync(path).isDirectory();
3677
+ } catch {
3678
+ return false;
3679
+ }
3680
+ }
3681
+ function nameSample(paths, limit = 10) {
3682
+ if (paths.length <= limit)
3683
+ return paths.join(", ");
3684
+ return `${paths.slice(0, limit).join(", ")} … and ${paths.length - limit} more`;
3685
+ }
3686
+ var TAG6 = "config-migration", MAX_MERGE_DEPTH = 32;
3348
3687
  var init_config_dir_migration = __esm(() => {
3349
3688
  init_paths();
3350
3689
  });
@@ -3614,7 +3953,7 @@ function resolveDaemonUserId(userEmail, members, apiKeyUserId) {
3614
3953
  // src/ci-patch-trees.ts
3615
3954
  import { execFileSync as execFileSync2 } from "node:child_process";
3616
3955
  import {
3617
- lstatSync,
3956
+ lstatSync as lstatSync2,
3618
3957
  mkdirSync as mkdirSync2,
3619
3958
  mkdtempSync,
3620
3959
  readdirSync as readdirSync2,
@@ -3747,7 +4086,7 @@ function safeDestination(clean, path) {
3747
4086
  for (const part of parts.slice(0, -1)) {
3748
4087
  probe = join3(probe, part);
3749
4088
  try {
3750
- if (lstatSync(probe).isSymbolicLink()) {
4089
+ if (lstatSync2(probe).isSymbolicLink()) {
3751
4090
  throw new Error(`the repair's destination passes through a symlink in the checkout (${path}) — refusing to write through it`);
3752
4091
  }
3753
4092
  } catch (err) {
@@ -3773,6 +4112,7 @@ import {
3773
4112
  extractPrUrl,
3774
4113
  GIT_NO_HOOKS as GIT_NO_HOOKS3,
3775
4114
  installCommand,
4115
+ isPrUrlOnOrigin,
3776
4116
  log as log9,
3777
4117
  removeWorktreeHoldingBranch,
3778
4118
  resolvePrHeadBranch
@@ -3863,9 +4203,48 @@ async function resolveReviewBranch(description, cwd) {
3863
4203
  if (!prUrl)
3864
4204
  return { kind: "none" };
3865
4205
  const provider = detectGitProvider(cwd);
4206
+ if (!isPrUrlOnOrigin(prUrl, cwd, provider)) {
4207
+ return {
4208
+ kind: "skip",
4209
+ reason: `cannot prove ${prUrl} is a pull request on this checkout's own origin (provider: ${provider}) — the PR: line is editable board data, so nothing was read from it`
4210
+ };
4211
+ }
3866
4212
  const resolved = await resolvePrHeadBranch(prUrl, cwd, provider);
3867
4213
  return resolved;
3868
4214
  }
4215
+ function assessPrReviewContinuity(opts) {
4216
+ const { repoRoot, branch, reviewedSha } = opts;
4217
+ if (!reviewedSha)
4218
+ return { kind: "ok" };
4219
+ let head;
4220
+ try {
4221
+ execFileSync3("git", [...GIT_NO_HOOKS3, "fetch", "origin", branch], {
4222
+ cwd: repoRoot,
4223
+ stdio: "pipe",
4224
+ timeout: 60000
4225
+ });
4226
+ head = execFileSync3("git", [...GIT_NO_HOOKS3, "rev-parse", "FETCH_HEAD"], {
4227
+ cwd: repoRoot,
4228
+ encoding: "utf-8",
4229
+ stdio: "pipe",
4230
+ timeout: 1e4
4231
+ }).trim();
4232
+ } catch (err) {
4233
+ return {
4234
+ kind: "skip",
4235
+ reason: `could not read origin/${branch} to check review continuity: ${gitErrorDetail(err)}`
4236
+ };
4237
+ }
4238
+ try {
4239
+ execFileSync3("git", [...GIT_NO_HOOKS3, "merge-base", "--is-ancestor", reviewedSha, head], { cwd: repoRoot, stdio: "pipe", timeout: 1e4 });
4240
+ return { kind: "ok" };
4241
+ } catch (err) {
4242
+ return {
4243
+ kind: "skip",
4244
+ reason: `the recorded Reviewed-SHA ${reviewedSha} is not contained in origin/${branch} (head ${head}) — the PR: line points at a head this card's review history never built (${gitErrorDetail(err) || "not an ancestor"})`
4245
+ };
4246
+ }
4247
+ }
3869
4248
  function reviewedFromPrUrl(description) {
3870
4249
  if (extractBranchFromDescription(description))
3871
4250
  return null;
@@ -4509,12 +4888,15 @@ var init_ci_repair = __esm(() => {
4509
4888
 
4510
4889
  // src/auto-merge.ts
4511
4890
  import {
4891
+ countBlockingReviewVerdicts,
4512
4892
  extractCiReviewRequestedSha,
4513
4893
  extractReviewedSha,
4514
4894
  getPrStatus,
4515
4895
  isPrOnOrigin as isPrOnOrigin2,
4516
4896
  log as log13,
4517
4897
  mergePullRequest,
4898
+ readHeadCommitTime,
4899
+ readPrFindings,
4518
4900
  requestIndependentReview,
4519
4901
  upsertCiReviewRequestedSha
4520
4902
  } from "@gethmy/harness";
@@ -4563,6 +4945,15 @@ function decideAutoMergeAction(input) {
4563
4945
  case "clean":
4564
4946
  break;
4565
4947
  case "blocking":
4948
+ if (config.findingsLoop) {
4949
+ const rounds = input.blockingVerdictRounds;
4950
+ if (rounds !== undefined && rounds <= config.maxFindingRounds) {
4951
+ return "report-findings";
4952
+ }
4953
+ if (rounds !== undefined && Number.isFinite(rounds)) {
4954
+ return "stamp-findings-exhausted";
4955
+ }
4956
+ }
4566
4957
  return "stamp-review-findings";
4567
4958
  case "pending":
4568
4959
  return "wait";
@@ -4591,6 +4982,9 @@ async function stampCiFailure(client, card) {
4591
4982
  async function stampReviewFindings(client, card) {
4592
4983
  await stampForHumanOnce(client, card, "Independent review posted blocking findings");
4593
4984
  }
4985
+ async function stampFindingsExhausted(client, card, maxRounds) {
4986
+ await stampForHumanOnce(client, card, `Findings loop spent its ${maxRounds} round${maxRounds === 1 ? "" : "s"} and the review still objects`);
4987
+ }
4594
4988
  async function stampReviewIncomplete(client, card) {
4595
4989
  await stampForHumanOnce(client, card, "Independent review did not complete (no verdict)");
4596
4990
  }
@@ -4609,7 +5003,7 @@ async function attemptAutoMerge(deps) {
4609
5003
  const gate = driver === "interactive" ? autoMerge.interactive : autoMerge.enabled;
4610
5004
  if (!gate)
4611
5005
  return "disabled";
4612
- if (driver === "interactive" && !isPrOnOrigin2(prUrl, cwd)) {
5006
+ if (!isPrOnOrigin2(prUrl, cwd)) {
4613
5007
  log13.warn(TAG13, `#${card.short_id} refusing to merge ${prUrl} — it is not a pull request on this checkout's own origin. The card's PR: line is editable board data; nothing was read from that URL.`);
4614
5008
  return "foreign-pr";
4615
5009
  }
@@ -4637,6 +5031,9 @@ async function attemptAutoMerge(deps) {
4637
5031
  enabled: true,
4638
5032
  ...driver === "interactive" && independent.enabled ? { reReviewOnBranchChange: false } : {}
4639
5033
  };
5034
+ const countRounds = driver === "interactive" && autoMerge.findingsLoop && independent.enabled && independent.verdict.enabled && reviewVerdict === "blocking";
5035
+ const blockingVerdictRounds = countRounds ? await countBlockingReviewVerdicts(prUrl, cwd, provider, independent.verdict.checkName) : undefined;
5036
+ const headCommitTime = countRounds ? await readHeadCommitTime(prUrl, cwd, provider) : null;
4640
5037
  const action = decideAutoMergeAction({
4641
5038
  ciStatus,
4642
5039
  headSha,
@@ -4644,7 +5041,8 @@ async function attemptAutoMerge(deps) {
4644
5041
  config: decisionConfig,
4645
5042
  independentReview,
4646
5043
  reviewVerdict,
4647
- reviewRequestedSha
5044
+ reviewRequestedSha,
5045
+ blockingVerdictRounds
4648
5046
  });
4649
5047
  switch (action) {
4650
5048
  case "wait":
@@ -4672,6 +5070,28 @@ async function attemptAutoMerge(deps) {
4672
5070
  log13.info(TAG13, `#${card.short_id} the independent review posted blocking findings ("${independent.verdict.checkName}" is red) — holding the merge and flagging for a human. The findings are on the PR: fix them and push (the new head is reviewed afresh), or merge it yourself once you have judged them.`);
4673
5071
  await stampReviewFindings(client, card);
4674
5072
  return action;
5073
+ case "report-findings": {
5074
+ const report = await readPrFindings(prUrl, cwd, provider, {
5075
+ authors: independent.findingAuthors ?? [],
5076
+ ...headCommitTime ? { since: headCommitTime } : {}
5077
+ });
5078
+ if (!report.readable) {
5079
+ log13.warn(TAG13, `#${card.short_id} the independent review posted blocking findings but none could be read from ${independent.findingAuthors?.join(", ") || "no configured reviewer"} — holding the merge and flagging for a human instead of acting on a partial or empty list.`);
5080
+ await stampReviewFindings(client, card);
5081
+ return "stamp-review-findings";
5082
+ }
5083
+ log13.info(TAG13, `#${card.short_id} the independent review posted blocking findings ("${independent.verdict.checkName}" is red) — reporting ${report.items.length} finding(s) for verification, round ${blockingVerdictRounds} of ${autoMerge.maxFindingRounds}.`);
5084
+ deps.onFindings?.({
5085
+ report,
5086
+ round: blockingVerdictRounds ?? 0,
5087
+ maxRounds: autoMerge.maxFindingRounds
5088
+ });
5089
+ return action;
5090
+ }
5091
+ case "stamp-findings-exhausted":
5092
+ log13.info(TAG13, `#${card.short_id} the findings loop spent its ${autoMerge.maxFindingRounds} round(s) and "${independent.verdict.checkName}" is still red — holding the merge for a person. The findings were read and acted on; what ran out is the round budget, so pushing another fix will park again.`);
5093
+ await stampFindingsExhausted(client, card, autoMerge.maxFindingRounds);
5094
+ return action;
4675
5095
  case "stamp-review-incomplete":
4676
5096
  log13.info(TAG13, `#${card.short_id} the independent review did not complete ("${independent.checkName}" is red, no verdict) — holding the merge and flagging for a human. Re-request it with a remove-then-add of "${independent.label}"; the per-head ledger stops this daemon buying a second pass itself.`);
4677
5097
  await stampReviewIncomplete(client, card);
@@ -6169,7 +6589,7 @@ var init_completion = __esm(() => {
6169
6589
 
6170
6590
  // src/progress-tracker.ts
6171
6591
  import { log as log21 } from "@gethmy/harness";
6172
- function truncate(str, max) {
6592
+ function truncate2(str, max) {
6173
6593
  return str.length > max ? `${str.slice(0, max - 3)}...` : str;
6174
6594
  }
6175
6595
 
@@ -6354,7 +6774,7 @@ class ProgressTracker {
6354
6774
  const firstLine = (end === -1 ? trimmed : trimmed.slice(0, end)).trim();
6355
6775
  if (firstLine.length >= 10 && firstLine.length <= 200) {
6356
6776
  if (ACTION_PREFIX.test(firstLine)) {
6357
- this.lastAction = truncate(firstLine, MAX_TASK_LENGTH);
6777
+ this.lastAction = truncate2(firstLine, MAX_TASK_LENGTH);
6358
6778
  }
6359
6779
  }
6360
6780
  }
@@ -6413,16 +6833,16 @@ class ProgressTracker {
6413
6833
  }
6414
6834
  case "Grep": {
6415
6835
  const pattern = this.extractString(input, "pattern");
6416
- return pattern ? `Searching for "${truncate(pattern, 40)}"` : "Searching code";
6836
+ return pattern ? `Searching for "${truncate2(pattern, 40)}"` : "Searching code";
6417
6837
  }
6418
6838
  case "Bash": {
6419
6839
  const cmd = this.extractString(input, "command");
6420
- return cmd ? `Running: ${truncate(cmd.split(`
6840
+ return cmd ? `Running: ${truncate2(cmd.split(`
6421
6841
  `)[0], 80)}` : "Running command";
6422
6842
  }
6423
6843
  case "Agent": {
6424
6844
  const desc = this.extractString(input, "description");
6425
- return desc ? `Sub-agent: ${truncate(desc, 60)}` : "Delegating to sub-agent";
6845
+ return desc ? `Sub-agent: ${truncate2(desc, 60)}` : "Delegating to sub-agent";
6426
6846
  }
6427
6847
  default: {
6428
6848
  if (name.startsWith("mcp__harmony__harmony_")) {
@@ -6471,7 +6891,7 @@ class ProgressTracker {
6471
6891
  agentIdentifier: this.sessionIdentifier,
6472
6892
  agentName: AGENT_NAME,
6473
6893
  status: "working",
6474
- currentTask: truncate(currentTask, MAX_TASK_LENGTH),
6894
+ currentTask: truncate2(currentTask, MAX_TASK_LENGTH),
6475
6895
  progressPercent: this.progress,
6476
6896
  phase: this.phase,
6477
6897
  filesChanged: this.filesEdited.size,
@@ -6489,7 +6909,7 @@ class ProgressTracker {
6489
6909
  this.lastEmittedProgress = this.progress;
6490
6910
  this.runEventSink.recordProgress({
6491
6911
  progressPercent: this.progress,
6492
- currentTask: truncate(currentTask, MAX_TASK_LENGTH),
6912
+ currentTask: truncate2(currentTask, MAX_TASK_LENGTH),
6493
6913
  phase: this.phase,
6494
6914
  filesChanged: this.filesEdited.size
6495
6915
  });
@@ -6502,7 +6922,7 @@ class ProgressTracker {
6502
6922
  this.heartbeatTimer = setTimeout(() => {
6503
6923
  if (!this.stopped) {
6504
6924
  const task = this.lastAction ? `Still working — ${this.lastAction}` : "Still working...";
6505
- this.sendUpdate(truncate(task, MAX_TASK_LENGTH));
6925
+ this.sendUpdate(truncate2(task, MAX_TASK_LENGTH));
6506
6926
  this.startHeartbeat();
6507
6927
  }
6508
6928
  }, HEARTBEAT_MS);
@@ -7802,6 +8222,7 @@ class StateStore {
7802
8222
  const rec = this.ensureCard(cardId);
7803
8223
  rec.attempts += 1;
7804
8224
  rec.totalAttempts = (rec.totalAttempts ?? 0) + 1;
8225
+ rec.runStarts = (rec.runStarts ?? 0) + 1;
7805
8226
  rec.lastAttemptAt = Date.now();
7806
8227
  await this.persist();
7807
8228
  return rec.attempts;
@@ -7888,7 +8309,7 @@ class StateStore {
7888
8309
  const rec = this.getCard(cardId);
7889
8310
  if (!rec)
7890
8311
  return;
7891
- if (rec.attempts === 0 && !rec.ciRepairAttempts && rec.ciRepairParkedAt == null && (!clearTurns || !rec.totalTurns)) {
8312
+ if (rec.attempts === 0 && !rec.ciRepairAttempts && rec.ciRepairParkedAt == null && (!clearTurns || !rec.totalTurns && !rec.runStarts)) {
7892
8313
  return;
7893
8314
  }
7894
8315
  rec.attempts = 0;
@@ -7896,6 +8317,8 @@ class StateStore {
7896
8317
  rec.ciRepairParkedAt = null;
7897
8318
  if (clearTurns)
7898
8319
  rec.totalTurns = 0;
8320
+ if (clearTurns)
8321
+ rec.runStarts = 0;
7899
8322
  await this.persist();
7900
8323
  }
7901
8324
  getCiRepairAttempts(cardId) {
@@ -7968,6 +8391,9 @@ class StateStore {
7968
8391
  getCardTurns(cardId) {
7969
8392
  return this.getCard(cardId)?.totalTurns ?? 0;
7970
8393
  }
8394
+ getCardRunStarts(cardId) {
8395
+ return this.getCard(cardId)?.runStarts ?? 0;
8396
+ }
7971
8397
  getCardCostCents(cardId) {
7972
8398
  return this.getCard(cardId)?.totalCostCents ?? 0;
7973
8399
  }
@@ -8313,6 +8739,7 @@ import {
8313
8739
  collectGateEvidence,
8314
8740
  DevServerReadinessError,
8315
8741
  devServerLaunch,
8742
+ extractReviewedSha as extractReviewedSha2,
8316
8743
  formatDiffSummary,
8317
8744
  GIT_NO_HOOKS as GIT_NO_HOOKS7,
8318
8745
  implementRunContainmentCliArgs,
@@ -8528,6 +8955,18 @@ class ReviewWorker {
8528
8955
  await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
8529
8956
  return;
8530
8957
  }
8958
+ if (this.config.review.autoMerge.enabled && reviewedFromPrUrl(card.description)) {
8959
+ const continuity = assessPrReviewContinuity({
8960
+ repoRoot,
8961
+ branch: resolution.branch,
8962
+ reviewedSha: extractReviewedSha2(card.description ?? null)
8963
+ });
8964
+ if (continuity.kind === "skip") {
8965
+ log28.warn(this.tag, `#${card.short_id} refusing the PR-derived re-review (${continuity.reason}) — marking for human review (staying in Review)`);
8966
+ await addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR);
8967
+ return;
8968
+ }
8969
+ }
8531
8970
  this.branchName = resolution.branch;
8532
8971
  log28.info(this.tag, `Review branch: ${this.branchName}`);
8533
8972
  let reviewSession;
@@ -9291,7 +9730,7 @@ var init_unblock = () => {};
9291
9730
  // src/cli-agent-runner.ts
9292
9731
  import { log as log31 } from "@gethmy/harness";
9293
9732
  function truncateOutput(value) {
9294
- return value === undefined ? undefined : value.slice(0, MAX_OUTPUT_LEN);
9733
+ return value === undefined ? undefined : value.slice(0, MAX_OUTPUT_CHARS);
9295
9734
  }
9296
9735
 
9297
9736
  class CliAgentRunner {
@@ -9302,6 +9741,10 @@ class CliAgentRunner {
9302
9741
  flushTimer = null;
9303
9742
  flushing = false;
9304
9743
  stopped = false;
9744
+ batchAttempts = 0;
9745
+ nextBatchSize = MAX_BUFFER;
9746
+ withheldToolUses = new Map;
9747
+ pendingWithholdWithoutId;
9305
9748
  constructor(client, cardId, sessionId) {
9306
9749
  this.client = client;
9307
9750
  this.cardId = cardId;
@@ -9311,21 +9754,43 @@ class CliAgentRunner {
9311
9754
  if (this.stopped)
9312
9755
  return;
9313
9756
  parser.on("tool_start", (name, input, toolUseId) => {
9757
+ const redacted = redactToolCall({ input });
9758
+ if (redacted.withheld) {
9759
+ if (toolUseId) {
9760
+ this.withheldToolUses.set(toolUseId, redacted.withheld);
9761
+ } else {
9762
+ this.pendingWithholdWithoutId = redacted.withheld;
9763
+ }
9764
+ }
9314
9765
  this.enqueue({
9315
9766
  kind: "tool_started",
9316
9767
  source: "agent",
9317
- payload: { toolName: name, toolUseId, input }
9768
+ payload: redacted.withheld ? { toolName: name, toolUseId, withheld: redacted.withheld } : { toolName: name, toolUseId, input: redacted.input }
9318
9769
  });
9319
9770
  });
9320
9771
  parser.on("tool_end", (name, toolUseId, content) => {
9772
+ const withheld = (toolUseId ? this.withheldToolUses.get(toolUseId) : undefined) ?? this.pendingWithholdWithoutId;
9773
+ if (withheld) {
9774
+ if (toolUseId)
9775
+ this.withheldToolUses.delete(toolUseId);
9776
+ this.pendingWithholdWithoutId = undefined;
9777
+ this.enqueue({
9778
+ kind: "tool_ended",
9779
+ source: "agent",
9780
+ payload: {
9781
+ toolName: name,
9782
+ toolUseId,
9783
+ output: `[withheld: ${withheld}]`,
9784
+ withheld
9785
+ }
9786
+ });
9787
+ return;
9788
+ }
9789
+ const redacted = redactToolCall({ output: content });
9321
9790
  this.enqueue({
9322
9791
  kind: "tool_ended",
9323
9792
  source: "agent",
9324
- payload: {
9325
- toolName: name,
9326
- toolUseId,
9327
- output: content === undefined ? undefined : content.slice(0, MAX_OUTPUT_LEN)
9328
- }
9793
+ payload: { toolName: name, toolUseId, output: redacted.output }
9329
9794
  });
9330
9795
  });
9331
9796
  parser.on("text", (content) => {
@@ -9426,6 +9891,10 @@ class CliAgentRunner {
9426
9891
  enqueue(body) {
9427
9892
  if (this.stopped)
9428
9893
  return;
9894
+ if (runEventPayloadExceedsLimit(body.payload)) {
9895
+ log31.warn(TAG28, `Dropping oversized ${body.kind} event: payload exceeds ${MAX_RUN_EVENT_PAYLOAD_BYTES} bytes`);
9896
+ return;
9897
+ }
9429
9898
  this.buffer.push({ ...body, createdAt: new Date().toISOString() });
9430
9899
  if (this.buffer.length >= MAX_BUFFER)
9431
9900
  this.flush();
@@ -9440,27 +9909,49 @@ class CliAgentRunner {
9440
9909
  if (this.flushing || this.buffer.length === 0)
9441
9910
  return;
9442
9911
  this.flushing = true;
9443
- const batch = this.buffer.splice(0, MAX_BUFFER);
9912
+ const batch = this.buffer.splice(0, Math.min(this.nextBatchSize, MAX_BUFFER));
9444
9913
  try {
9445
9914
  await this.client.appendAgentRunEvents(this.cardId, {
9446
9915
  sessionId: this.sessionId,
9447
9916
  events: batch
9448
9917
  });
9918
+ this.batchAttempts = 0;
9919
+ this.nextBatchSize = MAX_BUFFER;
9449
9920
  } catch (err) {
9450
- log31.warn(TAG28, `Failed to flush run events: ${err}`);
9451
- this.buffer.unshift(...batch);
9452
- if (this.buffer.length > MAX_BUFFER) {
9453
- this.buffer.length = MAX_BUFFER;
9921
+ this.batchAttempts++;
9922
+ if (this.batchAttempts >= MAX_BATCH_ATTEMPTS) {
9923
+ this.batchAttempts = 0;
9924
+ this.requeue(batch);
9925
+ if (batch.length > 1) {
9926
+ this.nextBatchSize = Math.ceil(batch.length / 2);
9927
+ log31.warn(TAG28, `Flush of ${batch.length} run event(s) failed ${MAX_BATCH_ATTEMPTS}×; bisecting to ${this.nextBatchSize}: ${err}`);
9928
+ return;
9929
+ }
9930
+ const dropped = this.buffer.shift();
9931
+ this.nextBatchSize = MAX_BUFFER;
9932
+ log31.warn(TAG28, `Dropping one ${dropped?.kind} event the API refused ${MAX_BATCH_ATTEMPTS}×: ${err}`);
9933
+ return;
9454
9934
  }
9935
+ log31.warn(TAG28, `Failed to flush run events (attempt ${this.batchAttempts}/${MAX_BATCH_ATTEMPTS}): ${err}`);
9936
+ this.requeue(batch);
9455
9937
  } finally {
9456
9938
  this.flushing = false;
9457
9939
  }
9458
9940
  }
9941
+ requeue(batch) {
9942
+ this.buffer.unshift(...batch);
9943
+ if (this.buffer.length > MAX_BUFFER) {
9944
+ this.buffer.length = MAX_BUFFER;
9945
+ }
9946
+ }
9459
9947
  async flushFinal() {
9460
- for (let i = 0;this.buffer.length > 0 && i < 5; i++) {
9948
+ for (let i = 0;this.buffer.length > 0 && i < FINAL_FLUSH_ATTEMPTS; i++) {
9461
9949
  const before = this.buffer.length;
9950
+ const beforeBatchSize = this.nextBatchSize;
9951
+ const beforeAttempts = this.batchAttempts;
9462
9952
  await this.flush();
9463
- if (this.buffer.length >= before)
9953
+ const inert = this.buffer.length >= before && this.nextBatchSize >= beforeBatchSize && this.batchAttempts <= beforeAttempts;
9954
+ if (inert)
9464
9955
  break;
9465
9956
  }
9466
9957
  }
@@ -9484,8 +9975,10 @@ function mapCost(cost) {
9484
9975
  durationMs: cost.durationMs
9485
9976
  };
9486
9977
  }
9487
- var TAG28 = "cli-agent-runner", FLUSH_INTERVAL_MS = 2000, MAX_BUFFER = 1000, MAX_TEXT_LEN = 8000, MAX_OUTPUT_LEN = 4000;
9488
- var init_cli_agent_runner = () => {};
9978
+ var TAG28 = "cli-agent-runner", FLUSH_INTERVAL_MS = 2000, MAX_BUFFER = 1000, MAX_TEXT_LEN = 8000, MAX_BATCH_ATTEMPTS = 3, FINAL_FLUSH_ATTEMPTS = 40;
9979
+ var init_cli_agent_runner = __esm(() => {
9980
+ init_dist();
9981
+ });
9489
9982
 
9490
9983
  // src/fanout.ts
9491
9984
  import { log as log32 } from "@gethmy/harness";
@@ -10684,6 +11177,19 @@ class Worker {
10684
11177
  log34.info(this.tag, resuming ? `Resuming #${card.short_id} "${card.title}" with ${this.grantedTurns ?? "the default"} more turns` : `Preparing #${card.short_id} "${card.title}"`);
10685
11178
  const attemptCount = await this.stateStore.incrementAttempt(card.id);
10686
11179
  const isRework = attemptCount > 1;
11180
+ const runStarts = this.stateStore.getCardRunStarts(card.id);
11181
+ const runCap = this.config.budget.maxRunsPerCard;
11182
+ if (crossedTurnCap(runStarts - 1, runStarts, runCap)) {
11183
+ log34.warn(this.tag, `#${card.short_id} reached its per-card run backstop — ${runStarts} of ${runCap} run starts. It will not be picked up again until it is reassigned or the cap is raised.`);
11184
+ try {
11185
+ await this.client.addComment(card.id, buildRunCapComment(runStarts, runCap), {
11186
+ commentType: "blocker",
11187
+ agentSessionId: this.sessionId ?? undefined
11188
+ });
11189
+ } catch (err) {
11190
+ log34.warn(this.tag, `Could not post the run-backstop notice for ${card.id}: ${err instanceof Error ? err.message : err}`);
11191
+ }
11192
+ }
10687
11193
  const recordedBranch = recordedBranchForCard(card.description, card.short_id, [
10688
11194
  this.config.worktree.failedBranchPrefix,
10689
11195
  this.config.worktree.approvedBranchPrefix
@@ -10935,7 +11441,8 @@ class Worker {
10935
11441
  }
10936
11442
  const msg = err instanceof Error ? err.message : String(err);
10937
11443
  const rawStderr = err?.stderr;
10938
- const errClass = classifyRunError2(typeof rawStderr === "string" && rawStderr ? rawStderr : msg);
11444
+ const errClass = classifyRunError2(typeof rawStderr === "string" && rawStderr ? msg + `
11445
+ ` + rawStderr : msg);
10939
11446
  const sdkKind = err?.errorKind;
10940
11447
  if (errClass.kind === null && sdkKind != null)
10941
11448
  errClass.kind = sdkKind;
@@ -10943,11 +11450,6 @@ class Worker {
10943
11450
  try {
10944
11451
  this.onApiError?.(errClass);
10945
11452
  } catch {}
10946
- try {
10947
- await this.stateStore.decrementAttempt(card.id);
10948
- } catch (dErr) {
10949
- log34.warn(this.tag, `Failed to roll back the attempt: ${dErr}`);
10950
- }
10951
11453
  await this.parkForDecision(card, "account_limit");
10952
11454
  return;
10953
11455
  }
@@ -12683,6 +13185,10 @@ class Pool {
12683
13185
  }
12684
13186
  }
12685
13187
  async enqueue(card, column, labels, subtasks, mode = "implement") {
13188
+ if (card.done || card.archived_at) {
13189
+ log35.debug(TAG32, `#${card.short_id} skipped — ${card.done ? "done" : "archived"} (finished work is never enqueued, #1102)`);
13190
+ return;
13191
+ }
12686
13192
  if (this.isCardKnown(card.id) || this.reservations.has(card.id)) {
12687
13193
  log35.debug(TAG32, `Card ${card.id} already queued, active, or reserved, skipping`);
12688
13194
  return;
@@ -13080,9 +13586,23 @@ I am handing the card back rather than holding it parked. Reassign it — that c
13080
13586
  await this.stopRun(run, "Continue refused: the card is at its per-card turn cap.");
13081
13587
  return;
13082
13588
  }
13083
- if (run.pauseTrigger !== "account_limit") {
13084
- await this.stateStore.decrementAttempt(run.cardId);
13589
+ const runsSpent = this.budget.checkCardRuns(run.cardId);
13590
+ if (!runsSpent.allow) {
13591
+ log35.warn(TAG32, `Refusing a Continue on ${run.cardId} — ${runsSpent.detail}. Handing the card back: reassign it to clear the count, or raise budget.maxRunsPerCard.`);
13592
+ try {
13593
+ await this.client.addComment(run.cardId, `**That Continue cannot be granted.** ${runsSpent.detail}, which is this card's run backstop (\`budget.maxRunsPerCard\`), so the implement pipeline would refuse it again the moment it was dispatched.
13594
+
13595
+ I am handing the card back rather than holding it parked. Reassign it — that clears the count and starts it fresh — or raise the cap in the daemon config if this card genuinely needs the room. The branch is pushed, so nothing committed is lost.`, {
13596
+ commentType: "blocker",
13597
+ agentSessionId: run.sessionId ?? undefined
13598
+ });
13599
+ } catch (err) {
13600
+ log35.warn(TAG32, `Could not explain the refused Continue on ${run.cardId}: ${err instanceof Error ? err.message : err}`);
13601
+ }
13602
+ await this.stopRun(run, "Continue refused: the card is at its per-card run backstop.");
13603
+ return;
13085
13604
  }
13605
+ await this.stateStore.decrementAttempt(run.cardId);
13086
13606
  const grantedTurns = opts.extraTurns > 0 ? Math.min(opts.extraTurns, MAX_GRANTED_TURNS) : this.config.budget.pause.extraTurns ?? this.defaultTurnsFor(run.pipeline);
13087
13607
  const canSpendConsumption = run.pipeline === "implement" && this.config.runner === "sdk";
13088
13608
  const grantedBudgetUsd = !canSpendConsumption ? null : opts.extraBudgetUsd != null && opts.extraBudgetUsd > 0 ? Math.min(opts.extraBudgetUsd, MAX_GRANTED_BUDGET_USD) : this.config.budget.pause.extraBudgetUsd ?? this.config.sdk?.maxBudgetUsd ?? null;
@@ -13126,6 +13646,7 @@ I am handing the card back rather than holding it parked. Reassign it — that c
13126
13646
  daemonPid: process.pid,
13127
13647
  lastHeartbeatAt: Date.now()
13128
13648
  });
13649
+ await this.stateStore.decrementAttempt(run.cardId);
13129
13650
  try {
13130
13651
  await this.client.updateAgentProgress(run.cardId, {
13131
13652
  ...this.sessionIdentityFor(run),
@@ -13214,6 +13735,20 @@ Reassign the card — that clears the turn count and starts it fresh — or rais
13214
13735
  await this.stopAttemptCap(cardId, "Attempt budget cleared, but the card is at its per-card turn cap.");
13215
13736
  return;
13216
13737
  }
13738
+ const runsSpent = this.budget.checkCardRuns(cardId);
13739
+ if (!runsSpent.allow) {
13740
+ log35.warn(TAG32, `Attempt grant on ${cardId} cannot start a run — ${runsSpent.detail}. Clearing the attempt budget anyway; reassign the card to clear the run count, or raise budget.maxRunsPerCard.`);
13741
+ try {
13742
+ await this.client.addComment(cardId, `**Attempt budget cleared, but this card is at its run backstop.** ${runsSpent.detail}, which is its ceiling (\`budget.maxRunsPerCard\`), so I will not pick it up even with attempts restored.
13743
+
13744
+ Reassign the card — that clears the run count and starts it fresh — or raise the cap in the daemon config.`, { commentType: "blocker" });
13745
+ } catch (err) {
13746
+ log35.warn(TAG32, `Could not explain the capped attempt grant on ${cardId}: ${err instanceof Error ? err.message : err}`);
13747
+ }
13748
+ await this.stateStore.resetAttempts(cardId, { clearTurns: false });
13749
+ await this.stopAttemptCap(cardId, "Attempt budget cleared, but the card is at its per-card run backstop.");
13750
+ return;
13751
+ }
13217
13752
  const identity = this.attemptCapIdentityFor(cardId);
13218
13753
  await this.stateStore.resetAttempts(cardId, { clearTurns: false });
13219
13754
  await this.stateStore.clearAwaitingDecision(cardId);
@@ -13274,6 +13809,10 @@ Reassign the card — that clears the turn count and starts it fresh — or rais
13274
13809
  await this.retireUnroutableRuns(card.short_id, cardId);
13275
13810
  return;
13276
13811
  }
13812
+ if (card.done || card.archived_at) {
13813
+ await this.retireUnroutableRuns(card.short_id, cardId, `card is ${card.done ? "done" : "archived"} — finished work is never enqueued (#1102)`);
13814
+ return;
13815
+ }
13277
13816
  const labelMap = buildLabelMap(board.labels ?? []);
13278
13817
  const cardLabels = resolveCardLabels(card, labelMap);
13279
13818
  const subtasks = card.subtasks ?? [];
@@ -13282,12 +13821,12 @@ Reassign the card — that clears the turn count and starts it fresh — or rais
13282
13821
  log35.error(TAG32, `Failed to re-enqueue ${cardId} after a budget decision: ${err}`);
13283
13822
  }
13284
13823
  }
13285
- async retireUnroutableRuns(shortId, cardId) {
13824
+ async retireUnroutableRuns(shortId, cardId, reason = "column not found on this board") {
13286
13825
  const unended = this.stateStore.getRunsForCard(cardId).filter((r) => r.endedAt === null);
13287
- log35.warn(TAG32, `#${shortId}: column not found on this board — retiring ${unended.length} run record(s) as foreign/unroutable instead of re-enqueueing`);
13826
+ log35.warn(TAG32, `#${shortId}: ${reason} — retiring ${unended.length} run record(s) instead of re-enqueueing`);
13288
13827
  for (const run of unended) {
13289
13828
  await this.stateStore.endRun(run.runId, "orphaned", {
13290
- errorMessage: "column not found on this daemon's board at re-enqueue — retired as foreign or unroutable (#1057)"
13829
+ errorMessage: `${reason} at re-enqueue — retired as unroutable (#1057/#1102)`
13291
13830
  });
13292
13831
  }
13293
13832
  }
@@ -14569,7 +15108,7 @@ class Reconciler {
14569
15108
  }
14570
15109
  };
14571
15110
  const assignedCards = cards.filter((c) => {
14572
- if (c.assigned_agent_id !== this.agentId || c.archived_at)
15111
+ if (c.assigned_agent_id !== this.agentId || c.archived_at || c.done)
14573
15112
  return false;
14574
15113
  const col = columnMap.get(c.column_id);
14575
15114
  return col ? classifyPickup(c, col.name, pickupConfig) !== null : false;
@@ -14878,7 +15417,8 @@ function mergeCardRecords(target, legacy) {
14878
15417
  attempts: Math.max(target.attempts ?? 0, legacy.attempts ?? 0),
14879
15418
  totalAttempts: Math.max(target.totalAttempts ?? 0, legacy.totalAttempts ?? 0),
14880
15419
  totalCostCents: Math.max(target.totalCostCents ?? 0, legacy.totalCostCents ?? 0),
14881
- totalTurns: Math.max(target.totalTurns ?? 0, legacy.totalTurns ?? 0)
15420
+ totalTurns: Math.max(target.totalTurns ?? 0, legacy.totalTurns ?? 0),
15421
+ runStarts: Math.max(target.runStarts ?? 0, legacy.runStarts ?? 0)
14882
15422
  };
14883
15423
  }
14884
15424
  function mergeDaily(target, legacy) {
@@ -15729,6 +16269,7 @@ var init_worktree_gc = __esm(() => {
15729
16269
  var exports_src = {};
15730
16270
  __export(exports_src, {
15731
16271
  validatePrerequisites: () => validatePrerequisites,
16272
+ tryEnqueueCard: () => tryEnqueueCard,
15732
16273
  main: () => main
15733
16274
  });
15734
16275
  import { execFileSync as execFileSync9 } from "node:child_process";
@@ -16111,6 +16652,10 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
16111
16652
  log48.debug(TAG45, `Card ${cardId} no longer assigned to agent — skipping`);
16112
16653
  return;
16113
16654
  }
16655
+ if (card.done || card.archived_at) {
16656
+ log48.debug(TAG45, `Card ${cardId} is finished — skipping`);
16657
+ return;
16658
+ }
16114
16659
  const board = await client.getBoard(config.projectId, { summary: true });
16115
16660
  const columns = board.columns;
16116
16661
  const column = columns.find((c) => c.id === card.column_id);
@@ -16182,5 +16727,6 @@ init_src();
16182
16727
 
16183
16728
  export {
16184
16729
  validatePrerequisites,
16730
+ tryEnqueueCard,
16185
16731
  main
16186
16732
  };