From 8f15c67614565ea0dac14950c53304c74c94778f Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 14 May 2026 14:04:33 +0200 Subject: [PATCH] feat(cli): add `Movers` section to `colony gain` for regression detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the queried window into a trailing "recent" segment (default `window/7`) and a "prior" segment, then surfaces ops whose per-hour call rate, token rate, or error count has shifted materially. Lists top 3 risers (▲), top 3 fallers (▼), and top 3 error risers (!) inline above the Operations table. `(new)` and `(gone)` tags handle ops with no prior or no recent activity so a brand-new hot loop isn't reported as +∞%. Adds `--recent-hours ` to override the split and `--no-movers` to suppress the section; JSON output gains a `live.movers` payload. --- .changeset/vast-impalas-draw.md | 13 + apps/cli/src/commands/gain.ts | 302 +++++++++++++++++- apps/cli/test/gain.test.ts | 211 +++++++++++- .../.openspec.yaml | 2 + .../notes.md | 32 ++ 5 files changed, 557 insertions(+), 3 deletions(-) create mode 100644 .changeset/vast-impalas-draw.md create mode 100644 openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/.openspec.yaml create mode 100644 openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/notes.md diff --git a/.changeset/vast-impalas-draw.md b/.changeset/vast-impalas-draw.md new file mode 100644 index 0000000..b55f989 --- /dev/null +++ b/.changeset/vast-impalas-draw.md @@ -0,0 +1,13 @@ +--- +'colonyq': minor +--- + +Add a `Movers` section to `colony gain` that splits the queried window into a +trailing "recent" segment and a "prior" segment, then surfaces operations whose +per-hour call rate, token rate, or error count has shifted materially between +the two. Top 3 risers (▲), top 3 fallers (▼), and top 3 error risers (!) are +listed inline above the existing Operations table. New ops (no prior activity) +are tagged `(new)` and disappeared ops `(gone)`. Two new flags: `--recent-hours +` to override the split (default: `window / 7`) and `--no-movers` to +suppress the section. JSON output gains a `live.movers` payload with the same +shape as the rendered rows. diff --git a/apps/cli/src/commands/gain.ts b/apps/cli/src/commands/gain.ts index 19c18ec..a82a4b0 100644 --- a/apps/cli/src/commands/gain.ts +++ b/apps/cli/src/commands/gain.ts @@ -29,6 +29,37 @@ interface GainOptions { outputCostPer1m?: string; reference?: boolean; honest?: boolean; + recentHours?: string; + movers?: boolean; +} + +export interface MoverRow { + operation: string; + recent_calls: number; + prior_calls: number; + recent_tokens: number; + prior_tokens: number; + recent_errors: number; + prior_errors: number; + recent_rate: number; + prior_rate: number; + calls_delta_pct: number | null; + tokens_delta_pct: number | null; + errors_delta_abs: number; + state: 'new' | 'gone' | 'changed'; +} + +export interface MoversReport { + recent_hours: number; + prior_hours: number; + recent_since: number; + prior_since: number; + total_recent_calls: number; + total_prior_calls: number; + risers: MoverRow[]; + fallers: MoverRow[]; + error_risers: MoverRow[]; + skipped_reason: string | null; } interface TopErrorReason { @@ -50,6 +81,11 @@ export function registerGainCommand(program: Command): void { .option('--session-limit ', 'number of live sessions to print (default 12; 0 = all)') .option('--reference', 'also print the static per-session reference catalog') .option('--honest', 'show only live mcp_metrics receipts; omit reference/comparison models') + .option( + '--recent-hours ', + 'trailing window in hours for the Movers section (default: window / 7)', + ) + .option('--no-movers', 'hide the Movers (last vs prior) regression section') .option( '--input-cost-per-1m ', 'USD rate per 1M input tokens; env COLONY_MCP_INPUT_USD_PER_1M', @@ -70,21 +106,47 @@ export function registerGainCommand(program: Command): void { ? sinceArg : now - windowHours * 60 * 60_000; - const live = await withStorage( + const moversEnabled = opts.movers !== false; + const recentHours = resolveRecentHours(opts.recentHours, windowHours); + const recentSince = recentHours !== null ? now - recentHours * 60 * 60_000 : null; + + const { live, recent } = await withStorage( settings, (storage) => { const sessionLimit = parseSessionLimit(opts.sessionLimit); - return storage.aggregateMcpMetrics({ + const fullAgg = storage.aggregateMcpMetrics({ since, until: now, ...(opts.operation !== undefined ? { operation: opts.operation } : {}), ...(sessionLimit !== undefined ? { sessionLimit } : {}), cost: costOptionsFromCli(opts), }); + const recentAgg = + moversEnabled && recentSince !== null && recentSince > since + ? storage.aggregateMcpMetrics({ + since: recentSince, + until: now, + ...(opts.operation !== undefined ? { operation: opts.operation } : {}), + cost: costOptionsFromCli(opts), + }) + : null; + return { live: fullAgg, recent: recentAgg }; }, { readonly: true }, ); + const movers = + moversEnabled && recent !== null && recentHours !== null && recentSince !== null + ? buildMoversReport({ + full: live.operations, + recent: recent.operations, + recentHours, + priorHours: windowHours - recentHours, + recentSince, + priorSince: since, + }) + : null; + const referenceTotals = savingsReferenceTotals(); const comparison = savingsLiveComparison(live.operations); const comparisonCost = live.cost_basis.configured @@ -99,6 +161,7 @@ export function registerGainCommand(program: Command): void { operations: live.operations, session_summary: live.session_summary, sessions: live.sessions, + ...(movers !== null ? { movers } : {}), }, }; const payload = @@ -136,6 +199,7 @@ export function registerGainCommand(program: Command): void { opts.operation, opts.reference === true, opts.honest === true, + movers, ); }); } @@ -152,6 +216,7 @@ export function writeGainReport( operationFilter: string | undefined, includeReference = false, honest = false, + movers: MoversReport | null = null, ): void { const comparison = savingsLiveComparison(liveRows, referenceRows); const comparisonCost = costBasis.configured @@ -165,6 +230,7 @@ export function writeGainReport( costBasis, hours, operationFilter, + movers, ); if (honest) return; process.stdout.write('\n'); @@ -280,6 +346,7 @@ export function writeLiveSection( costBasis: McpMetricsCostBasis, hours: number, operationFilter: string | undefined, + movers: MoversReport | null = null, ): void { const w = process.stdout; const filter = operationFilter ? ` (op=${operationFilter})` : ''; @@ -300,6 +367,9 @@ export function writeLiveSection( ), ); writeLiveOverview(rows, totals, sessionSummary, costBasis); + if (movers !== null) { + writeMoversSection(movers); + } w.write('\n'); w.write(`${kleur.bold('Operations')}\n`); const head = padRow( @@ -651,6 +721,234 @@ function findTopTokenSpend( return top; } +const MOVER_MIN_HOURS = 4; +const MOVER_MIN_CALLS = 5; +const MOVER_RATE_THRESHOLD = 2; +const MOVER_ERROR_MIN = 3; +const MOVER_ERROR_THRESHOLD = 3; +const MOVER_DISPLAY_LIMIT = 3; + +function resolveRecentHours(raw: string | undefined, windowHours: number): number | null { + if (windowHours < MOVER_MIN_HOURS) return null; + if (raw !== undefined && raw.trim() !== '') { + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed >= windowHours) return null; + return parsed; + } + return Math.max(windowHours / 7, 1); +} + +export function buildMoversReport(args: { + full: ReadonlyArray; + recent: ReadonlyArray; + recentHours: number; + priorHours: number; + recentSince: number; + priorSince: number; +}): MoversReport { + const { full, recent, recentHours, priorHours, recentSince, priorSince } = args; + const recentByOp = new Map(); + for (const row of recent) recentByOp.set(row.operation, row); + const rows: MoverRow[] = []; + let totalRecentCalls = 0; + let totalPriorCalls = 0; + + for (const fullRow of full) { + const recentRow = recentByOp.get(fullRow.operation); + const recentCalls = recentRow?.calls ?? 0; + const priorCalls = Math.max(0, fullRow.calls - recentCalls); + const recentTokens = recentRow?.total_tokens ?? 0; + const priorTokens = Math.max(0, fullRow.total_tokens - recentTokens); + const recentErrors = recentRow?.error_count ?? 0; + const priorErrors = Math.max(0, fullRow.error_count - recentErrors); + totalRecentCalls += recentCalls; + totalPriorCalls += priorCalls; + + const recentRate = recentHours > 0 ? recentCalls / recentHours : 0; + const priorRate = priorHours > 0 ? priorCalls / priorHours : 0; + const state: MoverRow['state'] = + priorCalls === 0 && recentCalls > 0 + ? 'new' + : recentCalls === 0 && priorCalls > 0 + ? 'gone' + : 'changed'; + + rows.push({ + operation: fullRow.operation, + recent_calls: recentCalls, + prior_calls: priorCalls, + recent_tokens: recentTokens, + prior_tokens: priorTokens, + recent_errors: recentErrors, + prior_errors: priorErrors, + recent_rate: recentRate, + prior_rate: priorRate, + calls_delta_pct: rateDeltaPct(recentRate, priorRate), + tokens_delta_pct: rateDeltaPct( + recentHours > 0 ? recentTokens / recentHours : 0, + priorHours > 0 ? priorTokens / priorHours : 0, + ), + errors_delta_abs: recentErrors - priorErrors, + state, + }); + } + + const skippedReason = + totalRecentCalls + totalPriorCalls === 0 + ? 'no calls in either window' + : recentHours <= 0 || priorHours <= 0 + ? 'window cannot be split' + : null; + + if (skippedReason !== null) { + return { + recent_hours: recentHours, + prior_hours: priorHours, + recent_since: recentSince, + prior_since: priorSince, + total_recent_calls: totalRecentCalls, + total_prior_calls: totalPriorCalls, + risers: [], + fallers: [], + error_risers: [], + skipped_reason: skippedReason, + }; + } + + const risers = rows + .filter( + (row) => + row.recent_calls >= MOVER_MIN_CALLS && + (row.state === 'new' || + (row.prior_rate > 0 && row.recent_rate >= row.prior_rate * MOVER_RATE_THRESHOLD)), + ) + .sort((a, b) => moverScore(b, 'rise') - moverScore(a, 'rise')) + .slice(0, MOVER_DISPLAY_LIMIT); + + const fallers = rows + .filter( + (row) => + row.prior_calls >= MOVER_MIN_CALLS && + (row.state === 'gone' || + (row.recent_rate > 0 && row.prior_rate >= row.recent_rate * MOVER_RATE_THRESHOLD)), + ) + .sort((a, b) => moverScore(b, 'fall') - moverScore(a, 'fall')) + .slice(0, MOVER_DISPLAY_LIMIT); + + const errorRisers = rows + .filter( + (row) => + row.recent_errors >= MOVER_ERROR_MIN && + row.recent_errors >= Math.max(row.prior_errors, 1) * MOVER_ERROR_THRESHOLD, + ) + .sort((a, b) => b.recent_errors - b.prior_errors - (a.recent_errors - a.prior_errors)) + .slice(0, MOVER_DISPLAY_LIMIT); + + return { + recent_hours: recentHours, + prior_hours: priorHours, + recent_since: recentSince, + prior_since: priorSince, + total_recent_calls: totalRecentCalls, + total_prior_calls: totalPriorCalls, + risers, + fallers, + error_risers: errorRisers, + skipped_reason: null, + }; +} + +function rateDeltaPct(recentRate: number, priorRate: number): number | null { + if (priorRate <= 0) return null; + return ((recentRate - priorRate) / priorRate) * 100; +} + +function moverScore(row: MoverRow, direction: 'rise' | 'fall'): number { + if (direction === 'rise') { + if (row.state === 'new') return Number.MAX_SAFE_INTEGER - 1 + row.recent_calls; + return row.calls_delta_pct ?? row.tokens_delta_pct ?? 0; + } + if (row.state === 'gone') return Number.MAX_SAFE_INTEGER - 1 + row.prior_calls; + const fallByCalls = row.calls_delta_pct !== null ? -row.calls_delta_pct : 0; + const fallByTokens = row.tokens_delta_pct !== null ? -row.tokens_delta_pct : 0; + return Math.max(fallByCalls, fallByTokens); +} + +export function writeMoversSection(movers: MoversReport): void { + const w = process.stdout; + if (movers.risers.length === 0 && movers.fallers.length === 0 && movers.error_risers.length === 0) + return; + const recentLabel = formatHoursLabel(movers.recent_hours); + const priorLabel = formatHoursLabel(movers.prior_hours); + w.write(`${kleur.bold('Movers')} ${kleur.dim(`(last ${recentLabel} vs prior ${priorLabel})`)}\n`); + for (const row of movers.risers) { + w.write(` ${kleur.green('▲')} ${formatMoverLine(row)}\n`); + } + for (const row of movers.fallers) { + w.write(` ${kleur.cyan('▼')} ${formatMoverLine(row)}\n`); + } + for (const row of movers.error_risers) { + w.write( + ` ${kleur.red('!')} ${row.operation} errors ${row.prior_errors} -> ${row.recent_errors}` + + ` (${formatTokenDeltaAbs(row.errors_delta_abs)})\n`, + ); + } +} + +function formatMoverLine(row: MoverRow): string { + if (row.state === 'new') { + return `${row.operation} (new) ${row.recent_calls} call${ + row.recent_calls === 1 ? '' : 's' + }, ${formatTokens(row.recent_tokens)} tokens`; + } + if (row.state === 'gone') { + return `${row.operation} (gone) was ${row.prior_calls} call${ + row.prior_calls === 1 ? '' : 's' + }, ${formatTokens(row.prior_tokens)} tokens`; + } + const callsPart = + row.calls_delta_pct !== null + ? `calls ${formatSignedPct(row.calls_delta_pct)} (${formatHourlyRate(row.recent_rate)}/h vs ${formatHourlyRate(row.prior_rate)}/h)` + : `calls ${row.recent_calls} vs ${row.prior_calls}`; + const tokensPart = + row.tokens_delta_pct !== null + ? `tokens ${formatSignedPct(row.tokens_delta_pct)}` + : `tokens ${formatTokens(row.recent_tokens)} vs ${formatTokens(row.prior_tokens)}`; + return `${row.operation} ${callsPart} ${tokensPart}`; +} + +function formatHourlyRate(rate: number): string { + if (rate >= 10) return `${Math.round(rate)}`; + if (rate >= 1) return rate.toFixed(1); + return rate.toFixed(2); +} + +function formatSignedPct(value: number): string { + const rounded = Math.abs(value) >= 10 ? Math.round(value) : Number(value.toFixed(1)); + const prefix = value > 0 ? '+' : ''; + const text = `${prefix}${rounded}%`; + return value >= 0 ? kleur.green(text) : kleur.red(text); +} + +function formatTokenDeltaAbs(value: number): string { + const prefix = value > 0 ? '+' : value < 0 ? '-' : ''; + const text = `${prefix}${Math.abs(value)}`; + return value >= 0 ? kleur.red(text) : kleur.green(text); +} + +function formatHoursLabel(hours: number): string { + if (hours >= 24) { + const days = hours / 24; + if (Number.isInteger(days)) return `${days}d`; + return `${days.toFixed(1)}d`; + } + if (hours >= 1) { + if (Number.isInteger(hours)) return `${hours}h`; + return `${hours.toFixed(1)}h`; + } + return `${Math.round(hours * 60)}m`; +} + function writeUnmatchedComparisonSummary(comparison: SavingsLiveComparison): void { if (comparison.totals.unmatched_calls === 0) return; const operations = comparison.unmatched_operations diff --git a/apps/cli/test/gain.test.ts b/apps/cli/test/gain.test.ts index d17c3c4..bb1244f 100644 --- a/apps/cli/test/gain.test.ts +++ b/apps/cli/test/gain.test.ts @@ -5,7 +5,13 @@ import type { McpMetricsSessionSummary, } from '@colony/storage'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { writeGainReport, writeLiveSection, writeReferenceSection } from '../src/commands/gain.js'; +import { + buildMoversReport, + writeGainReport, + writeLiveSection, + writeMoversSection, + writeReferenceSection, +} from '../src/commands/gain.js'; const COST_BASIS = { input_usd_per_1m_tokens: 1, @@ -550,3 +556,206 @@ describe('gain command output', () => { expect(healthDiagnosis?.mcp_operations).toContain('savings_report'); }); }); + +function moverFixture( + overrides: Partial & { operation: string }, +): McpMetricsAggregateRow { + return { + calls: 0, + ok_count: 0, + error_count: 0, + error_reasons: [], + success_tokens: 0, + error_tokens: 0, + avg_success_tokens: 0, + avg_error_tokens: 0, + max_input_tokens: 0, + max_output_tokens: 0, + max_total_tokens: 0, + max_duration_ms: 0, + input_bytes: 0, + output_bytes: 0, + total_bytes: 0, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_cost_usd: 0, + output_cost_usd: 0, + total_cost_usd: 0, + avg_cost_usd: 0, + avg_input_tokens: 0, + avg_output_tokens: 0, + total_duration_ms: 0, + avg_duration_ms: 0, + last_ts: null, + ...overrides, + }; +} + +describe('buildMoversReport', () => { + it('flags a regression where recent rate is far above prior rate', () => { + const report = buildMoversReport({ + full: [moverFixture({ operation: 'task_plan_list', calls: 7716, total_tokens: 34_930_000 })], + recent: [ + moverFixture({ operation: 'task_plan_list', calls: 2400, total_tokens: 11_000_000 }), + ], + recentHours: 24, + priorHours: 144, + recentSince: 100_000_000, + priorSince: 0, + }); + + expect(report.risers).toHaveLength(1); + const row = report.risers[0]; + expect(row?.operation).toBe('task_plan_list'); + expect(row?.state).toBe('changed'); + expect(row?.recent_calls).toBe(2400); + expect(row?.prior_calls).toBe(5316); + expect(row?.calls_delta_pct).not.toBeNull(); + expect(row?.calls_delta_pct ?? 0).toBeGreaterThan(150); + }); + + it('classifies a new operation that only appears in the recent window', () => { + const report = buildMoversReport({ + full: [moverFixture({ operation: 'new_op', calls: 40, total_tokens: 8000 })], + recent: [moverFixture({ operation: 'new_op', calls: 40, total_tokens: 8000 })], + recentHours: 12, + priorHours: 156, + recentSince: 100_000_000, + priorSince: 0, + }); + + expect(report.risers).toHaveLength(1); + expect(report.risers[0]?.state).toBe('new'); + expect(report.risers[0]?.prior_calls).toBe(0); + }); + + it('classifies a gone operation that only had prior activity', () => { + const report = buildMoversReport({ + full: [moverFixture({ operation: 'old_op', calls: 80, total_tokens: 20_000 })], + recent: [], + recentHours: 24, + priorHours: 144, + recentSince: 100_000_000, + priorSince: 0, + }); + + expect(report.fallers).toHaveLength(1); + expect(report.fallers[0]?.state).toBe('gone'); + expect(report.fallers[0]?.recent_calls).toBe(0); + }); + + it('surfaces error risers separately when error count triples', () => { + const report = buildMoversReport({ + full: [ + moverFixture({ + operation: 'task_note_working', + calls: 28, + error_count: 14, + }), + ], + recent: [ + moverFixture({ + operation: 'task_note_working', + calls: 12, + error_count: 12, + }), + ], + recentHours: 24, + priorHours: 144, + recentSince: 100_000_000, + priorSince: 0, + }); + + expect(report.error_risers).toHaveLength(1); + expect(report.error_risers[0]?.recent_errors).toBe(12); + expect(report.error_risers[0]?.prior_errors).toBe(2); + }); + + it('filters out low-volume noise below the minimum call threshold', () => { + const report = buildMoversReport({ + full: [moverFixture({ operation: 'tiny_op', calls: 4, total_tokens: 100 })], + recent: [moverFixture({ operation: 'tiny_op', calls: 4, total_tokens: 100 })], + recentHours: 24, + priorHours: 144, + recentSince: 100_000_000, + priorSince: 0, + }); + + expect(report.risers).toHaveLength(0); + expect(report.fallers).toHaveLength(0); + expect(report.error_risers).toHaveLength(0); + }); +}); + +describe('writeMoversSection', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders Movers header with recent and prior labels plus a riser row', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }); + + writeMoversSection({ + recent_hours: 24, + prior_hours: 144, + recent_since: 100_000_000, + prior_since: 0, + total_recent_calls: 2400, + total_prior_calls: 5316, + risers: [ + { + operation: 'task_plan_list', + recent_calls: 2400, + prior_calls: 5316, + recent_tokens: 11_000_000, + prior_tokens: 23_930_000, + recent_errors: 0, + prior_errors: 0, + recent_rate: 100, + prior_rate: 36.9, + calls_delta_pct: 170, + tokens_delta_pct: 180, + errors_delta_abs: 0, + state: 'changed', + }, + ], + fallers: [], + error_risers: [], + skipped_reason: null, + }); + + expect(output).toContain('Movers'); + expect(output).toContain('(last 1d vs prior 6d)'); + expect(output).toContain('task_plan_list'); + expect(output).toContain('+170%'); + expect(output).toContain('+180%'); + }); + + it('emits nothing when the report has no risers, fallers, or error risers', () => { + let output = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + output += String(chunk); + return true; + }); + + writeMoversSection({ + recent_hours: 24, + prior_hours: 144, + recent_since: 100_000_000, + prior_since: 0, + total_recent_calls: 10, + total_prior_calls: 100, + risers: [], + fallers: [], + error_risers: [], + skipped_reason: null, + }); + + expect(output).toBe(''); + }); +}); diff --git a/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/.openspec.yaml b/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/.openspec.yaml new file mode 100644 index 0000000..66dd08a --- /dev/null +++ b/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-14 diff --git a/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/notes.md b/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/notes.md new file mode 100644 index 0000000..299e2f9 --- /dev/null +++ b/openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/notes.md @@ -0,0 +1,32 @@ +# agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56 (minimal / T1) + +Branch: `agent/claude/add-movers-section-to-colony-gain-trend-2026-05-14-13-56` + +Add temporal regression detection to `colony gain` so the user can spot ops that +suddenly ramped (or stopped) in the recent window vs the rest of the queried period. + +- The CLI runs `aggregateMcpMetrics` twice: once for the full requested window + (existing call) and once for the trailing "recent" segment. Prior counts/tokens/ + errors are derived by subtraction. No new storage method. +- Default split: `recent_hours = max(windowHours / 7, 1)`. For the default 168h + window, that's 24h recent vs 144h prior. `--recent-hours ` overrides. +- A row qualifies as a riser when its per-hour call rate is ≥ 2x the prior rate + with ≥ 5 recent calls, or when it's brand new with ≥ 5 recent calls. Fallers + use the mirror condition. Error risers fire when recent errors ≥ 3x prior and + ≥ 3 absolute. Limits: top 3 risers + top 3 fallers + top 3 error risers. +- `(new)` and `(gone)` states render distinctly from percentage deltas so a brand- + new hot loop isn't reported as "+∞%". +- `--no-movers` suppresses the section; the section is also silent on windows < 4h. +- JSON output gains `live.movers` with the same shape as the rendered rows so + downstream tooling can consume the signal. + +## Handoff + +- Handoff: change=`agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56`; branch=`agent//`; scope=`TODO`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56` on branch `agent//`. Work inside the existing sandbox, review `openspec/changes/agent-claude-add-movers-section-to-colony-gain-trend-2026-05-14-13-56/notes.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup`. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent// --base dev --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL + `MERGED` state in the completion handoff. +- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).