Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/health-coach-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'colonyq': minor
'@colony/storage': minor
---

`colony health --coach` walks a repo through first-week setup. It detects
adoption stage (`fresh` / `installed_no_signal` / `early` / `mid_adoption`)
from cheap signals (`countObservations`, installed-IDE flags,
`firstObservationTs`, `Math.max(toolCallsSince, countMcpMetricsSince)`),
then surfaces the NEXT incomplete step from a fixed 7-step ladder:
`install_runtime` → `first_task_post` → `first_task_claim_file` →
`first_task_hand_off` → `first_plan_claim` → `first_quota_release` →
`first_gain_review`. Each step carries an exact `cmd:` and `tool:` string.

Progress is persisted in a new `coach_progress` SQLite table (migration
`014-coach-progress.ts`, schema_version 13 → 14). Step completion is
event-observed via `mcp_metrics` / `observations`, never user-clicked.
`colony gain` records a `coach_gain_review` observation so step 7 can
self-detect. `--coach` is mutually exclusive with `--fix-plan` and respects
`--json`.
97 changes: 63 additions & 34 deletions apps/cli/src/commands/gain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ import type {
} from '@colony/storage';
import type { Command } from 'commander';
import kleur from 'kleur';
import { withStorage } from '../util/store.js';
import { withStorage, withStore } from '../util/store.js';

/**
* Observation kind written by `colony gain` to mark a savings-review
* invocation. `colony health --coach` reads this kind to detect step 7 of
* the first-week ladder ("review your savings"). Kept in sync with
* `apps/cli/src/commands/health-coach.ts::GAIN_REVIEW_OBSERVATION_KIND`.
*/
const COACH_GAIN_REVIEW_KIND = 'coach_gain_review';
const COACH_GAIN_REVIEW_SESSION_ID = 'observer';

interface GainOptions {
json?: boolean;
Expand Down Expand Up @@ -123,8 +132,7 @@ export function registerGainCommand(program: Command): void {
const recentHours = resolveRecentHours(opts.recentHours, windowHours);
const recentSince = recentHours !== null ? now - recentHours * 60 * 60_000 : null;

const summaryRequested =
opts.summary === true || opts.graph === true || opts.daily === true;
const summaryRequested = opts.summary === true || opts.graph === true || opts.daily === true;
const dailyDays = parsePositiveInt(opts.days) ?? 30;
const topOpsLimit = parsePositiveInt(opts.topOps) ?? 10;
const dailySince = summaryRequested
Expand Down Expand Up @@ -212,6 +220,16 @@ export function registerGainCommand(program: Command): void {
...livePayload,
};

// Record a lightweight `coach_gain_review` observation so the coach
// walkthrough can detect step 7 ("review savings") on the next
// `colony health --coach`. Best-effort: a failure here must never
// mask the gain output the user came for.
try {
await recordCoachGainReview(opts);
} catch {
// Swallow — see comment above.
}

if (opts.json === true) {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
Expand Down Expand Up @@ -252,6 +270,27 @@ export function registerGainCommand(program: Command): void {
});
}

async function recordCoachGainReview(opts: GainOptions): Promise<void> {
const settings = loadSettings();
await withStore(settings, (store) => {
store.startSession({
id: COACH_GAIN_REVIEW_SESSION_ID,
ide: 'observer',
cwd: process.cwd(),
});
store.addObservation({
session_id: COACH_GAIN_REVIEW_SESSION_ID,
kind: COACH_GAIN_REVIEW_KIND,
content: 'colony gain invocation recorded by the coach walkthrough',
metadata: {
summary: opts.summary === true,
json: opts.json === true,
operation: opts.operation ?? null,
},
});
});
}

export function writeGainReport(
referenceRows: ReadonlyArray<SavingsReferenceRow>,
referenceTotals: SavingsReferenceTotals,
Expand Down Expand Up @@ -1181,10 +1220,7 @@ export function renderImpactBar(value: number, max: number, width: number): stri
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || width <= 0) {
return '░'.repeat(Math.max(0, width));
}
const filled = Math.min(
width,
Math.max(0, Math.round((Math.max(0, value) / max) * width)),
);
const filled = Math.min(width, Math.max(0, Math.round((Math.max(0, value) / max) * width)));
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
}

Expand Down Expand Up @@ -1291,7 +1327,9 @@ export function writeSummaryReport(input: SummaryReportInput): void {

if (showHeadline) {
const filter = operationFilter ? ` (op=${operationFilter})` : '';
w.write(`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`);
w.write(
`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`,
);
w.write(`${HEAVY_RULE}\n`);
writeSummaryHeadline(totals, comparison, costBasis);

Expand Down Expand Up @@ -1358,20 +1396,15 @@ function writeSummaryHeadline(
);
}
if (savingsPct !== null) {
const savedLabel = savedTokens >= 0
? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})`
: `${formatTokens(Math.abs(savedTokens))} over (${formatPctSigned(savingsPct)})`;
const savedLabel =
savedTokens >= 0
? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})`
: `${formatTokens(Math.abs(savedTokens))} over (${formatPctSigned(savingsPct)})`;
lines.push(['Tokens saved:', savedLabel]);
} else {
lines.push([
'Tokens saved:',
kleur.dim('— (no reference baseline matched in this window)'),
]);
lines.push(['Tokens saved:', kleur.dim('— (no reference baseline matched in this window)')]);
}
lines.push([
'Total exec time:',
`${formatDurationMs(totalMs)} (avg ${formatDurationMs(avgMs)})`,
]);
lines.push(['Total exec time:', `${formatDurationMs(totalMs)} (avg ${formatDurationMs(avgMs)})`]);

for (const [label, value] of lines) {
w.write(`${padVisible(kleur.dim(label), labelWidth)}${value}\n`);
Expand All @@ -1386,9 +1419,7 @@ function writeSummaryHeadline(
const colored = colorByEfficiency(meterPct, `${meter} ${pctLabel}`);
w.write(`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${colored}\n`);
} else {
w.write(
`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${kleur.dim('—')}\n`,
);
w.write(`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${kleur.dim('—')}\n`);
}
}

Expand Down Expand Up @@ -1427,8 +1458,8 @@ function writeSummaryByOperation(
}

const sorted = [...operations].sort((a, b) => {
const savedA = savedByOp.get(a.operation) ?? -Infinity;
const savedB = savedByOp.get(b.operation) ?? -Infinity;
const savedA = savedByOp.get(a.operation) ?? Number.NEGATIVE_INFINITY;
const savedB = savedByOp.get(b.operation) ?? Number.NEGATIVE_INFINITY;
if (savedA !== savedB) return savedB - savedA;
return b.total_tokens - a.total_tokens;
});
Expand Down Expand Up @@ -1481,15 +1512,14 @@ function writeSummaryByOperation(
w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`);
}

function writeSummaryDailyGraph(
daily: ReadonlyArray<McpMetricsDailyRow>,
days: number,
): void {
function writeSummaryDailyGraph(daily: ReadonlyArray<McpMetricsDailyRow>, days: number): void {
const w = process.stdout;
const window = fillDailyWindow(daily, days);
const maxTokens = window.reduce((m, row) => Math.max(m, row.total_tokens), 0);
w.write(`${kleur.bold(`Daily Activity (last ${days} days)`)}\n`);
w.write(`${kleur.dim('-'.repeat(SUMMARY_GRAPH_LABEL_WIDTH + 3 + SUMMARY_GRAPH_BAR_WIDTH + 1 + SUMMARY_GRAPH_VALUE_WIDTH))}\n`);
w.write(
`${kleur.dim('-'.repeat(SUMMARY_GRAPH_LABEL_WIDTH + 3 + SUMMARY_GRAPH_BAR_WIDTH + 1 + SUMMARY_GRAPH_VALUE_WIDTH))}\n`,
);
if (maxTokens === 0) {
w.write(kleur.dim(' (no token activity in window)\n'));
return;
Expand All @@ -1504,10 +1534,7 @@ function writeSummaryDailyGraph(
}
}

function writeSummaryDailyBreakdown(
daily: ReadonlyArray<McpMetricsDailyRow>,
days: number,
): void {
function writeSummaryDailyBreakdown(daily: ReadonlyArray<McpMetricsDailyRow>, days: number): void {
const w = process.stdout;
const window = fillDailyWindow(daily, days).slice(-SUMMARY_BREAKDOWN_LIMIT);
const totals = window.reduce(
Expand All @@ -1522,7 +1549,9 @@ function writeSummaryDailyBreakdown(
{ calls: 0, input_tokens: 0, output_tokens: 0, total_tokens: 0, total_duration_ms: 0 },
);

w.write(`${kleur.bold(`Daily Breakdown (${window.length} day${window.length === 1 ? '' : 's'})`)}\n`);
w.write(
`${kleur.bold(`Daily Breakdown (${window.length} day${window.length === 1 ? '' : 's'})`)}\n`,
);
const ruleWidth = 74;
w.write(`${kleur.dim('='.repeat(ruleWidth))}\n`);
const head = [
Expand Down
Loading
Loading