Skip to content

feat: implement iterate run + iterate watch with file-based persistence#462

Open
emil07770 wants to merge 1 commit into
profullstack:masterfrom
emil07770:feat/iterate-run-watch
Open

feat: implement iterate run + iterate watch with file-based persistence#462
emil07770 wants to merge 1 commit into
profullstack:masterfrom
emil07770:feat/iterate-run-watch

Conversation

@emil07770
Copy link
Copy Markdown
Contributor

Closes #461

What this PR implements

Full implementation of sh1pt iterate run and sh1pt iterate watch — replaces the [stub] placeholders with working logic.

iterate run

  • Loads goals (from iterate goals) and metric signals for the selected scope
  • Displays last metric snapshot if available
  • Builds an agent prompt with goals, signals, current values, and constraints
  • Invokes agent via spawnSync(agentBin, ['--print', prompt]) (supports claude, codex, qwen)
  • Saves a RunRecord (id, startedAt, finishedAt, agent, scope, goals, status, diff) to iterate-runs.json (capped at 100 entries)
  • Flags: --dry-run (skip agent, record skipped), --auto-apply (skip y/N prompt), --json (machine-readable output)

iterate watch

  • Saves a WatchConfig to iterate-watch.json with agent, interval, quietHours, cloud flag
  • --stop: clears the watch config
  • --status: shows current config and whether quiet hours are active
  • Local mode: prints a ready-to-paste crontab line (*/N * * * * sh1pt iterate run --agent X --auto-apply)
  • Cloud mode: prints sh1pt scale deploy --cloud instructions
  • inQuietHours(spec) helper handles overnight spans (e.g. 22-08 wraps midnight)

Shared infrastructure added

  • atomicWrite(file, data)writeFile(.tmp) → rename, mode 0o600
  • readJson<T>(file, fallback) — generic JSON loader with ENOENT handling
  • SCOPE_SIGNALS — maps scope names to metric signal arrays
  • MetricSnapshot, RunRecord, WatchConfig interfaces
  • Metric snapshot captured before each run and persisted to iterate-metrics.json

Testing

# Show goals and signals without invoking agent
sh1pt iterate run --dry-run --scope copy

# Run with claude (requires claude CLI)
sh1pt iterate run --agent claude --scope perf

# Configure local daemon
sh1pt iterate watch --interval 3600 --quiet-hours 22-08

# Show watch status
sh1pt iterate watch --status

# Stop watch daemon
sh1pt iterate watch --stop

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR replaces stub implementations in iterate run and iterate watch with working logic: agent invocation via spawnSync, file-based persistence of run records, metrics snapshots, and watch configuration, plus an atomicWrite/readJson shared infrastructure layer.

  • iterate run loads goals and metric signals, builds a structured prompt, optionally prompts the user, invokes the agent binary, and appends a RunRecord (capped at 100) to iterate-runs.json.
  • iterate watch saves a WatchConfig and emits either a ready-to-paste crontab line (local mode) or cloud deploy instructions; --stop and --status manage the saved config.
  • Three correctness issues were found: interactive runs never capture the agent diff (stdio: 'inherit' discards result.stdout), --dry-run --json silently skips persisting the run record, and --scope is dropped from WatchConfig so the generated crontab always runs against the default all scope.

Confidence Score: 3/5

Three correctness bugs in the core run/watch paths make this risky to ship as-is without fixes.

The interactive run path always stores an empty diff because stdio inherit sends the agent output to the terminal rather than capturing it in result.stdout. The --dry-run --json combination silently skips writing to the run history. And the --scope option is never stored in WatchConfig, so every generated crontab fires against the default all scope regardless of what the user configured.

packages/cli/src/commands/iterate.ts — all three bugs are in this single file, concentrated in the iterate run action handler and the WatchConfig interface.

Important Files Changed

Filename Overview
packages/cli/src/commands/iterate.ts Implements iterate run and iterate watch with file-based persistence; three logic bugs found: stdout not captured in interactive mode, dry-run+json skips appendRun, and scope missing from WatchConfig/crontab output.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[sh1pt iterate run] --> B{dry-run?}
    B -- yes and json --> C[print record to stdout not persisted]
    B -- yes no json --> D[appendRun then print dry-run notice]
    B -- no --> E{auto-apply or json?}
    E -- no --> F[readline prompt y/N]
    F -- N --> G[appendRun skipped]
    F -- y --> H[spawnSync agentBin --print prompt]
    E -- yes --> H
    H --> I{error or exit nonzero?}
    I -- yes --> J[appendRun status=error]
    I -- no --> K[record.diff = result.stdout]
    K --> L[appendRun status=applied]
    M[sh1pt iterate watch] --> N{stop?}
    N -- yes --> O[clearWatchConfig]
    N -- no --> P{status?}
    P -- yes --> Q[print cfg and quiet hours]
    P -- no --> R[saveWatchConfig]
    R --> S{cloud?}
    S -- yes --> T[print cloud deploy instructions]
    S -- no --> U[print crontab line]
Loading

Reviews (1): Last reviewed commit: "feat: implement iterate run + iterate wa..." | Re-trigger Greptile

Comment on lines +261 to +264
const result = spawnSync(agentBin, ['--print', prompt], {
encoding: 'utf8',
stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
});
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 result.stdout is always null when stdio is 'inherit', so record.diff will always be stored as '' in the normal interactive flow. The agent's output goes to the terminal but is never captured. Only --json mode (which uses 'pipe') would actually populate the diff. To capture output while still streaming to the terminal, use ['inherit', 'pipe', 'inherit'] so stdout is piped while stderr still streams.

Suggested change
const result = spawnSync(agentBin, ['--print', prompt], {
encoding: 'utf8',
stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
});
const result = spawnSync(agentBin, ['--print', prompt], {
encoding: 'utf8',
stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : ['inherit', 'pipe', 'inherit'],
});

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!

Comment on lines +206 to +217
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;
}
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 When --dry-run and --json are combined, the function returns before appendRun is called, so the run is never written to iterate-runs.json. Every other code path (non-JSON dry-run, user-skipped, agent error, agent success) persists the record. This inconsistency means --dry-run --json runs are invisible in run history.

Suggested change
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;
}
if (opts.dryRun) {
record.status = 'skipped';
record.finishedAt = new Date().toISOString();
await appendRun(record);
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}`));
return;
}

Comment on lines +29 to +36
interface WatchConfig {
agent: string;
interval: number;
quietHours?: string;
cloud: boolean;
enabledAt: string;
lastRunAt?: string;
}
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 WatchConfig does not include a scope field, so the generated crontab line hardcodes no scope (defaulting to 'all'). A user who runs sh1pt iterate watch --scope perf ... will get a cron entry that silently runs against the all scope instead of perf. The configured scope needs to be stored in WatchConfig and emitted in the crontab line.

Suggested change
interface WatchConfig {
agent: string;
interval: number;
quietHours?: string;
cloud: boolean;
enabledAt: string;
lastRunAt?: string;
}
interface WatchConfig {
agent: string;
interval: number;
scope: string;
quietHours?: string;
cloud: boolean;
enabledAt: string;
lastRunAt?: string;
}

Comment on lines +109 to +113
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]) };
}
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 parseQuietHours accepts any integer pair without range validation, so values like 25-30 are silently stored and then always evaluate to false in inQuietHours, effectively disabling quiet hours without any warning. A 0–23 bounds check would catch this at configuration time.

Suggested change
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 parseQuietHours(spec: string): { start: number; end: number } | null {
const m = /^(\d{1,2})-(\d{1,2})$/.exec(spec);
if (!m) return null;
const start = Number(m[1]);
const end = Number(m[2]);
if (start > 23 || end > 23) return null;
return { start, end };
}

Comment on lines +50 to 59
async function readJson<T>(file: string, fallback: T): Promise<T> {
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<string, string>) : {};
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;
}
}
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 readJson returns the fallback only when the parsed value is falsy or not an object. For loadRuns() the fallback is [], but if the file contains {} the condition typeof parsed === 'object' passes and the plain object is cast to RunRecord[]. The subsequent runs.push(run) call would throw TypeError: runs.push is not a function at runtime. An Array.isArray guard on the fallback type catches this.

Suggested change
async function readJson<T>(file: string, fallback: T): Promise<T> {
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<string, string>) : {};
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 readJson<T>(file: string, fallback: T): Promise<T> {
try {
const raw = await fs.readFile(file, 'utf8');
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object') return fallback;
if (Array.isArray(fallback) && !Array.isArray(parsed)) return fallback;
return parsed as T;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback;
throw err;
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: implement iterate run + iterate watch (file-based persistence, agent invocation)

1 participant