diff --git a/.changeset/health-coach-mode.md b/.changeset/health-coach-mode.md new file mode 100644 index 0000000..d52487f --- /dev/null +++ b/.changeset/health-coach-mode.md @@ -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`. diff --git a/apps/cli/src/commands/gain.ts b/apps/cli/src/commands/gain.ts index 3864a6e..7b3140d 100644 --- a/apps/cli/src/commands/gain.ts +++ b/apps/cli/src/commands/gain.ts @@ -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; @@ -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 @@ -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; @@ -252,6 +270,27 @@ export function registerGainCommand(program: Command): void { }); } +async function recordCoachGainReview(opts: GainOptions): Promise { + 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, referenceTotals: SavingsReferenceTotals, @@ -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)); } @@ -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); @@ -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`); @@ -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`); } } @@ -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; }); @@ -1481,15 +1512,14 @@ function writeSummaryByOperation( w.write(`${kleur.dim('-'.repeat(SUMMARY_TABLE_WIDTH))}\n`); } -function writeSummaryDailyGraph( - daily: ReadonlyArray, - days: number, -): void { +function writeSummaryDailyGraph(daily: ReadonlyArray, 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; @@ -1504,10 +1534,7 @@ function writeSummaryDailyGraph( } } -function writeSummaryDailyBreakdown( - daily: ReadonlyArray, - days: number, -): void { +function writeSummaryDailyBreakdown(daily: ReadonlyArray, days: number): void { const w = process.stdout; const window = fillDailyWindow(daily, days).slice(-SUMMARY_BREAKDOWN_LIMIT); const totals = window.reduce( @@ -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 = [ diff --git a/apps/cli/src/commands/health-coach.ts b/apps/cli/src/commands/health-coach.ts new file mode 100644 index 0000000..e66a07c --- /dev/null +++ b/apps/cli/src/commands/health-coach.ts @@ -0,0 +1,326 @@ +import type { Settings } from '@colony/config'; +import type { ClaimBeforeEditStats, CoachStepRow, Storage, ToolCallRow } from '@colony/storage'; +import { listInstalledIdes } from '../lib/installed-ides.js'; + +const SEVEN_DAYS_MS = 7 * 24 * 3_600_000; +const EARLY_OBSERVATION_THRESHOLD = 50; +const GAIN_REVIEW_OBSERVATION_KIND = 'coach_gain_review'; + +/** + * Where on the first-week ladder the repo currently sits. The four buckets + * map to the renderer's tone: `fresh` greets a new install, `installed_no_signal` + * nudges the user to actually fire a tool, `early` celebrates first signal and + * surfaces the next habit, `mid_adoption` thins out into a "you're cruising" + * banner so the coach doesn't outstay its welcome. + */ +export type CoachStage = 'fresh' | 'installed_no_signal' | 'early' | 'mid_adoption'; + +/** + * One rung on the 7-step adoption ladder. `done_when` is encoded in + * {@link buildCoachPayload} as a predicate against tool calls / observation + * counts — never as a click. `cmd` and `tool` are surfaced verbatim by the + * renderer so the user can copy-paste the next move. + */ +export interface CoachStep { + /** Stable identifier; also the SQLite primary key in coach_progress. */ + id: string; + /** Short human title for the renderer. */ + title: string; + /** Concrete CLI command the user should run next. */ + cmd: string; + /** MCP tool that satisfies the step when fired by the agent. */ + tool: string; + /** What event the coach observes to mark this step complete. */ + done_when: string; +} + +export interface CoachCompletedStep extends CoachStep { + completed_at: number; + evidence: string | null; +} + +export interface CoachPayload { + stage: CoachStage; + /** True iff the coach saw no observations and no installed IDEs. */ + fresh_repo: boolean; + /** Total observations recorded by the local store. */ + observation_count: number; + /** IDE names currently flagged installed in settings. */ + installed_ides: string[]; + /** Steps that have already been completed (and persisted to coach_progress). */ + completed_steps: CoachCompletedStep[]; + /** The next incomplete step, or null if the user has finished the ladder. */ + next_step: CoachStep | null; + /** Remaining steps after `next_step`, in ladder order. */ + upcoming: CoachStep[]; +} + +export interface BuildCoachPayloadOptions { + /** + * Window start for tool-call evidence. Defaults to "since the beginning of + * time" — the coach is interested in lifetime first-fires, not recent + * activity, but tests pin this to a fixed cutoff. + */ + since?: number; + now?: number; +} + +/** + * The 7-step ladder. Order matters: the renderer surfaces them by index, so + * step N+1 only shows up after step N is marked complete. + */ +export const COACH_LADDER: readonly CoachStep[] = [ + { + id: 'install_runtime', + title: 'Install a colony runtime', + cmd: 'colony install --ide codex', + tool: 'colony install', + done_when: 'any IDE is flagged installed in settings.ides', + }, + { + id: 'first_task_post', + title: 'Post your first task note', + cmd: 'mcp__colony__task_post({ task_id, session_id, kind: "note", content: "branch=...; task=...; next=..." })', + tool: 'mcp__colony__task_post', + done_when: 'any task_post call recorded in tool_calls', + }, + { + id: 'first_task_claim_file', + title: 'Claim a file before editing it', + cmd: 'mcp__colony__task_claim_file({ task_id, session_id, file_path: "..." })', + tool: 'mcp__colony__task_claim_file', + done_when: 'any task_claim_file call OR pre-edit claim observed', + }, + { + id: 'first_task_hand_off', + title: 'Hand off ownership of a lane', + cmd: 'mcp__colony__task_hand_off({ task_id, session_id, released_files: [...], summary: "..." })', + tool: 'mcp__colony__task_hand_off', + done_when: 'any task_hand_off call recorded', + }, + { + id: 'first_plan_claim', + title: 'Claim a plan subtask (close the 0/47 gap)', + cmd: 'mcp__colony__task_plan_claim_subtask({ plan_id, subtask_id, session_id })', + tool: 'mcp__colony__task_plan_claim_subtask', + done_when: 'any task_plan_claim_subtask call recorded', + }, + { + id: 'first_quota_release', + title: 'Accept your first quota relay', + cmd: 'mcp__colony__task_claim_quota_accept({ task_id, session_id, handoff_observation_id })', + tool: 'mcp__colony__task_claim_quota_accept', + done_when: 'any task_claim_quota_accept call recorded', + }, + { + id: 'first_gain_review', + title: 'Review your savings with `colony gain`', + cmd: 'colony gain --summary', + tool: 'colony gain', + done_when: 'a coach_gain_review observation is recorded by colony gain', + }, +]; + +/** + * Build the coach payload from local store evidence. This is the single place + * that knows the ladder ordering, the `done_when` predicates, and the stage + * classifier. It also persists newly-completed steps to `coach_progress` so + * the next invocation shows progress without re-deriving from raw evidence. + */ +export function buildCoachPayload( + storage: Storage, + settings: Settings, + options: BuildCoachPayloadOptions = {}, +): CoachPayload { + const now = options.now ?? Date.now(); + const since = options.since ?? 0; + const installedIdes = listInstalledIdes(settings); + const observationCount = storage.countObservations(); + const calls = storage.toolCallsSince(since); + const claimStats = storage.claimBeforeEditStats(since); + + const completedSet = new Set(storage.listCoachSteps().map((row: CoachStepRow) => row.step_id)); + + const observe = (stepId: string, evidence: string | null): void => { + if (completedSet.has(stepId)) return; + storage.markCoachStep(stepId, evidence); + completedSet.add(stepId); + }; + + if (installedIdes.length > 0) { + observe('install_runtime', `ides=${installedIdes.join(',')}`); + } + detectToolStep(calls, 'task_post', 'first_task_post', observe); + if (countTool(calls, 'task_claim_file') > 0 || (claimStats.edits_claimed_before ?? 0) > 0) { + const fileCount = countTool(calls, 'task_claim_file'); + const claimedBefore = claimStats.edits_claimed_before ?? 0; + observe( + 'first_task_claim_file', + `task_claim_file=${fileCount}, edits_claimed_before=${claimedBefore}`, + ); + } + detectToolStep(calls, 'task_hand_off', 'first_task_hand_off', observe); + detectToolStep(calls, 'task_plan_claim_subtask', 'first_plan_claim', observe); + detectToolStep(calls, 'task_claim_quota_accept', 'first_quota_release', observe); + if (storage.countObservationsByKindSince(GAIN_REVIEW_OBSERVATION_KIND, 0) > 0) { + observe('first_gain_review', `kind=${GAIN_REVIEW_OBSERVATION_KIND}`); + } + + // Re-read after marks so completed_steps reflects the canonical store rows + // (including completed_at timestamps the predicates above couldn't set). + const completedRows = storage.listCoachSteps(); + const completedById = new Map(completedRows.map((row) => [row.step_id, row])); + + const completed_steps: CoachCompletedStep[] = []; + const remaining: CoachStep[] = []; + for (const step of COACH_LADDER) { + const row = completedById.get(step.id); + if (row !== undefined) { + completed_steps.push({ + ...step, + completed_at: row.completed_at, + evidence: row.evidence, + }); + } else { + remaining.push(step); + } + } + const [next_step = null, ...upcoming] = remaining; + + const fresh_repo = observationCount === 0 && installedIdes.length === 0; + const stage = classifyStage(storage, { + fresh_repo, + observationCount, + installedIdes, + calls, + claimStats, + now, + }); + + return { + stage, + fresh_repo, + observation_count: observationCount, + installed_ides: installedIdes, + completed_steps, + next_step, + upcoming, + }; +} + +function classifyStage( + storage: Storage, + ctx: { + fresh_repo: boolean; + observationCount: number; + installedIdes: string[]; + calls: ToolCallRow[]; + claimStats: ClaimBeforeEditStats; + now: number; + }, +): CoachStage { + if (ctx.fresh_repo) return 'fresh'; + const hasAnyToolCall = ctx.calls.length > 0; + const hasAnyMcpReceipt = storage.countMcpMetricsSince(0, ctx.now) > 0; + if (ctx.installedIdes.length > 0 && !hasAnyToolCall && !hasAnyMcpReceipt) { + return 'installed_no_signal'; + } + const firstTs = storage.firstObservationTs(); + const ageMs = firstTs === null ? 0 : ctx.now - firstTs; + if (ctx.observationCount < EARLY_OBSERVATION_THRESHOLD || ageMs < SEVEN_DAYS_MS) { + return 'early'; + } + return 'mid_adoption'; +} + +function detectToolStep( + calls: ToolCallRow[], + toolName: string, + stepId: string, + observe: (stepId: string, evidence: string | null) => void, +): void { + const match = calls.find((call) => isColonyTool(call.tool, toolName)); + if (match !== undefined) { + observe(stepId, `tool=${match.tool}, call_id=${match.id}`); + } +} + +function countTool(calls: ToolCallRow[], toolName: string): number { + return calls.filter((call) => isColonyTool(call.tool, toolName)).length; +} + +function isColonyTool(tool: string, toolName: string): boolean { + return tool === toolName || tool === `colony.${toolName}` || tool === `mcp__colony__${toolName}`; +} + +export interface FormatCoachOutputOptions { + json?: boolean; +} + +/** + * Render the coach payload either as compact prose (default) or as JSON. + * The prose form is intentionally numbered + indented with `cmd:` / `tool:` + * lines so it's grep-able and copy-paste-friendly. + */ +export function formatCoachOutput( + payload: CoachPayload, + options: FormatCoachOutputOptions = {}, +): string { + if (options.json === true) { + return JSON.stringify(payload, null, 2); + } + + const lines: string[] = []; + lines.push('colony health --coach'); + lines.push(''); + lines.push(stageBanner(payload)); + lines.push(''); + + if (payload.completed_steps.length > 0) { + lines.push(`Completed (${payload.completed_steps.length}/${COACH_LADDER.length}):`); + for (const step of payload.completed_steps) { + const index = COACH_LADDER.findIndex((s) => s.id === step.id) + 1; + lines.push(` ${index}. [x] ${step.title}`); + if (step.evidence !== null) { + lines.push(` evidence: ${step.evidence}`); + } + } + lines.push(''); + } + + if (payload.next_step !== null) { + const index = COACH_LADDER.findIndex((s) => s.id === payload.next_step?.id) + 1; + lines.push('Next habit:'); + lines.push(` ${index}. ${payload.next_step.title}`); + lines.push(` cmd: ${payload.next_step.cmd}`); + lines.push(` tool: ${payload.next_step.tool}`); + lines.push(` done_when: ${payload.next_step.done_when}`); + lines.push(''); + } else { + lines.push('You finished the first-week ladder. Coach has nothing left to teach.'); + lines.push(''); + } + + if (payload.upcoming.length > 0) { + lines.push('Upcoming:'); + for (const step of payload.upcoming) { + const index = COACH_LADDER.findIndex((s) => s.id === step.id) + 1; + lines.push(` ${index}. ${step.title}`); + } + } + + return lines.join('\n'); +} + +function stageBanner(payload: CoachPayload): string { + switch (payload.stage) { + case 'fresh': + return 'stage: fresh repo (no observations, no installed IDEs). Start at step 1.'; + case 'installed_no_signal': + return `stage: installed but quiet (ides=${payload.installed_ides.join(',') || 'none'}). Fire one tool to wake colony up.`; + case 'early': + return `stage: early adoption (${payload.observation_count} observations). Keep the habit going.`; + case 'mid_adoption': + return `stage: cruising (${payload.observation_count} observations). Coach will step aside soon.`; + } +} diff --git a/apps/cli/src/commands/health.ts b/apps/cli/src/commands/health.ts index 7107a3a..bde9325 100644 --- a/apps/cli/src/commands/health.ts +++ b/apps/cli/src/commands/health.ts @@ -1615,6 +1615,10 @@ export function registerHealthCommand(program: Command): void { '--merge-repo-store', 'merge claim-before-edit signals from /.omx/colony-home/data.db when present (covers per-repo COLONY_HOME redirects)', ) + .option( + '--coach', + 'first-week walkthrough: detect new vs returning repos and surface the next habit to adopt', + ) .action( async (opts: { hours: string; @@ -1627,6 +1631,7 @@ export function registerHealthCommand(program: Command): void { apply?: boolean; releaseSafeStaleClaims?: boolean; mergeRepoStore?: boolean; + coach?: boolean; }) => { const hours = parseHours(opts.hours); const recentWindowHours = parseHours(opts.recentWindowHours); @@ -1637,6 +1642,24 @@ export function registerHealthCommand(program: Command): void { const repoStorePathToMerge = repoStorePath !== null && existsSync(repoStorePath) ? repoStorePath : null; + if (opts.coach === true) { + if (opts.fixPlan === true) { + // `--coach` and `--fix-plan` are mutually exclusive surfaces; + // surface the override on stderr so it's visible in scripts and + // let `--coach` win (the friendlier of the two). + process.stderr.write('--coach and --fix-plan are mutually exclusive; --coach wins\n'); + } + const { withStorage } = await import('../util/store.js'); + const { buildCoachPayload, formatCoachOutput } = await import('./health-coach.js'); + // Writable store: coach needs to persist `coach_progress` rows + // when a `done_when` predicate fires for the first time. + await withStorage(settings, (storage) => { + const payload = buildCoachPayload(storage, settings); + process.stdout.write(`${formatCoachOutput(payload, { json: opts.json === true })}\n`); + }); + return; + } + if (opts.fixPlan === true) { const { withStore } = await import('../util/store.js'); await withStore(settings, async (store) => { @@ -1908,9 +1931,7 @@ function buildCoordinationSweepDiffPayload( const skippedClaims = result.skipped_dirty_claims ?? []; const staleClaims = result.stale_claims ?? []; const skippedKeys = new Set(skippedClaims.map(staleClaimDiffKey)); - const releasable = staleClaims.filter( - (claim) => !skippedKeys.has(staleClaimDiffKey(claim)), - ); + const releasable = staleClaims.filter((claim) => !skippedKeys.has(staleClaimDiffKey(claim))); const projectedReleased = releasable.filter( (claim) => claim.cleanup_action === 'expire_weak_claim', ).length; @@ -1918,9 +1939,7 @@ function buildCoordinationSweepDiffPayload( const releasedClaims = opts.mode === 'actual' ? safe.released_claims : safe.released_claims + projectedReleased; const downgradedClaims = - opts.mode === 'actual' - ? safe.downgraded_claims - : safe.downgraded_claims + projectedDowngraded; + opts.mode === 'actual' ? safe.downgraded_claims : safe.downgraded_claims + projectedDowngraded; const releasedQuotaPendingClaims = safe.released_quota_pending_claims; return { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index b15da1b..8d973e6 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -10,6 +10,7 @@ import { formatGitGuardexLanesOutput, readGitGuardexLanes, } from '../lib/gitguardex.js'; +import { listInstalledIdes } from '../lib/installed-ides.js'; import { dataDbPath, withStorage } from '../util/store.js'; interface WorkerState { @@ -115,9 +116,7 @@ export function registerStatusCommand(program: Command): void { process.exitCode = 1; } - const enabled = Object.entries(settings.ides) - .filter(([, v]) => v) - .map(([k]) => k); + const enabled = listInstalledIdes(settings); const state = readWorkerState(dir); const provider = settings.embedding.provider; const model = settings.embedding.model; diff --git a/apps/cli/src/lib/installed-ides.ts b/apps/cli/src/lib/installed-ides.ts new file mode 100644 index 0000000..0dbf762 --- /dev/null +++ b/apps/cli/src/lib/installed-ides.ts @@ -0,0 +1,15 @@ +import type { Settings } from '@colony/config'; + +/** + * Names of IDEs that the user has flagged as installed in their settings. + * + * Both `status` and `health --coach` need this list, so it lives in a + * shared lib helper to avoid drift between the two surfaces. The order + * follows the insertion order of `settings.ides`, which keeps the output + * stable across runs. + */ +export function listInstalledIdes(settings: Settings): string[] { + return Object.entries(settings.ides) + .filter(([, enabled]) => enabled) + .map(([name]) => name); +} diff --git a/apps/cli/test/health-coach.test.ts b/apps/cli/test/health-coach.test.ts new file mode 100644 index 0000000..6b56d33 --- /dev/null +++ b/apps/cli/test/health-coach.test.ts @@ -0,0 +1,175 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import type { Settings } from '@colony/config'; +import { Storage } from '@colony/storage'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + COACH_LADDER, + buildCoachPayload, + formatCoachOutput, +} from '../src/commands/health-coach.js'; + +let dataDir: string; +let storage: Storage; + +function makeSettings(overrides: { ides?: Record } = {}): Settings { + return { + ...defaultSettings, + ides: overrides.ides ?? {}, + } as Settings; +} + +function seedSession(id: string): void { + storage.createSession({ + id, + ide: 'codex', + cwd: '/tmp/test', + started_at: Date.now() - 60_000, + metadata: null, + }); +} + +function insertToolCall(sessionId: string, tool: string, ts: number = Date.now()): void { + // Mirrors what hook handlers write: kind='tool_use' + metadata.tool=. + storage.insertObservation({ + session_id: sessionId, + kind: 'tool_use', + content: `tool ${tool}`, + compressed: false, + intensity: null, + ts, + metadata: { tool }, + }); +} + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'colony-coach-')); + storage = new Storage(join(dataDir, 'data.db')); +}); + +afterEach(() => { + storage.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('colony health --coach', () => { + it('classifies a brand-new repo as fresh and points at step 1', () => { + const payload = buildCoachPayload(storage, makeSettings()); + expect(payload.stage).toBe('fresh'); + expect(payload.fresh_repo).toBe(true); + expect(payload.completed_steps).toEqual([]); + expect(payload.next_step?.id).toBe('install_runtime'); + expect(payload.upcoming).toHaveLength(COACH_LADDER.length - 1); + }); + + it('marks install_runtime as complete when an IDE is flagged installed', () => { + const payload = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + expect(payload.fresh_repo).toBe(false); + expect(payload.stage).toBe('installed_no_signal'); + expect(payload.completed_steps[0]?.id).toBe('install_runtime'); + expect(payload.completed_steps[0]?.evidence).toContain('codex'); + expect(payload.next_step?.id).toBe('first_task_post'); + }); + + it('marks first_task_post when an mcp__colony__task_post call is observed', () => { + seedSession('s1'); + insertToolCall('s1', 'mcp__colony__task_post'); + + const payload = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + const ids = payload.completed_steps.map((step) => step.id); + expect(ids).toContain('install_runtime'); + expect(ids).toContain('first_task_post'); + expect(payload.next_step?.id).toBe('first_task_claim_file'); + }); + + it('marks first_task_claim_file when an mcp__colony__task_claim_file call is observed', () => { + seedSession('s1'); + insertToolCall('s1', 'mcp__colony__task_claim_file'); + + const payload = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + const ids = payload.completed_steps.map((step) => step.id); + expect(ids).toContain('first_task_claim_file'); + // The evidence string should call out the MCP tool name we observed. + const claimStep = payload.completed_steps.find((s) => s.id === 'first_task_claim_file'); + expect(claimStep?.evidence).toContain('task_claim_file'); + }); + + it('marks first_gain_review when a coach_gain_review observation exists', () => { + seedSession('observer'); + storage.insertObservation({ + session_id: 'observer', + kind: 'coach_gain_review', + content: 'gain invocation', + compressed: false, + intensity: null, + ts: Date.now(), + }); + + const payload = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + const ids = payload.completed_steps.map((step) => step.id); + expect(ids).toContain('first_gain_review'); + }); + + it('persists completion across calls (idempotent markCoachStep)', () => { + seedSession('s1'); + insertToolCall('s1', 'mcp__colony__task_post', 1_700_000_000_000); + + const first = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + const firstCompletedAt = first.completed_steps.find( + (s) => s.id === 'first_task_post', + )?.completed_at; + expect(firstCompletedAt).toBeDefined(); + + // Re-run with no new evidence — completed_at must be identical, proving + // the row wasn't overwritten by a second INSERT OR IGNORE. + const second = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + const secondCompletedAt = second.completed_steps.find( + (s) => s.id === 'first_task_post', + )?.completed_at; + expect(secondCompletedAt).toBe(firstCompletedAt); + }); + + it('emits a numbered, copy-pasteable prose report with cmd + tool lines', () => { + const payload = buildCoachPayload(storage, makeSettings()); + const text = formatCoachOutput(payload); + expect(text).toContain('colony health --coach'); + expect(text).toContain('Next habit:'); + expect(text).toContain('cmd: colony install --ide codex'); + expect(text).toContain('tool: colony install'); + }); + + it('emits JSON when requested', () => { + const payload = buildCoachPayload(storage, makeSettings()); + const json = JSON.parse(formatCoachOutput(payload, { json: true })); + expect(json.stage).toBe('fresh'); + expect(json.next_step.id).toBe('install_runtime'); + expect(Array.isArray(json.upcoming)).toBe(true); + }); + + it('produces a finished banner when every step is complete', () => { + seedSession('s1'); + for (const step of COACH_LADDER) { + // Synthesize the matching tool call for steps that key on tool names. + if (step.id === 'install_runtime') continue; + if (step.id === 'first_gain_review') continue; + const toolName = step.tool.replace(/^mcp__colony__/, ''); + insertToolCall('s1', `mcp__colony__${toolName}`); + } + storage.insertObservation({ + session_id: 's1', + kind: 'coach_gain_review', + content: 'gain', + compressed: false, + intensity: null, + ts: Date.now(), + }); + const payload = buildCoachPayload(storage, makeSettings({ ides: { codex: true } })); + expect(payload.completed_steps).toHaveLength(COACH_LADDER.length); + expect(payload.next_step).toBeNull(); + expect(payload.upcoming).toEqual([]); + const text = formatCoachOutput(payload); + expect(text).toContain('You finished the first-week ladder'); + }); +}); diff --git a/openspec/changes/health-coach-mode-2026-05-16/CHANGE.md b/openspec/changes/health-coach-mode-2026-05-16/CHANGE.md new file mode 100644 index 0000000..7407dc3 --- /dev/null +++ b/openspec/changes/health-coach-mode-2026-05-16/CHANGE.md @@ -0,0 +1,50 @@ +# Change: health coach mode + +## Why + +README §Roadmap → v0.x → "Adoption nudges" lists: + +> ⏳ Adoption coach mode in `colony health` that walks a new repo through first-week setup + +`colony health` today scores adoption against thresholds but does not _walk_ +a new repo through the habits that produce the score. Result: a fresh +install sees a wall of green/yellow signals with no clear next action. + +## What changes + +Add `colony health --coach` — a first-week walkthrough that: + +- Detects stage: `fresh` / `installed_no_signal` / `early` / `mid_adoption`, + using cheap signals (`countObservations`, installed IDE flags, + `firstObservationTs`, `Math.max(toolCallsSince, countMcpMetricsSince)` — + Codex MCP traffic lands in `mcp_metrics` not `observations`). +- 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 `cmd:` and `tool:` strings so the + reader has the exact next command. +- Persists progress in a new `coach_progress` SQLite table + (`step_id PRIMARY KEY, completed_at, evidence`). Steps complete from + observed events (`mcp_metrics` rows, `claimBeforeEditStats`, `colony gain` + invocation observation), never from user click. +- Respects `--json` (returns `{ stage, completed_steps, next_step, upcoming }`). +- Mutually exclusive with `--fix-plan` — stderr warning, `--coach` wins. + +## Surface + +| Surface | Shape | +| --- | --- | +| CLI | `colony health --coach [--json]` | +| Storage | new table `coach_progress`, schema_version 13 → 14 | +| New methods | `markCoachStep(id, evidence)`, `listCoachSteps()`, `firstObservationTs()`, `countObservationsByKindSince(kind, since)` | +| New files | `apps/cli/src/commands/health-coach.ts`, `apps/cli/src/lib/installed-ides.ts`, `packages/storage/src/migrations/014-coach-progress.ts` | +| Side effect | `colony gain` records a `coach_gain_review` observation so step 7 can self-detect | + +## Verification + +- `pnpm --filter colonyq typecheck` — clean +- `pnpm typecheck` (monorepo) — clean +- `pnpm --filter colonyq test` — 293/293 pass (9 new health-coach tests) +- `pnpm --filter @colony/storage test` — 157/157 pass +- `biome check` on touched files — clean +- Manual smoke confirmed fresh + `--json` + `--coach --fix-plan` mutex diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index a02b0b7..0dfce76 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -83,6 +83,7 @@ export type { ReinforcementKind, AgentProfileRow, NewAgentProfile, + CoachStepRow, ExampleRow, NewExample, ExampleManifestKind, diff --git a/packages/storage/src/migrations/014-coach-progress.ts b/packages/storage/src/migrations/014-coach-progress.ts new file mode 100644 index 0000000..ed5d03a --- /dev/null +++ b/packages/storage/src/migrations/014-coach-progress.ts @@ -0,0 +1,10 @@ +export const version = 14; +export const name = 'coach-progress'; + +export const sql = ` +CREATE TABLE IF NOT EXISTS coach_progress ( + step_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL, + evidence TEXT +); +`; diff --git a/packages/storage/src/schema.ts b/packages/storage/src/schema.ts index 0e1ab8f..f6aa54b 100644 --- a/packages/storage/src/schema.ts +++ b/packages/storage/src/schema.ts @@ -322,7 +322,16 @@ CREATE INDEX IF NOT EXISTS idx_task_run_attempts_status CREATE INDEX IF NOT EXISTS idx_task_run_attempts_parent ON task_run_attempts(parent_attempt_id); -INSERT OR IGNORE INTO schema_version(version) VALUES (13); +-- First-week coach progress (colony health --coach walkthrough). +-- One row per completed ladder step. Rows are written the first time an +-- event-observed done_when predicate fires; the CLI never asks for clicks. +CREATE TABLE IF NOT EXISTS coach_progress ( + step_id TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL, + evidence TEXT +); + +INSERT OR IGNORE INTO schema_version(version) VALUES (14); `; /** diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 2c89e3f..7f43049 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -20,6 +20,7 @@ import type { AgentProfileRow, AggregateMcpMetricsDailyOptions, AggregateMcpMetricsOptions, + CoachStepRow, ExampleRow, LaneRunState, LaneStateRow, @@ -1242,6 +1243,59 @@ export class Storage { return row.n; } + /** + * Earliest observation timestamp recorded by any session, or null if the + * observations table is empty. Used by the coach walkthrough to compute + * "days since first session" without paying for a full sessions scan. + */ + firstObservationTs(): number | null { + const row = this.db.prepare('SELECT MIN(ts) AS first_ts FROM observations').get() as + | { first_ts: number | null } + | undefined; + return row?.first_ts ?? null; + } + + /** + * Idempotently record completion of a `colony health --coach` ladder step. + * `INSERT OR IGNORE` means the first observation of a step wins; later + * calls with the same `step_id` are no-ops, so we never overwrite the + * original completion timestamp. + */ + markCoachStep(stepId: string, evidence: string | null = null): void { + this.db + .prepare( + `INSERT OR IGNORE INTO coach_progress(step_id, completed_at, evidence) + VALUES (?, ?, ?)`, + ) + .run(stepId, Date.now(), evidence); + } + + /** + * Every coach step the user has completed, ordered by completion time. + * Returns an empty array when the table is fresh — the coach renderer + * treats that as "step 1 is next". + */ + listCoachSteps(): CoachStepRow[] { + return this.db + .prepare( + `SELECT step_id, completed_at, evidence + FROM coach_progress + ORDER BY completed_at ASC`, + ) + .all() as CoachStepRow[]; + } + + /** + * Count observations matching a kind since a timestamp. Used by the coach + * walkthrough to detect first-time invocations (e.g. `coach_gain_review`). + */ + countObservationsByKindSince(kind: string, since: number): number { + const row = this.db + .prepare('SELECT COUNT(*) AS n FROM observations WHERE kind = ? AND ts >= ?') + .get(kind, since) as { n: number } | undefined; + return row?.n ?? 0; + } + recordMcpMetric(metric: NewMcpMetric): void { this.db .prepare( diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 2be334f..2d91fc9 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -42,6 +42,18 @@ export interface NewObservation { reply_to?: number | null; } +/** + * One row per completed `colony health --coach` step. Step completion is + * event-observed; the CLI writes this row the first time the matching + * `done_when` predicate fires. `evidence` is an opaque short string that + * the coach renderer can surface as proof (tool name, observation id, etc.). + */ +export interface CoachStepRow { + step_id: string; + completed_at: number; + evidence: string | null; +} + export interface TaskRow { id: number; title: string;