Skip to content

Commit 53836ff

Browse files
NagyViktNagyViktclaude
authored
feat(cli): health --coach mode for first-week adoption walkthrough (#576)
* feat(cli): add health --coach mode for first-week adoption walkthrough - New colony health --coach flag; mutex with --fix-plan - 7-step ladder (install_runtime through first_gain_review) - Stage detection: fresh / installed_no_signal / early / mid_adoption - New coach_progress SQLite table (migration 014, schema_version 14) - markCoachStep / listCoachSteps / firstObservationTs storage methods - Shared installed-ides helper between status and health-coach - colony gain emits coach_gain_review observation for step 7 detection Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: add changeset for health coach mode Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a83eeea commit 53836ff

13 files changed

Lines changed: 763 additions & 44 deletions

File tree

.changeset/health-coach-mode.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'colonyq': minor
3+
'@colony/storage': minor
4+
---
5+
6+
`colony health --coach` walks a repo through first-week setup. It detects
7+
adoption stage (`fresh` / `installed_no_signal` / `early` / `mid_adoption`)
8+
from cheap signals (`countObservations`, installed-IDE flags,
9+
`firstObservationTs`, `Math.max(toolCallsSince, countMcpMetricsSince)`),
10+
then surfaces the NEXT incomplete step from a fixed 7-step ladder:
11+
`install_runtime``first_task_post``first_task_claim_file`
12+
`first_task_hand_off``first_plan_claim``first_quota_release`
13+
`first_gain_review`. Each step carries an exact `cmd:` and `tool:` string.
14+
15+
Progress is persisted in a new `coach_progress` SQLite table (migration
16+
`014-coach-progress.ts`, schema_version 13 → 14). Step completion is
17+
event-observed via `mcp_metrics` / `observations`, never user-clicked.
18+
`colony gain` records a `coach_gain_review` observation so step 7 can
19+
self-detect. `--coach` is mutually exclusive with `--fix-plan` and respects
20+
`--json`.

apps/cli/src/commands/gain.ts

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,16 @@ import type {
2020
} from '@colony/storage';
2121
import type { Command } from 'commander';
2222
import kleur from 'kleur';
23-
import { withStorage } from '../util/store.js';
23+
import { withStorage, withStore } from '../util/store.js';
24+
25+
/**
26+
* Observation kind written by `colony gain` to mark a savings-review
27+
* invocation. `colony health --coach` reads this kind to detect step 7 of
28+
* the first-week ladder ("review your savings"). Kept in sync with
29+
* `apps/cli/src/commands/health-coach.ts::GAIN_REVIEW_OBSERVATION_KIND`.
30+
*/
31+
const COACH_GAIN_REVIEW_KIND = 'coach_gain_review';
32+
const COACH_GAIN_REVIEW_SESSION_ID = 'observer';
2433

2534
interface GainOptions {
2635
json?: boolean;
@@ -135,8 +144,7 @@ export function registerGainCommand(program: Command): void {
135144
const recentHours = resolveRecentHours(opts.recentHours, windowHours);
136145
const recentSince = recentHours !== null ? now - recentHours * 60 * 60_000 : null;
137146

138-
const summaryRequested =
139-
opts.summary === true || opts.graph === true || opts.daily === true;
147+
const summaryRequested = opts.summary === true || opts.graph === true || opts.daily === true;
140148
const dailyDays = parsePositiveInt(opts.days) ?? 30;
141149
const topOpsLimit = parsePositiveInt(opts.topOps) ?? 10;
142150
const dailySince = summaryRequested
@@ -224,6 +232,16 @@ export function registerGainCommand(program: Command): void {
224232
...livePayload,
225233
};
226234

235+
// Record a lightweight `coach_gain_review` observation so the coach
236+
// walkthrough can detect step 7 ("review savings") on the next
237+
// `colony health --coach`. Best-effort: a failure here must never
238+
// mask the gain output the user came for.
239+
try {
240+
await recordCoachGainReview(opts);
241+
} catch {
242+
// Swallow — see comment above.
243+
}
244+
227245
if (opts.json === true) {
228246
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
229247
return;
@@ -349,6 +367,27 @@ export function registerGainCommand(program: Command): void {
349367
});
350368
}
351369

370+
async function recordCoachGainReview(opts: GainOptions): Promise<void> {
371+
const settings = loadSettings();
372+
await withStore(settings, (store) => {
373+
store.startSession({
374+
id: COACH_GAIN_REVIEW_SESSION_ID,
375+
ide: 'observer',
376+
cwd: process.cwd(),
377+
});
378+
store.addObservation({
379+
session_id: COACH_GAIN_REVIEW_SESSION_ID,
380+
kind: COACH_GAIN_REVIEW_KIND,
381+
content: 'colony gain invocation recorded by the coach walkthrough',
382+
metadata: {
383+
summary: opts.summary === true,
384+
json: opts.json === true,
385+
operation: opts.operation ?? null,
386+
},
387+
});
388+
});
389+
}
390+
352391
export function writeGainReport(
353392
referenceRows: ReadonlyArray<SavingsReferenceRow>,
354393
referenceTotals: SavingsReferenceTotals,
@@ -1284,10 +1323,7 @@ export function renderImpactBar(value: number, max: number, width: number): stri
12841323
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || width <= 0) {
12851324
return '░'.repeat(Math.max(0, width));
12861325
}
1287-
const filled = Math.min(
1288-
width,
1289-
Math.max(0, Math.round((Math.max(0, value) / max) * width)),
1290-
);
1326+
const filled = Math.min(width, Math.max(0, Math.round((Math.max(0, value) / max) * width)));
12911327
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
12921328
}
12931329

@@ -1394,7 +1430,9 @@ export function writeSummaryReport(input: SummaryReportInput): void {
13941430

13951431
if (showHeadline) {
13961432
const filter = operationFilter ? ` (op=${operationFilter})` : '';
1397-
w.write(`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`);
1433+
w.write(
1434+
`${kleur.bold(`Colony Token Savings (last ${formatHoursLabel(windowHours)}${filter})`)}\n`,
1435+
);
13981436
w.write(`${HEAVY_RULE}\n`);
13991437
writeSummaryHeadline(totals, comparison, costBasis);
14001438

@@ -1461,20 +1499,15 @@ function writeSummaryHeadline(
14611499
);
14621500
}
14631501
if (savingsPct !== null) {
1464-
const savedLabel = savedTokens >= 0
1465-
? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})`
1466-
: `${formatTokens(Math.abs(savedTokens))} over (${formatPctSigned(savingsPct)})`;
1502+
const savedLabel =
1503+
savedTokens >= 0
1504+
? `${formatTokens(savedTokens)} (${formatPctSigned(savingsPct)})`
1505+
: `${formatTokens(Math.abs(savedTokens))} over (${formatPctSigned(savingsPct)})`;
14671506
lines.push(['Tokens saved:', savedLabel]);
14681507
} else {
1469-
lines.push([
1470-
'Tokens saved:',
1471-
kleur.dim('— (no reference baseline matched in this window)'),
1472-
]);
1508+
lines.push(['Tokens saved:', kleur.dim('— (no reference baseline matched in this window)')]);
14731509
}
1474-
lines.push([
1475-
'Total exec time:',
1476-
`${formatDurationMs(totalMs)} (avg ${formatDurationMs(avgMs)})`,
1477-
]);
1510+
lines.push(['Total exec time:', `${formatDurationMs(totalMs)} (avg ${formatDurationMs(avgMs)})`]);
14781511

14791512
for (const [label, value] of lines) {
14801513
w.write(`${padVisible(kleur.dim(label), labelWidth)}${value}\n`);
@@ -1489,9 +1522,7 @@ function writeSummaryHeadline(
14891522
const colored = colorByEfficiency(meterPct, `${meter} ${pctLabel}`);
14901523
w.write(`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${colored}\n`);
14911524
} else {
1492-
w.write(
1493-
`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${kleur.dim('—')}\n`,
1494-
);
1525+
w.write(`${padVisible(kleur.dim('Efficiency meter:'), labelWidth)}${kleur.dim('—')}\n`);
14951526
}
14961527
}
14971528

@@ -1530,8 +1561,8 @@ function writeSummaryByOperation(
15301561
}
15311562

15321563
const sorted = [...operations].sort((a, b) => {
1533-
const savedA = savedByOp.get(a.operation) ?? -Infinity;
1534-
const savedB = savedByOp.get(b.operation) ?? -Infinity;
1564+
const savedA = savedByOp.get(a.operation) ?? Number.NEGATIVE_INFINITY;
1565+
const savedB = savedByOp.get(b.operation) ?? Number.NEGATIVE_INFINITY;
15351566
if (savedA !== savedB) return savedB - savedA;
15361567
return b.total_tokens - a.total_tokens;
15371568
});
@@ -1584,15 +1615,14 @@ function writeSummaryByOperation(
15841615
w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`);
15851616
}
15861617

1587-
function writeSummaryDailyGraph(
1588-
daily: ReadonlyArray<McpMetricsDailyRow>,
1589-
days: number,
1590-
): void {
1618+
function writeSummaryDailyGraph(daily: ReadonlyArray<McpMetricsDailyRow>, days: number): void {
15911619
const w = process.stdout;
15921620
const window = fillDailyWindow(daily, days);
15931621
const maxTokens = window.reduce((m, row) => Math.max(m, row.total_tokens), 0);
15941622
w.write(`${kleur.bold(`Daily Activity (last ${days} days)`)}\n`);
1595-
w.write(`${kleur.dim('-'.repeat(SUMMARY_GRAPH_LABEL_WIDTH + 3 + SUMMARY_GRAPH_BAR_WIDTH + 1 + SUMMARY_GRAPH_VALUE_WIDTH))}\n`);
1623+
w.write(
1624+
`${kleur.dim('-'.repeat(SUMMARY_GRAPH_LABEL_WIDTH + 3 + SUMMARY_GRAPH_BAR_WIDTH + 1 + SUMMARY_GRAPH_VALUE_WIDTH))}\n`,
1625+
);
15961626
if (maxTokens === 0) {
15971627
w.write(kleur.dim(' (no token activity in window)\n'));
15981628
return;
@@ -1607,10 +1637,7 @@ function writeSummaryDailyGraph(
16071637
}
16081638
}
16091639

1610-
function writeSummaryDailyBreakdown(
1611-
daily: ReadonlyArray<McpMetricsDailyRow>,
1612-
days: number,
1613-
): void {
1640+
function writeSummaryDailyBreakdown(daily: ReadonlyArray<McpMetricsDailyRow>, days: number): void {
16141641
const w = process.stdout;
16151642
const window = fillDailyWindow(daily, days).slice(-SUMMARY_BREAKDOWN_LIMIT);
16161643
const totals = window.reduce(
@@ -1625,7 +1652,9 @@ function writeSummaryDailyBreakdown(
16251652
{ calls: 0, input_tokens: 0, output_tokens: 0, total_tokens: 0, total_duration_ms: 0 },
16261653
);
16271654

1628-
w.write(`${kleur.bold(`Daily Breakdown (${window.length} day${window.length === 1 ? '' : 's'})`)}\n`);
1655+
w.write(
1656+
`${kleur.bold(`Daily Breakdown (${window.length} day${window.length === 1 ? '' : 's'})`)}\n`,
1657+
);
16291658
const ruleWidth = 74;
16301659
w.write(`${kleur.dim('='.repeat(ruleWidth))}\n`);
16311660
const head = [

0 commit comments

Comments
 (0)