Skip to content
Open
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
251 changes: 233 additions & 18 deletions packages/cli/src/commands/iterate.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,88 @@
import { Command } from 'commander';
import kleur from 'kleur';
import { agentsCmd } from './agents.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { randomBytes } 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');

// ---------------------------------------------------------------------------
// Experiments persistence
// ---------------------------------------------------------------------------

interface Experiment {
id: string;
hypothesis: string;
variants: string[];
traffic: number;
minSample: number;
createdAt: string;
updatedAt: string;
status: 'active' | 'ended' | 'paused';
significance?: number;
sampleCount?: number;
winner?: 'A' | 'B' | 'inconclusive';
note?: string;
}

const EXPERIMENTS_FILE = () => path.join(configDir(), 'iterate-experiments.json');

async function loadExperiments(): Promise<Experiment[]> {
try {
const raw = await fs.readFile(EXPERIMENTS_FILE(), 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
}

async function saveExperiments(experiments: Experiment[]): Promise<void> {
await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
const tmp = `${EXPERIMENTS_FILE()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(experiments, null, 2) + '\n', { mode: 0o600 });
await fs.rename(tmp, EXPERIMENTS_FILE());
}

async function loadGoals(): Promise<Record<string, string>> {
try {
const raw = await fs.readFile(GOALS_FILE(), 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw err;
}
}

async function saveGoals(goals: Record<string, string>): Promise<void> {
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());
}

export const iterateCmd = new Command('iterate')
.description('Observe metrics, have an agent propose changes, ship, measure. Powered by Claude / Codex / Qwen.')
.action(() => { iterateCmd.help(); });

// AI-CLI orchestration lives under iterate (was top-level `sh1pt agents`).
// sh1pt iterate agents [list|setup|talk|run|generate]
iterateCmd.addCommand(agentsCmd);
.option('--from <input>', 'existing live url, repo, or local path to start observing + iterating on')
.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.
return;
}
iterateCmd.help();
});

// AI-CLI agents moved to root level — see #235.
iterateCmd
.command('run')
.description('Single-shot cycle: pull metrics → have agent propose changes → apply (with confirmation) → ship')
Expand All @@ -32,6 +105,7 @@ iterateCmd
.command('watch')
.description('Daemon mode — run a cycle on every significant metric change')
.option('--agent <id>', 'claude | codex | qwen', 'claude')
.option('--cloud', 'schedule and run the watch loop in sh1pt cloud')
.option('--interval <seconds>', 're-check interval', Number, 3600)
.option('--quiet-hours <start-end>', 'e.g. 22-08 (24h local) to pause overnight')
.action((opts) => {
Expand All @@ -44,13 +118,58 @@ iterateCmd
.command('goals')
.description('Declare the success metrics iterate steers toward')
.argument('[kv...]', 'e.g. conversion=8% cpi=2.00 churn=5%')
.action((kv: string[]) => {
.option('--clear', 'remove all saved goals')
.option('--unset <key>', 'remove a single goal by key')
.option('--json', 'machine-readable output')
.action(async (kv: string[], opts: { clear?: boolean; unset?: string; json?: boolean }) => {
const goals = await loadGoals();

if (opts.clear) {
await saveGoals({});
console.log(kleur.yellow('all goals cleared'));
return;
}

if (opts.unset) {
if (opts.unset in goals) {
delete goals[opts.unset];
await saveGoals(goals);
console.log(kleur.yellow(`unset: ${opts.unset}`));
} else {
console.log(kleur.dim(`goal "${opts.unset}" not set`));
}
return;
}

if (kv.length === 0) {
console.log(kleur.dim('[stub] iterate goals — list current goals'));
if (Object.keys(goals).length === 0) {
console.log(kleur.dim('no goals set — pass key=value pairs to set them'));
return;
}
if (opts.json) {
console.log(JSON.stringify(goals, null, 2));
return;
}
console.log(kleur.bold('current goals:'));
for (const [k, v] of Object.entries(goals)) {
console.log(` ${kleur.cyan(k)} = ${v}`);
}
return;
}
console.log(kleur.cyan(`[stub] iterate goals set ${kv.join(' ')}`));
// TODO: persist goals; iterate run uses these as the optimization target

for (const pair of kv) {
const idx = pair.indexOf('=');
if (idx === -1) {
console.error(kleur.red(`invalid goal "${pair}" — expected key=value`));
continue;
}
const key = pair.slice(0, idx).trim();
const value = pair.slice(idx + 1).trim();
if (!key) { console.error(kleur.red(`empty key in "${pair}"`)); continue; }
goals[key] = value;
console.log(kleur.green(` set ${key} = ${value}`));
}
await saveGoals(goals);
});

iterateCmd
Expand All @@ -59,16 +178,112 @@ iterateCmd
.option('--variant <text...>', 'the B-side change; A is current state')
.option('--traffic <percent>', 'percentage routed to B', Number, 50)
.option('--min-sample <n>', 'minimum events before stopping', Number, 1000)
.action((hypothesis: string, opts) => {
console.log(kleur.cyan(`[stub] iterate test "${hypothesis}" ${JSON.stringify(opts)}`));
// TODO: generate two Ship variants, wire feature flag, schedule analysis at min-sample
.action(async (hypothesis: string, opts: { variant?: string[]; traffic?: number; minSample?: number }) => {
const experiments = await loadExperiments();
const now = new Date().toISOString();
const id = randomBytes(4).toString('hex');
Comment on lines +182 to +184
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 --traffic value not validated for valid range

Commander's Number coercion accepts any numeric string, including negative values and values above 100. Storing traffic: -10 or traffic: 200 is nonsensical for a traffic-split percentage and will display misleadingly (e.g., "-10% to B"). A simple range guard prevents invalid values from being persisted.

Suggested change
const experiments = await loadExperiments();
const now = new Date().toISOString();
const id = randomBytes(4).toString('hex');
const experiments = await loadExperiments();
const traffic = opts.traffic ?? 50;
if (traffic <= 0 || traffic >= 100) {
console.error(kleur.red(`--traffic must be between 1 and 99 (got ${traffic})`));
process.exit(1);
}
const now = new Date().toISOString();
const id = randomBytes(4).toString('hex');

const experiment: Experiment = {
id,
hypothesis,
variants: opts.variant ?? [],
traffic: opts.traffic ?? 50,
minSample: opts.minSample ?? 1000,
createdAt: now,
updatedAt: now,
status: 'active',
};
experiments.push(experiment);
await saveExperiments(experiments);
console.log(kleur.green(`experiment created: ${kleur.bold(id)}`));
console.log(` hypothesis : ${hypothesis}`);
if (experiment.variants.length > 0) {
console.log(` variant(s) : ${experiment.variants.join(', ')}`);
}
console.log(` traffic : ${experiment.traffic}% to B`);
console.log(` min-sample : ${experiment.minSample}`);
console.log(kleur.dim(` run \`sh1pt iterate experiments\` to track progress`));
});

iterateCmd
const experimentsCmd = iterateCmd
.command('experiments')
Comment on lines +207 to 208
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 experimentsCmd variable assigned but never used

The return value of .command('experiments') is stored in experimentsCmd, but the variable is never referenced again — the subcommand is already wired into iterateCmd as a side effect of the .command() call itself. The const binding is dead code.

Suggested change
const experimentsCmd = iterateCmd
.command('experiments')
iterateCmd
.command('experiments')

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

.description('Active and recently-ended experiments with significance')
.option('--json')
.action((opts: { json?: boolean }) => {
if (opts.json) { console.log(JSON.stringify({ active: [], ended: [] }, null, 2)); return; }
console.log(kleur.dim('[stub] iterate experiments — table of active / concluded tests'));
.option('--json', 'machine-readable output')
.option('--end <id>', 'mark an experiment as ended')
.option('--pause <id>', 'pause an active experiment')
.option('--resume <id>', 'resume a paused experiment')
.option('--winner <result>', 'record outcome when ending (A | B | inconclusive)')
.option('--note <text>', 'attach a note when ending')
.action(async (opts: {
json?: boolean;
end?: string;
pause?: string;
resume?: string;
winner?: 'A' | 'B' | 'inconclusive';
note?: string;
}) => {
const experiments = await loadExperiments();
const now = new Date().toISOString();

// Mutating actions
if (opts.end) {
const exp = experiments.find(e => e.id === opts.end);
if (!exp) { console.error(kleur.red(`experiment "${opts.end}" not found`)); process.exit(1); }
exp.status = 'ended';
exp.updatedAt = now;
if (opts.winner) exp.winner = opts.winner;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 --winner value not validated at runtime

Commander passes option values as plain strings; the TypeScript union type 'A' | 'B' | 'inconclusive' is compile-time only and offers no protection at runtime. A user who passes --winner foo will have the string "foo" written directly to the persisted JSON file, silently corrupting the winner field. A runtime guard is needed before the value is stored.

Suggested change
if (opts.winner) exp.winner = opts.winner;
const validWinners = ['A', 'B', 'inconclusive'] as const;
if (opts.winner) {
if (!(validWinners as readonly string[]).includes(opts.winner)) {
console.error(kleur.red(`invalid --winner "${opts.winner}" — must be A, B, or inconclusive`));
process.exit(1);
}
exp.winner = opts.winner as 'A' | 'B' | 'inconclusive';
}

if (opts.note) exp.note = opts.note;
await saveExperiments(experiments);
console.log(kleur.yellow(`ended: ${opts.end}`) + (opts.winner ? kleur.dim(` · winner=${opts.winner}`) : ''));
return;
Comment on lines +216 to +237
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 --winner and --note silently discarded without --end

--winner and --note are only consumed inside the if (opts.end) branch. If a user runs sh1pt iterate experiments --winner A --note "done" (forgetting to supply --end <id>), the handler falls through to the listing path — the options are silently ignored and no winner is recorded. The user gets a normal experiments list with no indication their flags were dropped, leading them to believe the winner was persisted when it was not.

}

if (opts.pause) {
const exp = experiments.find(e => e.id === opts.pause);
if (!exp) { console.error(kleur.red(`experiment "${opts.pause}" not found`)); process.exit(1); }
exp.status = 'paused';
exp.updatedAt = now;
await saveExperiments(experiments);
console.log(kleur.yellow(`paused: ${opts.pause}`));
return;
}

if (opts.resume) {
const exp = experiments.find(e => e.id === opts.resume);
if (!exp) { console.error(kleur.red(`experiment "${opts.resume}" not found`)); process.exit(1); }
exp.status = 'active';
exp.updatedAt = now;
await saveExperiments(experiments);
console.log(kleur.green(`resumed: ${opts.resume}`));
return;
}
Comment on lines +228 to +258
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Conflicting mutation flags silently discard one operation

When a user passes two mutation flags at once — e.g., --end id1 --pause id2 — only the first branch (--end) executes and returns early. The --pause operation is silently dropped. Since these flags target different experiment IDs, the user might reasonably expect both to take effect. At minimum, an error should be emitted when multiple mutation flags are provided simultaneously.


// List
if (opts.json) {
const active = experiments.filter(e => e.status === 'active');
const ended = experiments.filter(e => e.status === 'ended');
const paused = experiments.filter(e => e.status === 'paused');
console.log(JSON.stringify({ active, paused, ended }, null, 2));
return;
}

if (experiments.length === 0) {
console.log(kleur.dim('no experiments — run `sh1pt iterate test "<hypothesis>"` to start one'));
return;
}

const statusColor = (s: Experiment['status']) =>
s === 'active' ? kleur.green(s) : s === 'paused' ? kleur.yellow(s) : kleur.dim(s);

for (const exp of experiments) {
const winner = exp.winner ? kleur.cyan(` winner=${exp.winner}`) : '';
const sig = exp.significance != null ? kleur.dim(` sig=${exp.significance}`) : '';
const samples = exp.sampleCount != null ? kleur.dim(` n=${exp.sampleCount}`) : '';
console.log(`${kleur.bold(exp.id)} ${statusColor(exp.status)}${winner}${sig}${samples}`);
console.log(` ${exp.hypothesis}`);
if (exp.variants.length > 0) console.log(` ${kleur.dim('variants:')} ${exp.variants.join(', ')}`);
if (exp.note) console.log(` ${kleur.dim('note:')} ${exp.note}`);
}

const active = experiments.filter(e => e.status === 'active').length;
console.log(kleur.dim(`\n${active} active / ${experiments.length} total`));
});