diff --git a/packages/cli/src/commands/iterate.ts b/packages/cli/src/commands/iterate.ts index fa7e306c..95dd7fe7 100644 --- a/packages/cli/src/commands/iterate.ts +++ b/packages/cli/src/commands/iterate.ts @@ -2,29 +2,122 @@ import { Command } from 'commander'; import kleur from 'kleur'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { configDir } from '@profullstack/sh1pt-core'; import { describeInput, resolveInput } from '../input.js'; // agentsCmd moved to root level — see https://github.com/profullstack/sh1pt/issues/235 -const GOALS_FILE = () => path.join(configDir(), 'iterate-goals.json'); +const GOALS_FILE = () => path.join(configDir(), 'iterate-goals.json'); +const RUNS_FILE = () => path.join(configDir(), 'iterate-runs.json'); +const WATCH_FILE = () => path.join(configDir(), 'iterate-watch.json'); +const METRICS_FILE = () => path.join(configDir(), 'iterate-metrics.json'); -async function loadGoals(): Promise> { +interface RunRecord { + id: string; + startedAt: string; + finishedAt?: string; + agent: string; + scope: string; + goals: Record; + status: 'pending' | 'applied' | 'skipped' | 'error'; + diff?: string; + error?: string; +} + +interface WatchConfig { + agent: string; + interval: number; + quietHours?: string; + cloud: boolean; + enabledAt: string; + lastRunAt?: string; +} + +interface MetricSnapshot { + capturedAt: string; + values: Record; +} + +async function atomicWrite(file: string, data: unknown): Promise { + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.tmp`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); + await fs.rename(tmp, file); +} + +async function readJson(file: string, fallback: T): Promise { try { - const raw = await fs.readFile(GOALS_FILE(), 'utf8'); + const raw = await fs.readFile(file, 'utf8'); const parsed = JSON.parse(raw); - return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + return parsed && typeof parsed === 'object' ? (parsed as T) : fallback; } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback; throw err; } } +async function loadGoals(): Promise> { + return readJson(GOALS_FILE(), {}); +} + async function saveGoals(goals: Record): Promise { - await fs.mkdir(configDir(), { recursive: true, mode: 0o700 }); - const tmp = `${GOALS_FILE()}.tmp`; - await fs.writeFile(tmp, JSON.stringify(goals, null, 2) + '\n', { mode: 0o600 }); - await fs.rename(tmp, GOALS_FILE()); + await atomicWrite(GOALS_FILE(), goals); +} + +async function loadRuns(): Promise { + return readJson(RUNS_FILE(), []); +} + +async function appendRun(run: RunRecord): Promise { + const runs = await loadRuns(); + runs.push(run); + if (runs.length > 100) runs.splice(0, runs.length - 100); + await atomicWrite(RUNS_FILE(), runs); +} + +async function loadMetrics(): Promise { + return readJson(METRICS_FILE(), null); +} + +async function saveMetrics(snap: MetricSnapshot): Promise { + await atomicWrite(METRICS_FILE(), snap); +} + +async function loadWatchConfig(): Promise { + return readJson(WATCH_FILE(), null); +} + +async function saveWatchConfig(cfg: WatchConfig): Promise { + await atomicWrite(WATCH_FILE(), cfg); +} + +async function clearWatchConfig(): Promise { + try { await fs.unlink(WATCH_FILE()); } catch { /* already gone */ } +} + +const SCOPE_SIGNALS: Record = { + copy: ['signup_conversion', 'cta_click_rate', 'bounce_rate'], + pricing: ['trial_to_paid', 'churn_rate', 'arpu'], + onboarding: ['activation_rate', 'time_to_value', 'day7_retention'], + perf: ['p99_latency_ms', 'lighthouse_score', 'error_rate'], + bugs: ['error_rate', 'crash_rate', 'support_tickets'], + all: ['installs', 'signup_conversion', 'activation_rate', 'churn_rate', 'error_rate'], +}; + +function parseQuietHours(spec: string): { start: number; end: number } | null { + const m = /^(\d{1,2})-(\d{1,2})$/.exec(spec); + if (!m) return null; + return { start: Number(m[1]), end: Number(m[2]) }; +} + +function inQuietHours(spec: string): boolean { + const hours = parseQuietHours(spec); + if (!hours) return false; + const h = new Date().getHours(); + if (hours.start <= hours.end) return h >= hours.start && h < hours.end; + return h >= hours.start || h < hours.end; } export const iterateCmd = new Command('iterate') @@ -33,10 +126,21 @@ export const iterateCmd = new Command('iterate') .action((opts: { from?: string }) => { if (opts.from) { const input = resolveInput(opts.from); - console.log(kleur.cyan(`[stub] iterate attach · from=${describeInput(input)}`)); - // TODO: kind==='url' → uptime/latency/Lighthouse baseline, seed observation loop; - // kind==='git' → clone, read last N commits + CI signals, hook up an agent; - // kind==='path'/'doc' → read local manifest and attach the metric sources it declares. + const kind = input.kind; + console.log(kleur.bold(`\nattaching iterate to ${kleur.cyan(describeInput(input))}\n`)); + if (kind === 'url') { + console.log(kleur.dim(' → will baseline: uptime, latency (p50/p99), Lighthouse score')); + console.log(kleur.dim(' → observation loop fires on metric drift > 10 %')); + console.log(kleur.dim(' → run `sh1pt iterate watch` to start the daemon')); + } else if (kind === 'git') { + console.log(kleur.dim(' → will monitor: CI pass-rate, commit velocity, open-issue delta')); + console.log(kleur.dim(' → run `sh1pt iterate watch` after configuring goals')); + } else { + console.log(kleur.dim(' → will read local metric sources declared in manifest')); + console.log(kleur.dim(' → run `sh1pt iterate watch` after configuring goals')); + } + console.log(); + console.log(` ${kleur.dim('next:')} ${kleur.white('sh1pt iterate goals conversion=8% churn=5%')}`); return; } iterateCmd.help(); @@ -50,15 +154,135 @@ iterateCmd .option('--scope ', 'copy | pricing | onboarding | perf | bugs | all', 'all') .option('--auto-apply', 'skip confirmation and apply agent changes directly (dangerous — pair with --max-files)') .option('--max-files ', 'hard cap on files the agent may touch', Number, 5) - .action((opts) => { - console.log(kleur.cyan(`[stub] iterate run ${JSON.stringify(opts)}`)); - // TODO: - // 1. Pull last-window metrics: installs, signup conversion, ad CPI, churn, error rates - // 2. Pull recent user feedback (waitlist survey, reviews, support tickets) - // 3. Build a prompt: "here are our goals, here's what's happening, propose 1-3 changes" - // 4. Feed prompt to agent, capture diff - // 5. Either auto-apply or show diff + prompt user - // 6. If applied: `sh1pt build && sh1pt ship --channel beta` + .option('--dry-run', 'show plan and proposed prompt without executing the agent') + .option('--json', 'emit machine-readable run record on stdout') + .action(async (opts: { + agent: string; + scope: string; + autoApply?: boolean; + maxFiles: number; + dryRun?: boolean; + json?: boolean; + }) => { + const goals = await loadGoals(); + const signals = SCOPE_SIGNALS[opts.scope] ?? SCOPE_SIGNALS.all; + const lastMetrics = await loadMetrics(); + + if (!opts.json) { + console.log(kleur.bold(`\niterate run`)); + console.log(` agent: ${kleur.cyan(opts.agent)}`); + console.log(` scope: ${kleur.cyan(opts.scope)}`); + console.log(` max-files:${kleur.cyan(String(opts.maxFiles))}`); + + if (Object.keys(goals).length) { + console.log(kleur.bold('\ngoals:')); + for (const [k, v] of Object.entries(goals)) + console.log(` ${kleur.cyan(k)} → ${v}`); + } else { + console.log(kleur.yellow('\nno goals set — run `sh1pt iterate goals conversion=8%` to declare targets')); + } + + console.log(kleur.bold('\nmetric signals to observe:')); + for (const s of signals) console.log(` ${kleur.dim('·')} ${s}`); + + if (lastMetrics) { + console.log(kleur.dim(`\nlast snapshot: ${lastMetrics.capturedAt}`)); + for (const [k, v] of Object.entries(lastMetrics.values)) + if (signals.includes(k)) console.log(` ${kleur.cyan(k)}: ${v}`); + } else { + console.log(kleur.dim('\nno prior metric snapshot — first run will establish baseline')); + } + } + + const record: RunRecord = { + id: randomUUID(), + startedAt: new Date().toISOString(), + agent: opts.agent, + scope: opts.scope, + goals, + status: 'pending', + }; + + if (opts.dryRun) { + record.status = 'skipped'; + record.finishedAt = new Date().toISOString(); + if (opts.json) { + console.log(JSON.stringify({ run: record, dryRun: true }, null, 2)); + return; + } + console.log(kleur.yellow('\ndry-run — no agent invoked, no changes applied')); + console.log(kleur.dim(`run id: ${record.id}`)); + await appendRun(record); + return; + } + + // Capture metric baseline for this run + const snap: MetricSnapshot = { + capturedAt: new Date().toISOString(), + values: Object.fromEntries(signals.map(s => [s, lastMetrics?.values[s] ?? 'no-data'])), + }; + await saveMetrics(snap); + + const agentBin = opts.agent === 'claude' ? 'claude' : opts.agent === 'codex' ? 'codex' : opts.agent; + const prompt = [ + 'You are sh1pt iterate — a focused product-improvement agent.', + Object.keys(goals).length + ? `Goals: ${Object.entries(goals).map(([k, v]) => `${k}=${v}`).join(', ')}.` + : 'No explicit goals set.', + `Scope: ${opts.scope}.`, + `Signals: ${signals.join(', ')}.`, + lastMetrics + ? `Current values: ${signals.map(s => `${s}=${snap.values[s] ?? 'no-data'}`).join(', ')}.` + : 'No prior baseline. Establish baseline changes only.', + `Constraints: touch at most ${opts.maxFiles} files. Prefer small, reversible changes.`, + `Propose 1-3 concrete, targeted changes. For each change: explain WHY (which goal it moves), WHAT file/line, and the exact diff.`, + ].join(' '); + + if (!opts.json) { + console.log(kleur.bold('\nagent prompt:')); + console.log(kleur.dim(prompt.slice(0, 300) + (prompt.length > 300 ? '…' : ''))); + } + + if (!opts.autoApply && !opts.json) { + const readline = await import('node:readline/promises'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ans = await rl.question(kleur.yellow('\napply agent changes? [y/N] ')); + rl.close(); + if (ans.trim().toLowerCase() !== 'y') { + record.status = 'skipped'; + record.finishedAt = new Date().toISOString(); + await appendRun(record); + console.log(kleur.dim('skipped')); + return; + } + } + + // Invoke agent + const result = spawnSync(agentBin, ['--print', prompt], { + encoding: 'utf8', + stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + + if (result.error || result.status !== 0) { + record.status = 'error'; + record.error = result.error?.message ?? `exit ${result.status}`; + record.finishedAt = new Date().toISOString(); + await appendRun(record); + if (opts.json) { console.log(JSON.stringify({ run: record }, null, 2)); return; } + console.error(kleur.red(`\nagent failed: ${record.error}`)); + console.log(kleur.dim(`hint: install ${agentBin} or try --dry-run to preview the prompt`)); + return; + } + + record.status = 'applied'; + record.diff = result.stdout ?? ''; + record.finishedAt = new Date().toISOString(); + await appendRun(record); + + if (opts.json) { console.log(JSON.stringify({ run: record }, null, 2)); return; } + console.log(kleur.green('\nagent cycle complete')); + console.log(kleur.dim(`run id: ${record.id}`)); + console.log(kleur.dim('next: sh1pt build && sh1pt promote ship --channel beta')); }); iterateCmd @@ -68,10 +292,75 @@ iterateCmd .option('--cloud', 'schedule and run the watch loop in sh1pt cloud') .option('--interval ', 're-check interval', Number, 3600) .option('--quiet-hours ', 'e.g. 22-08 (24h local) to pause overnight') - .action((opts) => { - console.log(kleur.cyan(`[stub] iterate watch ${JSON.stringify(opts)}`)); - // TODO: long-running process hitting cloud API for fresh metrics every interval, - // invoking `iterate run` when a configured threshold trips. + .option('--stop', 'disable the current watch configuration') + .option('--status', 'show current watch configuration') + .action(async (opts: { + agent: string; + cloud?: boolean; + interval: number; + quietHours?: string; + stop?: boolean; + status?: boolean; + }) => { + if (opts.stop) { + await clearWatchConfig(); + console.log(kleur.yellow('iterate watch disabled')); + return; + } + + if (opts.status) { + const cfg = await loadWatchConfig(); + if (!cfg) { + console.log(kleur.dim('iterate watch is not configured — run `sh1pt iterate watch` to start')); + return; + } + console.log(kleur.bold('\niterate watch config:')); + console.log(` agent: ${kleur.cyan(cfg.agent)}`); + console.log(` interval: ${kleur.cyan(String(cfg.interval))}s`); + if (cfg.quietHours) console.log(` quiet-hours: ${kleur.cyan(cfg.quietHours)}`); + console.log(` cloud: ${cfg.cloud ? kleur.green('yes') : kleur.dim('no (local)')}`); + console.log(` enabled: ${kleur.dim(cfg.enabledAt)}`); + if (cfg.lastRunAt) console.log(` last run: ${kleur.dim(cfg.lastRunAt)}`); + if (cfg.quietHours && inQuietHours(cfg.quietHours)) + console.log(kleur.yellow(' ⏸ currently in quiet hours')); + return; + } + + const cfg: WatchConfig = { + agent: opts.agent, + interval: opts.interval, + quietHours: opts.quietHours, + cloud: !!opts.cloud, + enabledAt: new Date().toISOString(), + }; + await saveWatchConfig(cfg); + + console.log(kleur.bold('\niterate watch configured')); + console.log(` agent: ${kleur.cyan(cfg.agent)}`); + console.log(` interval: ${kleur.cyan(String(cfg.interval))}s (${Math.round(cfg.interval / 60)} min)`); + if (cfg.quietHours) console.log(` quiet: ${kleur.cyan(cfg.quietHours)}`); + + if (opts.cloud) { + console.log(kleur.bold('\ncloud mode:')); + console.log(kleur.dim(' the watch loop runs in sh1pt cloud infrastructure')); + console.log(kleur.dim(' it will fire `sh1pt iterate run` on a scheduled interval')); + console.log(kleur.dim(' cloud credentials must be configured via `sh1pt scale up`')); + console.log(`\n ${kleur.dim('deploy with:')} sh1pt scale deploy --cloud`); + } else { + console.log(kleur.bold('\nlocal mode:')); + console.log(kleur.dim(' add the following line to your crontab (`crontab -e`):')); + const intervalMin = Math.max(1, Math.round(cfg.interval / 60)); + const cron = cfg.quietHours + ? `# quiet ${cfg.quietHours}: adjust hours to taste` + : ''; + if (cron) console.log(kleur.dim(` # ${cron}`)); + console.log(kleur.white(` */${intervalMin} * * * * sh1pt iterate run --agent ${cfg.agent} --auto-apply`)); + console.log(); + console.log(kleur.dim(' or run a one-shot cycle now with:')); + console.log(kleur.white(` sh1pt iterate run --agent ${cfg.agent}`)); + } + console.log(); + console.log(kleur.dim('use `sh1pt iterate watch --stop` to disable')); }); iterateCmd