@gethmy/agent 1.10.1 → 1.10.3

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 +1 -1
  2. package/dist/cli.js +392 -189
  3. package/dist/index.js +388 -187
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -396,8 +396,12 @@ var init_types = __esm(() => {
396
396
  },
397
397
  claude: {
398
398
  model: "opus",
399
+ escalateModel: "claude-fable-5",
400
+ escalateAfterAttempts: 2,
399
401
  reviewModel: "sonnet",
400
- maxTurns: 200,
402
+ maxTurns: 80,
403
+ reviewMaxTurns: 60,
404
+ leanSettingSources: "local,user",
401
405
  additionalArgs: []
402
406
  },
403
407
  worktree: {
@@ -2006,6 +2010,54 @@ function parseNumstat(raw, maxFiles = MAX_CHANGED_FILES2) {
2006
2010
  }
2007
2011
  return { files, insertions, deletions };
2008
2012
  }
2013
+ function summarizeUnifiedDiff(diff) {
2014
+ const files = [];
2015
+ let current = null;
2016
+ let totalAdded = 0;
2017
+ let totalRemoved = 0;
2018
+ for (const line of diff.split(`
2019
+ `)) {
2020
+ if (line.startsWith("diff --git")) {
2021
+ const m = line.match(/ b\/(.+)$/);
2022
+ current = {
2023
+ path: m ? m[1] : line.slice("diff --git ".length),
2024
+ added: 0,
2025
+ removed: 0
2026
+ };
2027
+ files.push(current);
2028
+ continue;
2029
+ }
2030
+ if (!current)
2031
+ continue;
2032
+ if (line.startsWith("+++") || line.startsWith("---"))
2033
+ continue;
2034
+ if (line.startsWith("+")) {
2035
+ current.added++;
2036
+ totalAdded++;
2037
+ } else if (line.startsWith("-")) {
2038
+ current.removed++;
2039
+ totalRemoved++;
2040
+ }
2041
+ }
2042
+ return { files, totalAdded, totalRemoved };
2043
+ }
2044
+ function formatDiffSummary(diff, maxFiles = 100) {
2045
+ const trimmed = diff.trim();
2046
+ if (!trimmed || diff === "(unable to retrieve diff)") {
2047
+ return trimmed ? diff : "(no diff available)";
2048
+ }
2049
+ const { files, totalAdded, totalRemoved } = summarizeUnifiedDiff(diff);
2050
+ if (files.length === 0)
2051
+ return "(no file changes detected in diff)";
2052
+ const shown = files.slice(0, maxFiles);
2053
+ const lines = shown.map((f) => ` ${f.path} | +${f.added} -${f.removed}`);
2054
+ if (files.length > maxFiles) {
2055
+ lines.push(` ... and ${files.length - maxFiles} more file(s)`);
2056
+ }
2057
+ lines.push(` ${files.length} file(s) changed, ${totalAdded} insertion(s)(+), ${totalRemoved} deletion(s)(-)`);
2058
+ return lines.join(`
2059
+ `);
2060
+ }
2009
2061
  function captureDiffStat(worktreePath, baseBranch, maxFiles = MAX_CHANGED_FILES2) {
2010
2062
  try {
2011
2063
  const raw = execFileSync5("git", ["diff", "--numstat", `${baseBranch}...HEAD`], { cwd: worktreePath, encoding: "utf-8", timeout: 30000 });
@@ -2122,7 +2174,17 @@ async function runDeepReview(worktreePath, config, workerId) {
2122
2174
  "```"
2123
2175
  ].join(`
2124
2176
  `);
2125
- const output = execFileSync6("claude", ["--print", "--model", "sonnet", "--max-turns", "10", "--", reviewPrompt], {
2177
+ const leanSources = config.claude.leanSettingSources;
2178
+ const output = execFileSync6("claude", [
2179
+ "--print",
2180
+ "--model",
2181
+ "sonnet",
2182
+ "--max-turns",
2183
+ "10",
2184
+ ...leanSources ? ["--setting-sources", leanSources] : [],
2185
+ "--",
2186
+ reviewPrompt
2187
+ ], {
2126
2188
  cwd: worktreePath,
2127
2189
  encoding: "utf-8",
2128
2190
  timeout: config.verification.timeout,
@@ -2153,6 +2215,7 @@ function attemptAutoFix(worktreePath, config, errors) {
2153
2215
  "```"
2154
2216
  ].join(`
2155
2217
  `);
2218
+ const leanSources = config.claude.leanSettingSources;
2156
2219
  const args = [
2157
2220
  "--print",
2158
2221
  "--model",
@@ -2161,6 +2224,7 @@ function attemptAutoFix(worktreePath, config, errors) {
2161
2224
  "50",
2162
2225
  "--allowedTools",
2163
2226
  "Bash,Read,Write,Edit,Glob,Grep",
2227
+ ...leanSources ? ["--setting-sources", leanSources] : [],
2164
2228
  "--",
2165
2229
  fixPrompt
2166
2230
  ];
@@ -2317,7 +2381,8 @@ function buildTokenPayload(stats) {
2317
2381
  outputTokens: stats.cost.totalOutputTokens,
2318
2382
  cacheCreationInputTokens: stats.cost.totalCacheCreationInputTokens,
2319
2383
  cacheReadInputTokens: stats.cost.totalCacheReadInputTokens,
2320
- modelName: stats.cost.modelName
2384
+ modelName: stats.cost.modelName,
2385
+ numTurns: stats.cost.numTurns
2321
2386
  };
2322
2387
  }
2323
2388
  async function runCompletion(client, card, branchName, worktreePath, config, workerId, sessionStats, workspaceId, agentSessionId, stateStore, onMovedToCompletion) {
@@ -2903,7 +2968,8 @@ class ProgressTracker {
2903
2968
  outputTokens: this.lastCost?.totalOutputTokens ?? 0,
2904
2969
  cacheCreationInputTokens: this.lastCost?.totalCacheCreationInputTokens ?? 0,
2905
2970
  cacheReadInputTokens: this.lastCost?.totalCacheReadInputTokens ?? 0,
2906
- modelName: this.lastCost?.modelName
2971
+ modelName: this.lastCost?.modelName,
2972
+ numTurns: this.lastCost?.numTurns ?? 0
2907
2973
  }).catch((err) => {
2908
2974
  log.warn(TAG14, `Failed to send progress update: ${err}`);
2909
2975
  });
@@ -3365,7 +3431,120 @@ var init_review_completion = __esm(() => {
3365
3431
  init_worktree();
3366
3432
  });
3367
3433
 
3368
- // src/review-knowledge.ts
3434
+ // ../harmony-shared/dist/cardLinks.js
3435
+ var init_cardLinks = () => {};
3436
+ // ../harmony-shared/dist/commentSerializer.js
3437
+ function sanitizeHeaderField(value) {
3438
+ return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
3439
+ }
3440
+ function authorLabel(c) {
3441
+ if (c.author_type === "agent")
3442
+ return "AI agent";
3443
+ const raw = c.author?.full_name || "teammate";
3444
+ return sanitizeHeaderField(raw);
3445
+ }
3446
+ function criticalIds(comments) {
3447
+ const keep = new Set;
3448
+ for (const c of comments) {
3449
+ if (c.comment_type === "decision")
3450
+ keep.add(c.id);
3451
+ if (c.supersedes_id) {
3452
+ keep.add(c.id);
3453
+ keep.add(c.supersedes_id);
3454
+ }
3455
+ if (c.confirms_id) {
3456
+ keep.add(c.id);
3457
+ keep.add(c.confirms_id);
3458
+ }
3459
+ }
3460
+ return keep;
3461
+ }
3462
+ function serializeCommentThread(comments, options = {}) {
3463
+ const { heading = "Conversation", includeInstructions = true, activity = [], maxComments } = options;
3464
+ const visible = comments.filter((c) => !c.deleted_at).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
3465
+ if (visible.length === 0)
3466
+ return "";
3467
+ const indexById = new Map;
3468
+ visible.forEach((c, i) => {
3469
+ indexById.set(c.id, i + 1);
3470
+ });
3471
+ let rendered = visible;
3472
+ let elidedCount = 0;
3473
+ if (maxComments && visible.length > maxComments) {
3474
+ const keep = criticalIds(visible);
3475
+ const recentThreshold = visible.length - maxComments;
3476
+ rendered = visible.filter((c, i) => i >= recentThreshold || keep.has(c.id));
3477
+ elidedCount = visible.length - rendered.length;
3478
+ }
3479
+ const ref = (id) => {
3480
+ const n = indexById.get(id);
3481
+ return n ? `#${n}` : `#${id.slice(0, 8)}`;
3482
+ };
3483
+ const lines = [];
3484
+ if (elidedCount > 0) {
3485
+ lines.push({
3486
+ at: visible[0]?.created_at ?? "",
3487
+ text: `(${elidedCount} earlier comment(s) omitted for brevity)`
3488
+ });
3489
+ }
3490
+ for (const c of rendered) {
3491
+ const tags = [];
3492
+ if (c.edited_at)
3493
+ tags.push("edited");
3494
+ if (c.supersedes_id)
3495
+ tags.push(`supersedes ${ref(c.supersedes_id)}`);
3496
+ if (c.confirms_id)
3497
+ tags.push(`confirms ${ref(c.confirms_id)}`);
3498
+ if (c.resolved_at)
3499
+ tags.push("resolved");
3500
+ const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
3501
+ const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
3502
+ const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
3503
+ lines.push({
3504
+ at: c.created_at,
3505
+ text: `${header}
3506
+ <comment-body>
3507
+ ${fencedBody}
3508
+ </comment-body>`
3509
+ });
3510
+ }
3511
+ for (const a of activity) {
3512
+ const actor = a.actor ? `${a.actor} ` : "";
3513
+ lines.push({ at: a.at, text: `· (system) ${a.at} — ${actor}${a.text}` });
3514
+ }
3515
+ lines.sort((a, b) => a.at.localeCompare(b.at));
3516
+ const body = lines.map((l) => l.text).join(`
3517
+
3518
+ `);
3519
+ const instruction = includeInstructions ? `
3520
+
3521
+ ${CONFLICT_INSTRUCTION}` : "";
3522
+ return `## ${heading} (oldest → newest)
3523
+
3524
+ ${body}${instruction}`;
3525
+ }
3526
+ var CONFLICT_INSTRUCTION;
3527
+ var init_commentSerializer = __esm(() => {
3528
+ CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
3529
+ });
3530
+
3531
+ // ../harmony-shared/dist/constants.js
3532
+ var TIMINGS;
3533
+ var init_constants = __esm(() => {
3534
+ TIMINGS = {
3535
+ SEARCH_DEBOUNCE: 300,
3536
+ AUTOSAVE_DEBOUNCE: 1000,
3537
+ TOAST_DURATION: 3000,
3538
+ QUERY_STALE_TIME: 1000 * 60 * 5,
3539
+ QUERY_GC_TIME: 1000 * 60 * 60 * 24
3540
+ };
3541
+ });
3542
+ // ../harmony-shared/dist/logger.js
3543
+ var init_logger = () => {};
3544
+ // ../harmony-shared/dist/projectTemplates.js
3545
+ var init_projectTemplates = () => {};
3546
+
3547
+ // ../harmony-shared/dist/reviewMethodology.js
3369
3548
  var REVIEW_SYSTEM_PROMPT = `You are a senior code reviewer. Follow this two-pass methodology strictly.
3370
3549
  Report findings; do NOT fix them. This is a read-only review.
3371
3550
 
@@ -3441,7 +3620,41 @@ For each page affected by the changes:
3441
3620
  - Use snapshot for navigation — client-side routes may not appear in link lists.
3442
3621
  - Check for stale state: navigate away and back — does data refresh correctly?
3443
3622
  - Test browser back/forward — does the app handle history correctly?
3444
- - Watch for hydration errors or layout shifts after dynamic content loads.`;
3623
+ - Watch for hydration errors or layout shifts after dynamic content loads.`, REVIEW_VERDICT_SCHEMA = `{
3624
+ "verdict": "approved" | "rejected",
3625
+ "summary": "Brief overall assessment",
3626
+ "scopeCheck": {
3627
+ "status": "clean" | "drift" | "missing",
3628
+ "notes": "Optional explanation of scope issues"
3629
+ },
3630
+ "findings": [
3631
+ {
3632
+ "severity": "critical" | "major" | "minor",
3633
+ "category": "sql-safety | race-condition | llm-trust | enum-completeness | visual | functional | ux | console | scope | other",
3634
+ "title": "Short title",
3635
+ "description": "Detailed description of the issue",
3636
+ "location": "file:line (if applicable)"
3637
+ }
3638
+ ]
3639
+ }`, REVIEW_DECISION_RULES = `- **rejected**: Any \`critical\` finding, unaddressed requirements, or 2+ \`major\` findings.
3640
+ - **approved**: No critical findings, at most 1 major finding with minor findings OK.`;
3641
+ // ../harmony-shared/dist/types.js
3642
+ var init_types2 = () => {};
3643
+
3644
+ // ../harmony-shared/dist/index.js
3645
+ var init_dist = __esm(() => {
3646
+ init_cardLinks();
3647
+ init_commentSerializer();
3648
+ init_constants();
3649
+ init_logger();
3650
+ init_projectTemplates();
3651
+ init_types2();
3652
+ });
3653
+
3654
+ // src/review-knowledge.ts
3655
+ var init_review_knowledge = __esm(() => {
3656
+ init_dist();
3657
+ });
3445
3658
 
3446
3659
  // src/review-prompt.ts
3447
3660
  function buildReviewSystemPrompt() {
@@ -3452,15 +3665,13 @@ ${REVIEW_SYSTEM_PROMPT}
3452
3665
 
3453
3666
  ${QA_VISUAL_CHECKLIST}`;
3454
3667
  }
3455
- function buildReviewUserPrompt(enriched, branchName, worktreePath, previewUrl, diff, baseBranch) {
3668
+ function buildReviewUserPrompt(enriched, branchName, worktreePath, previewUrl, diffSummary, baseBranch) {
3456
3669
  const { card, labels, subtasks } = enriched;
3457
3670
  const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
3458
3671
  const subtaskStr = subtasks.length > 0 ? subtasks.map((s) => `- [${s.completed ? "x" : " "}] ${s.title}`).join(`
3459
3672
  `) : "No subtasks defined.";
3460
3673
  const description = card.description?.trim() || "No description provided.";
3461
- const truncatedDiff = diff.length > 80000 ? `${diff.slice(0, 80000)}
3462
-
3463
- ... (diff truncated at 80K characters)` : diff;
3674
+ const diffRange = branchName ? `origin/${baseBranch}..HEAD` : "HEAD";
3464
3675
  const branchLine = branchName ? `**Branch**: ${branchName}` : `**Mode**: Local review (no branch — reviewing working tree changes)`;
3465
3676
  return `## Card: #${card.short_id} - ${card.title}
3466
3677
  **Labels**: ${labelStr}
@@ -3472,10 +3683,15 @@ ${description}
3472
3683
  ## Subtasks (Acceptance Criteria)
3473
3684
  ${subtaskStr}
3474
3685
 
3475
- ## Diff ${branchName ? `(origin/${baseBranch}..HEAD)` : "(local changes)"}
3476
- \`\`\`diff
3477
- ${truncatedDiff}
3686
+ ## Changed Files (git diff --stat ${diffRange})
3478
3687
  \`\`\`
3688
+ ${diffSummary}
3689
+ \`\`\`
3690
+
3691
+ The full diff is intentionally NOT inlined here. Inspect the changes yourself —
3692
+ you have Read, Grep, Glob, and read-only Bash:
3693
+ - Run \`git diff ${diffRange}\` (or \`git diff ${diffRange} -- <file>\`) for the full hunks.
3694
+ - Read / Grep the changed files above and trace new values through their consumers.
3479
3695
 
3480
3696
  ## Review Steps
3481
3697
 
@@ -3506,33 +3722,18 @@ Use the \`/browse\` skill to navigate to ${previewUrl} and apply the visual QA c
3506
3722
  After completing all steps, output EXACTLY one JSON block (and nothing else after it):
3507
3723
 
3508
3724
  \`\`\`json
3509
- {
3510
- "verdict": "approved" | "rejected",
3511
- "summary": "Brief overall assessment",
3512
- "scopeCheck": {
3513
- "status": "clean" | "drift" | "missing",
3514
- "notes": "Optional explanation of scope issues"
3515
- },
3516
- "findings": [
3517
- {
3518
- "severity": "critical" | "major" | "minor",
3519
- "category": "sql-safety | race-condition | llm-trust | enum-completeness | visual | functional | ux | console | scope | other",
3520
- "title": "Short title",
3521
- "description": "Detailed description of the issue",
3522
- "location": "file:line (if applicable)"
3523
- }
3524
- ]
3525
- }
3725
+ ${REVIEW_VERDICT_SCHEMA}
3526
3726
  \`\`\`
3527
3727
 
3528
3728
  **Decision rules:**
3529
- - **rejected**: Any \`critical\` finding, unaddressed requirements, or 2+ \`major\` findings.
3530
- - **approved**: No critical findings, at most 1 major finding with minor findings OK.
3729
+ ${REVIEW_DECISION_RULES}
3531
3730
 
3532
3731
  **Do NOT modify any code.** This is a read-only review.
3533
3732
  ${branchName ? `You are reviewing code in a git worktree at \`${worktreePath}\` on branch \`${branchName}\`.` : `You are reviewing local changes in the repository at \`${worktreePath}\`.`}`;
3534
3733
  }
3535
- var init_review_prompt = () => {};
3734
+ var init_review_prompt = __esm(() => {
3735
+ init_review_knowledge();
3736
+ });
3536
3737
 
3537
3738
  // src/run-log.ts
3538
3739
  import { createWriteStream, mkdirSync } from "node:fs";
@@ -3674,13 +3875,19 @@ class StateStore {
3674
3875
  heartbeat(runId) {
3675
3876
  return this.updateRun(runId, { lastHeartbeatAt: Date.now() });
3676
3877
  }
3677
- endRun(runId, status, errorMessage) {
3878
+ endRun(runId, status, opts = {}) {
3678
3879
  const patch = {
3679
3880
  status,
3680
3881
  endedAt: Date.now()
3681
3882
  };
3682
- if (errorMessage !== undefined)
3683
- patch.errorMessage = errorMessage;
3883
+ if (opts.errorMessage !== undefined)
3884
+ patch.errorMessage = opts.errorMessage;
3885
+ if (opts.costCents !== undefined && opts.costCents > 0) {
3886
+ patch.costCents = opts.costCents;
3887
+ }
3888
+ if (opts.numTurns !== undefined && opts.numTurns > 0) {
3889
+ patch.numTurns = opts.numTurns;
3890
+ }
3684
3891
  return this.updateRun(runId, patch);
3685
3892
  }
3686
3893
  getRun(runId) {
@@ -4112,6 +4319,19 @@ class ReviewWorker {
4112
4319
  get isActive() {
4113
4320
  return this.state === "preparing" || this.state === "running" || this.state === "verifying" || this.state === "completing";
4114
4321
  }
4322
+ get costCents() {
4323
+ const cost = (this.progressTracker?.stats ?? this.lastSessionStats)?.cost;
4324
+ return cost ? Math.round(cost.totalCostUsd * 100) : 0;
4325
+ }
4326
+ endLedger() {
4327
+ const cost = (this.lastSessionStats ?? this.progressTracker?.stats)?.cost;
4328
+ if (!cost)
4329
+ return { costCents: 0, numTurns: 0 };
4330
+ return {
4331
+ costCents: Math.round(cost.totalCostUsd * 100),
4332
+ numTurns: cost.numTurns
4333
+ };
4334
+ }
4115
4335
  async run(card, column, labels, subtasks) {
4116
4336
  this.aborted = false;
4117
4337
  this.timedOut = false;
@@ -4137,7 +4357,8 @@ class ReviewWorker {
4137
4357
  lastHeartbeatAt: this.startedAt,
4138
4358
  endedAt: null,
4139
4359
  status: "active",
4140
- costCents: 0
4360
+ costCents: 0,
4361
+ numTurns: 0
4141
4362
  });
4142
4363
  this.branchName = extractBranchFromDescription(card.description);
4143
4364
  const localMode = !this.branchName;
@@ -4216,6 +4437,7 @@ class ReviewWorker {
4216
4437
  } catch {
4217
4438
  diff = "(unable to retrieve diff)";
4218
4439
  }
4440
+ const diffSummary = formatDiffSummary(diff);
4219
4441
  const previewUrl = `http://localhost:${port}`;
4220
4442
  const enriched = {
4221
4443
  card,
@@ -4225,7 +4447,7 @@ class ReviewWorker {
4225
4447
  mode: "review"
4226
4448
  };
4227
4449
  const systemPrompt = buildReviewSystemPrompt();
4228
- const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
4450
+ const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diffSummary, this.config.worktree.baseBranch);
4229
4451
  try {
4230
4452
  await this.client.recordPromptHistory({
4231
4453
  cardId: card.id,
@@ -4308,7 +4530,7 @@ class ReviewWorker {
4308
4530
  }
4309
4531
  if (this.runId) {
4310
4532
  try {
4311
- await this.stateStore.endRun(this.runId, this.state === "error" ? "paused" : "completed");
4533
+ await this.stateStore.endRun(this.runId, this.state === "error" ? "paused" : "completed", this.endLedger());
4312
4534
  } catch {}
4313
4535
  }
4314
4536
  } finally {
@@ -4317,7 +4539,7 @@ class ReviewWorker {
4317
4539
  const run = this.stateStore.getRun(this.runId);
4318
4540
  if (run && run.endedAt === null) {
4319
4541
  const status = this.timedOut ? "failed" : this.state === "error" || this.aborted ? "paused" : "completed";
4320
- await this.stateStore.endRun(this.runId, status);
4542
+ await this.stateStore.endRun(this.runId, status, this.endLedger());
4321
4543
  }
4322
4544
  } catch {}
4323
4545
  }
@@ -4402,6 +4624,7 @@ class ReviewWorker {
4402
4624
  }
4403
4625
  spawnClaude(prompt, systemPrompt, tracker, shortId) {
4404
4626
  return new Promise((resolve3, reject) => {
4627
+ const leanSources = this.config.claude.leanSettingSources;
4405
4628
  const args = [
4406
4629
  "--output-format",
4407
4630
  "stream-json",
@@ -4409,9 +4632,10 @@ class ReviewWorker {
4409
4632
  "--model",
4410
4633
  this.config.claude.reviewModel,
4411
4634
  "--max-turns",
4412
- String(this.config.claude.maxTurns),
4635
+ String(this.config.claude.reviewMaxTurns),
4413
4636
  "--allowedTools",
4414
4637
  "Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
4638
+ ...leanSources ? ["--setting-sources", leanSources] : [],
4415
4639
  ...systemPrompt ? ["--append-system-prompt", systemPrompt] : [],
4416
4640
  ...this.config.claude.additionalArgs,
4417
4641
  "--",
@@ -4557,6 +4781,7 @@ var TAG19 = "review-worker", CANCEL_SIGINT_TIMEOUT = 30000, CANCEL_SIGTERM_TIMEO
4557
4781
  var init_review_worker = __esm(() => {
4558
4782
  init_board_helpers();
4559
4783
  init_completion();
4784
+ init_git_diff_stat();
4560
4785
  init_log();
4561
4786
  init_process_group();
4562
4787
  init_progress_tracker();
@@ -4703,130 +4928,12 @@ var init_unblock = __esm(() => {
4703
4928
  init_log();
4704
4929
  });
4705
4930
 
4706
- // ../harmony-shared/dist/cardLinks.js
4707
- var init_cardLinks = () => {};
4708
- // ../harmony-shared/dist/commentSerializer.js
4709
- function sanitizeHeaderField(value) {
4710
- return value.replace(/[\]\r\n|<>]/g, " ").trim() || "—";
4711
- }
4712
- function authorLabel(c) {
4713
- if (c.author_type === "agent")
4714
- return "AI agent";
4715
- const raw = c.author?.full_name || "teammate";
4716
- return sanitizeHeaderField(raw);
4931
+ // src/model-tier.ts
4932
+ function chooseImplementModel(claude, priority, attempts) {
4933
+ const highPriority = priority === "high" || priority === "urgent";
4934
+ const escalated = highPriority || attempts >= claude.escalateAfterAttempts;
4935
+ return { model: escalated ? claude.escalateModel : claude.model, escalated };
4717
4936
  }
4718
- function criticalIds(comments) {
4719
- const keep = new Set;
4720
- for (const c of comments) {
4721
- if (c.comment_type === "decision")
4722
- keep.add(c.id);
4723
- if (c.supersedes_id) {
4724
- keep.add(c.id);
4725
- keep.add(c.supersedes_id);
4726
- }
4727
- if (c.confirms_id) {
4728
- keep.add(c.id);
4729
- keep.add(c.confirms_id);
4730
- }
4731
- }
4732
- return keep;
4733
- }
4734
- function serializeCommentThread(comments, options = {}) {
4735
- const { heading = "Conversation", includeInstructions = true, activity = [], maxComments } = options;
4736
- const visible = comments.filter((c) => !c.deleted_at).slice().sort((a, b) => a.created_at.localeCompare(b.created_at));
4737
- if (visible.length === 0)
4738
- return "";
4739
- const indexById = new Map;
4740
- visible.forEach((c, i) => {
4741
- indexById.set(c.id, i + 1);
4742
- });
4743
- let rendered = visible;
4744
- let elidedCount = 0;
4745
- if (maxComments && visible.length > maxComments) {
4746
- const keep = criticalIds(visible);
4747
- const recentThreshold = visible.length - maxComments;
4748
- rendered = visible.filter((c, i) => i >= recentThreshold || keep.has(c.id));
4749
- elidedCount = visible.length - rendered.length;
4750
- }
4751
- const ref = (id) => {
4752
- const n = indexById.get(id);
4753
- return n ? `#${n}` : `#${id.slice(0, 8)}`;
4754
- };
4755
- const lines = [];
4756
- if (elidedCount > 0) {
4757
- lines.push({
4758
- at: visible[0]?.created_at ?? "",
4759
- text: `(${elidedCount} earlier comment(s) omitted for brevity)`
4760
- });
4761
- }
4762
- for (const c of rendered) {
4763
- const tags = [];
4764
- if (c.edited_at)
4765
- tags.push("edited");
4766
- if (c.supersedes_id)
4767
- tags.push(`supersedes ${ref(c.supersedes_id)}`);
4768
- if (c.confirms_id)
4769
- tags.push(`confirms ${ref(c.confirms_id)}`);
4770
- if (c.resolved_at)
4771
- tags.push("resolved");
4772
- const tagStr = tags.length ? ` | ${tags.join(" | ")}` : "";
4773
- const header = `[${sanitizeHeaderField(ref(c.id))} | ${sanitizeHeaderField(c.author_type)} | ${authorLabel(c)} | ${sanitizeHeaderField(c.comment_type)} | ${sanitizeHeaderField(c.created_at)}${tagStr}]`;
4774
- const fencedBody = c.body.trim().replaceAll("<", "&lt;").replaceAll(">", "&gt;");
4775
- lines.push({
4776
- at: c.created_at,
4777
- text: `${header}
4778
- <comment-body>
4779
- ${fencedBody}
4780
- </comment-body>`
4781
- });
4782
- }
4783
- for (const a of activity) {
4784
- const actor = a.actor ? `${a.actor} ` : "";
4785
- lines.push({ at: a.at, text: `· (system) ${a.at} — ${actor}${a.text}` });
4786
- }
4787
- lines.sort((a, b) => a.at.localeCompare(b.at));
4788
- const body = lines.map((l) => l.text).join(`
4789
-
4790
- `);
4791
- const instruction = includeInstructions ? `
4792
-
4793
- ${CONFLICT_INSTRUCTION}` : "";
4794
- return `## ${heading} (oldest → newest)
4795
-
4796
- ${body}${instruction}`;
4797
- }
4798
- var CONFLICT_INSTRUCTION;
4799
- var init_commentSerializer = __esm(() => {
4800
- CONFLICT_INSTRUCTION = "When two comments conflict, prefer the latest created_at, UNLESS a later " + "comment explicitly confirms or restates the earlier finding. Evaluate " + "substance, not just recency. Cite the comment id(s) you relied on.";
4801
- });
4802
-
4803
- // ../harmony-shared/dist/constants.js
4804
- var TIMINGS;
4805
- var init_constants = __esm(() => {
4806
- TIMINGS = {
4807
- SEARCH_DEBOUNCE: 300,
4808
- AUTOSAVE_DEBOUNCE: 1000,
4809
- TOAST_DURATION: 3000,
4810
- QUERY_STALE_TIME: 1000 * 60 * 5,
4811
- QUERY_GC_TIME: 1000 * 60 * 60 * 24
4812
- };
4813
- });
4814
- // ../harmony-shared/dist/logger.js
4815
- var init_logger = () => {};
4816
- // ../harmony-shared/dist/projectTemplates.js
4817
- var init_projectTemplates = () => {};
4818
- // ../harmony-shared/dist/types.js
4819
- var init_types2 = () => {};
4820
-
4821
- // ../harmony-shared/dist/index.js
4822
- var init_dist = __esm(() => {
4823
- init_cardLinks();
4824
- init_commentSerializer();
4825
- init_constants();
4826
- init_logger();
4827
- init_projectTemplates();
4828
- init_types2();
4829
- });
4830
4937
 
4831
4938
  // src/prompt.ts
4832
4939
  async function buildPrompt(enriched, branchName, worktreePath, client, workspaceId, projectId) {
@@ -4988,6 +5095,8 @@ class Worker {
4988
5095
  verificationFailed = false;
4989
5096
  sessionId = null;
4990
5097
  runId = null;
5098
+ runCostCents = 0;
5099
+ runTurns = 0;
4991
5100
  constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted, onApiError) {
4992
5101
  this.config = config;
4993
5102
  this.client = client;
@@ -5040,10 +5149,20 @@ class Worker {
5040
5149
  get isActive() {
5041
5150
  return this.state === "preparing" || this.state === "planning" || this.state === "running" || this.state === "verifying" || this.state === "completing";
5042
5151
  }
5152
+ get costCents() {
5153
+ const live = this.progressTracker?.stats.cost;
5154
+ const liveCents = live ? Math.round(live.totalCostUsd * 100) : 0;
5155
+ return this.runCostCents + liveCents;
5156
+ }
5157
+ runLedger() {
5158
+ return { costCents: this.runCostCents, numTurns: this.runTurns };
5159
+ }
5043
5160
  async run(card, column, labels, subtasks) {
5044
5161
  this.aborted = false;
5045
5162
  this.timedOut = false;
5046
5163
  this.verificationFailed = false;
5164
+ this.runCostCents = 0;
5165
+ this.runTurns = 0;
5047
5166
  this.cardId = card.id;
5048
5167
  this.startedAt = Date.now();
5049
5168
  this.runId = newRunId();
@@ -5068,7 +5187,8 @@ class Worker {
5068
5187
  lastHeartbeatAt: this.startedAt,
5069
5188
  endedAt: null,
5070
5189
  status: "active",
5071
- costCents: 0
5190
+ costCents: 0,
5191
+ numTurns: 0
5072
5192
  });
5073
5193
  const { session } = await this.client.startAgentSession(card.id, {
5074
5194
  agentIdentifier: agentIdentifier(this.id),
@@ -5126,7 +5246,9 @@ class Worker {
5126
5246
  this.timedOut = true;
5127
5247
  this.cancel();
5128
5248
  }, this.config.maxTimeout);
5129
- await this.spawnClaude(prompt, card, subtasks);
5249
+ await this.spawnClaude(prompt, card, subtasks, {
5250
+ model: this.selectImplementModel(card)
5251
+ });
5130
5252
  if (this.aborted)
5131
5253
  return;
5132
5254
  if (this.timeoutTimer) {
@@ -5184,7 +5306,10 @@ class Worker {
5184
5306
  }
5185
5307
  if (this.runId) {
5186
5308
  try {
5187
- await this.stateStore.endRun(this.runId, "failed", errClass.kind ?? msg);
5309
+ await this.stateStore.endRun(this.runId, "failed", {
5310
+ errorMessage: errClass.kind ?? msg,
5311
+ ...this.runLedger()
5312
+ });
5188
5313
  } catch {}
5189
5314
  if (apiError) {
5190
5315
  await this.stateStore.decrementAttempt(card.id);
@@ -5196,7 +5321,7 @@ class Worker {
5196
5321
  const succeeded = this.runId && this.state !== "error" && !this.aborted && !this.verificationFailed;
5197
5322
  if (succeeded) {
5198
5323
  try {
5199
- await this.stateStore.endRun(this.runId, "completed");
5324
+ await this.stateStore.endRun(this.runId, "completed", this.runLedger());
5200
5325
  } catch {}
5201
5326
  await this.recordOutcome(card.id, "success");
5202
5327
  } else if (this.runId && this.timedOut) {
@@ -5222,16 +5347,25 @@ class Worker {
5222
5347
  log.error(this.tag, `timeout transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
5223
5348
  }
5224
5349
  try {
5225
- await this.stateStore.endRun(this.runId, "failed", "timeout");
5350
+ await this.stateStore.endRun(this.runId, "failed", {
5351
+ errorMessage: "timeout",
5352
+ ...this.runLedger()
5353
+ });
5226
5354
  } catch {}
5227
5355
  await this.recordOutcome(card.id, "failure");
5228
5356
  } else if (this.runId && this.aborted) {
5229
5357
  try {
5230
- await this.stateStore.endRun(this.runId, "paused", "cancelled");
5358
+ await this.stateStore.endRun(this.runId, "paused", {
5359
+ errorMessage: "cancelled",
5360
+ ...this.runLedger()
5361
+ });
5231
5362
  } catch {}
5232
5363
  } else if (this.runId && this.verificationFailed) {
5233
5364
  try {
5234
- await this.stateStore.endRun(this.runId, "paused", "verification");
5365
+ await this.stateStore.endRun(this.runId, "paused", {
5366
+ errorMessage: "verification",
5367
+ ...this.runLedger()
5368
+ });
5235
5369
  } catch {}
5236
5370
  await this.recordOutcome(card.id, "failure");
5237
5371
  }
@@ -5240,6 +5374,14 @@ class Worker {
5240
5374
  this.onDone(this);
5241
5375
  }
5242
5376
  }
5377
+ selectImplementModel(card) {
5378
+ const attempts = this.stateStore.getCard(card.id)?.attempts ?? 1;
5379
+ const { model, escalated } = chooseImplementModel(this.config.claude, card.priority, attempts);
5380
+ if (escalated) {
5381
+ log.info(this.tag, `Escalating implement model to "${model}" (attempts=${attempts}, priority=${card.priority ?? "none"})`);
5382
+ }
5383
+ return model;
5384
+ }
5243
5385
  async recordOutcome(cardId, outcome) {
5244
5386
  try {
5245
5387
  const cost = this.lastSessionStats?.cost;
@@ -5510,6 +5652,11 @@ class Worker {
5510
5652
  this.process.on("close", (code) => {
5511
5653
  this.process = null;
5512
5654
  this.lastSessionStats = this.progressTracker?.stats;
5655
+ const spawnCost = this.lastSessionStats?.cost;
5656
+ if (spawnCost) {
5657
+ this.runCostCents += Math.round(spawnCost.totalCostUsd * 100);
5658
+ this.runTurns += spawnCost.numTurns;
5659
+ }
5513
5660
  this.progressTracker?.flushFinal();
5514
5661
  this.progressTracker?.stop();
5515
5662
  this.progressTracker = null;
@@ -5557,6 +5704,8 @@ class Worker {
5557
5704
  this.startedAt = null;
5558
5705
  this.runId = null;
5559
5706
  this.sessionId = null;
5707
+ this.runCostCents = 0;
5708
+ this.runTurns = 0;
5560
5709
  }
5561
5710
  }
5562
5711
  var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*", PLAN_PHASE_TIMEOUT;
@@ -5779,7 +5928,8 @@ class Pool {
5779
5928
  cardId: w.cardId,
5780
5929
  cardShortId: null,
5781
5930
  startedAt: w.startedAt,
5782
- branchName: w.branchName
5931
+ branchName: w.branchName,
5932
+ costCents: w.costCents
5783
5933
  });
5784
5934
  }
5785
5935
  for (const w of this.reviewWorkers) {
@@ -5790,7 +5940,8 @@ class Pool {
5790
5940
  cardId: w.cardId,
5791
5941
  cardShortId: null,
5792
5942
  startedAt: w.startedAt,
5793
- branchName: w.branchName
5943
+ branchName: w.branchName,
5944
+ costCents: w.costCents
5794
5945
  });
5795
5946
  }
5796
5947
  return out;
@@ -6017,7 +6168,9 @@ async function recoverRun(run, store, client, config, outcome) {
6017
6168
  }
6018
6169
  }
6019
6170
  try {
6020
- await store.endRun(run.runId, "orphaned", "recovered after daemon restart");
6171
+ await store.endRun(run.runId, "orphaned", {
6172
+ errorMessage: "recovered after daemon restart"
6173
+ });
6021
6174
  } catch (err) {
6022
6175
  const msg = err instanceof Error ? err.message : String(err);
6023
6176
  outcome.errors.push(`endRun: ${msg}`);
@@ -6337,9 +6490,11 @@ function configRows(config, projectName, gitProvider, httpPort) {
6337
6490
  const reviewEnabled = config.agent.review.enabled;
6338
6491
  const poolDesc = reviewEnabled ? `Pool ${config.agent.poolSize} impl + ${config.agent.review.poolSize} review` : `Pool ${config.agent.poolSize} impl`;
6339
6492
  const flowDesc = reviewEnabled ? `Pickup ${config.agent.pickupColumns[0]} → ${config.agent.completion.moveToColumn} → ${config.agent.review.moveToColumn}` : `Pickup ${config.agent.pickupColumns.join(", ")}`;
6493
+ const { model, escalateModel, reviewModel } = config.agent.claude;
6494
+ const modelDesc = model === escalateModel ? model : `${model} → ${escalateModel} on retry/high · review ${reviewModel}`;
6340
6495
  rows.push({
6341
6496
  label: "Model",
6342
- value: `${config.agent.claude.model} · ${poolDesc} · ${flowDesc}`
6497
+ value: `${modelDesc} · ${poolDesc} · ${flowDesc}`
6343
6498
  });
6344
6499
  const tail = [];
6345
6500
  if (gitProvider)
@@ -6610,11 +6765,23 @@ var exports_worktree_gc = {};
6610
6765
  __export(exports_worktree_gc, {
6611
6766
  runWorktreeGc: () => runWorktreeGc,
6612
6767
  pruneFailedRemoteBranches: () => pruneFailedRemoteBranches,
6768
+ isTransientGitNetworkError: () => isTransientGitNetworkError,
6613
6769
  WorktreeGc: () => WorktreeGc
6614
6770
  });
6615
6771
  import { execFileSync as execFileSync9 } from "node:child_process";
6616
6772
  import { readdirSync, statSync as statSync2 } from "node:fs";
6617
6773
  import { resolve as resolve3 } from "node:path";
6774
+ function isTransientGitNetworkError(message) {
6775
+ return TRANSIENT_GIT_NETWORK_ERROR.test(message);
6776
+ }
6777
+ function gitErrorDetail(err) {
6778
+ if (err && typeof err === "object" && "stderr" in err) {
6779
+ const stderr = String(err.stderr ?? "").trim();
6780
+ if (stderr)
6781
+ return stderr;
6782
+ }
6783
+ return err instanceof Error ? err.message : String(err);
6784
+ }
6618
6785
  function runWorktreeGc(basePath, store, opts = {}) {
6619
6786
  const minAgeMs = opts.minAgeMs ?? 60 * 60 * 1000;
6620
6787
  const now = (opts.now ?? Date.now)();
@@ -6702,13 +6869,16 @@ function pruneFailedRemoteBranches(opts) {
6702
6869
  try {
6703
6870
  execFileSync9("git", ["fetch", "--prune", "origin"], {
6704
6871
  cwd: repoRoot,
6705
- stdio: "pipe"
6872
+ stdio: "pipe",
6873
+ ...GIT_NETWORK_EXEC
6706
6874
  });
6707
6875
  } catch (err) {
6708
- result.errors.push({
6709
- ref: "fetch",
6710
- error: err instanceof Error ? err.message : String(err)
6711
- });
6876
+ const detail = gitErrorDetail(err);
6877
+ if (isTransientGitNetworkError(detail)) {
6878
+ log.debug(TAG30, `Remote branch GC skipped remote unreachable: ${detail}`);
6879
+ return result;
6880
+ }
6881
+ result.errors.push({ ref: "fetch", error: detail });
6712
6882
  }
6713
6883
  const refPattern = `refs/remotes/origin/${opts.prefix}*`;
6714
6884
  let listing = "";
@@ -6725,7 +6895,9 @@ function pruneFailedRemoteBranches(opts) {
6725
6895
  });
6726
6896
  return result;
6727
6897
  }
6728
- const cutoffSecs = (opts.now ?? Date.now)() / 1000 - opts.retentionDays * 24 * 60 * 60;
6898
+ const clock = opts.now ?? Date.now;
6899
+ const cutoffSecs = clock() / 1000 - opts.retentionDays * 24 * 60 * 60;
6900
+ const sweepDeadline = clock() + GIT_PRUNE_SWEEP_BUDGET_MS;
6729
6901
  for (const line of listing.split(`
6730
6902
  `)) {
6731
6903
  const trimmed = line.trim();
@@ -6741,17 +6913,24 @@ function pruneFailedRemoteBranches(opts) {
6741
6913
  result.skipped.push(ref);
6742
6914
  continue;
6743
6915
  }
6916
+ if (clock() > sweepDeadline) {
6917
+ log.debug(TAG30, `Remote branch GC budget spent — removed ${result.removed.length}, remaining deferred to next tick`);
6918
+ break;
6919
+ }
6744
6920
  try {
6745
6921
  execFileSync9("git", ["push", "origin", `:refs/heads/${ref}`], {
6746
6922
  cwd: repoRoot,
6747
- stdio: "pipe"
6923
+ stdio: "pipe",
6924
+ ...GIT_NETWORK_EXEC
6748
6925
  });
6749
6926
  result.removed.push(ref);
6750
6927
  } catch (err) {
6751
- result.errors.push({
6752
- ref,
6753
- error: err instanceof Error ? err.message : String(err)
6754
- });
6928
+ const detail = gitErrorDetail(err);
6929
+ if (isTransientGitNetworkError(detail)) {
6930
+ log.debug(TAG30, `Remote branch GC interrupted remote unreachable: ${detail}`);
6931
+ break;
6932
+ }
6933
+ result.errors.push({ ref, error: detail });
6755
6934
  }
6756
6935
  }
6757
6936
  if (result.removed.length > 0) {
@@ -6809,10 +6988,32 @@ function getRepoRoot2() {
6809
6988
  return null;
6810
6989
  }
6811
6990
  }
6812
- var TAG30 = "worktree-gc";
6991
+ var TAG30 = "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;
6813
6992
  var init_worktree_gc = __esm(() => {
6814
6993
  init_log();
6815
6994
  init_worktree();
6995
+ GIT_NETWORK_EXEC = {
6996
+ timeout: GIT_NETWORK_TIMEOUT_MS,
6997
+ killSignal: "SIGKILL",
6998
+ env: {
6999
+ ...process.env,
7000
+ GIT_SSH_COMMAND: `${process.env.GIT_SSH_COMMAND ?? "ssh"} -o ConnectTimeout=${GIT_SSH_CONNECT_TIMEOUT_SECS} -o BatchMode=yes`,
7001
+ GIT_TERMINAL_PROMPT: "0"
7002
+ }
7003
+ };
7004
+ TRANSIENT_GIT_NETWORK_ERROR = new RegExp([
7005
+ "timed out",
7006
+ "connection refused",
7007
+ "connection reset",
7008
+ "could not resolve host",
7009
+ "temporary failure in name resolution",
7010
+ "ssh: connect to host",
7011
+ "kex_exchange_identification",
7012
+ "network is unreachable",
7013
+ "etimedout",
7014
+ "enotfound",
7015
+ "enetunreach"
7016
+ ].join("|"), "i");
6816
7017
  });
6817
7018
 
6818
7019
  // src/index.ts