diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f8355f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: build + test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build + - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30ddb04 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +## v2.0 — Codified autonomous harness + +Ground-up rewrite of the Ralph technique from a 55-line bash driver into a +cross-platform TypeScript runtime with mechanical guardrails. Split into two +packages: **`ralph-loop`** (the `ralph` runtime CLI) and **`create-ralph-loop`** +(the scaffolder, bumped to 2.0). + +### Added + +- **Guardrailed run loop** (`ralph run`): per-iteration `checkpoint → coder → + mechanical gates → independent verifier → accept-commit or hard-revert`. +- **Mechanical gates** run by the harness: `featureIntegrity` (features.json is + harness-owned), diff-size, and baseline-relative typecheck/test/build (only new + failures block). +- **Independent, fail-closed verifier** — a fresh context on a cheaper model + confirms every claimed pass; success is no longer self-graded. +- **Budgets** (cost / iteration / wall-clock) and **notifications** (webhook + + desktop); structured telemetry in `.ralph/progress.jsonl` and + `.ralph/run-state.json`. +- **Periodic replan** (strong-model DAG revision) and **gardener** (entropy + cleanup) passes. +- **Multi-model routing** via role → (adapter, model, permission tier); adapters + for `claude`, `codex`, and `aider` (**local LLMs** via `ollama/…`). +- **features.json v2** with `depends_on` / `status` / `attempts` / `verification` + and dormant `lease` fields; DAG-aware selection; dependency-minimized planning. +- New CLI: `ralph plan | dev | doctor | status | migrate | export`. +- `ralph.config.json` with a published JSON schema; GitHub Actions CI + (ubuntu + windows). + +### Changed + +- Orchestration is native TypeScript — **no bash required** (first-class Windows). +- Scaffolder emits a thin config + specs (templating moved to eta); the loop + updates via a version bump instead of re-scaffolding. +- Completion is now "all features verified" (the unused `COMPLETE + ` sentinel is gone). + +### Removed + +- Shipped `ralph.sh` / `init.sh` / `scripts/dev-*.sh` and the duplicated + adopt-mode script string literals — replaced by the runtime. + +### Migrating from v1 + +```bash +npx ralph migrate # feature_list.json → features.json v2, writes ralph.config.json, + # parks old bash scripts under .ralph/legacy/ +``` + +### Deferred + +- Multi-framework greenfield template packs (the runtime is already + framework-agnostic via configurable `devServer.command` and gates). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2075c6e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,102 @@ +# Contributing + +Thanks for working on Ralph Loop. This is an npm-workspaces monorepo with two +published packages and a shared test suite. + +## Layout + +``` +packages/ +├── ralph/ # ralph-loop — the runtime CLI (the product) +│ ├── src/ # module map below +│ ├── assets/prompts/ # default agent prompts (eta templates) +│ ├── schema/ # generated JSON schema for ralph.config.json +│ └── scripts/gen-schema.ts +└── create-ralph-loop/ # the scaffolder + ├── src/cli.ts + └── template/ # what gets written into a scaffolded project +e2e/ # real-git, mock-adapter end-to-end loop tests +``` + +## Dev workflow + +```bash +npm install +npm run build # tsc for both packages + regenerates schema/ralph.config.schema.json +npm test # vitest: unit suites + the e2e loop suite +npm run test:watch +``` + +- **Node ≥ 18, CommonJS.** Use normal imports (no `.js` extensions) and `node:`-prefixed builtins. +- **Never spawn processes directly.** Go through `packages/ralph/src/util/proc.ts` + (`run`, `runShell`, `spawnDetached`, `commandExists`, `killTree`). It uses + cross-spawn for argv spawns (Windows `.cmd` resolution) and Node's native spawn + for shell commands (cross-spawn misreports shell exit code 1 as ENOENT on Windows). +- **Validate external/parsed data with zod; fail closed.** Unparseable agent + output must never throw — degrade to a rejecting outcome. +- Prompts render with **eta** (`<%= it.x %>`), not handlebars, despite the history. + +## Module map (`packages/ralph/src`) + +| Area | Files | Responsibility | +|---|---|---| +| Foundation | `config/schema.ts`, `features/schema.ts`, `adapters/types.ts`, `events/types.ts` | zod schemas + shared type contracts (imported everywhere). | +| Adapters | `adapters/{claude,codex,aider,mock,registry}.ts` | Wrap agent CLIs behind `RunnerAdapter`. | +| Gates | `gates/{baseline,command,featureIntegrity,diffSize,index}.ts` | Mechanical checks over a `GateContext`. | +| Features | `features/{store,dag,migrate}.ts` | Harness-owned feature state, DAG selection, v1→v2 migration. | +| Dev server | `devserver/manager.ts` | Cross-platform dev-server lifecycle. | +| Prompts | `prompts/{render,blocks}.ts` | Template resolution + `` block parsing. | +| Verify / replan / garden | `verify/verifier.ts`, `replan/replanner.ts`, `garden/gardener.ts` | The three supporting agent roles. | +| Run | `run/{loop,iteration,checkpoint,state,types}.ts` | The orchestrator state machine. | +| Support | `budget/tracker.ts`, `notify/`, `events/log.ts`, `util/*` | Budgets, notifications, telemetry, proc/git/paths/logger. | +| CLI | `cli.ts` | `commander` wiring; assembles the `RunContext`. | + +The **loop is the only consumer** of most modules, so a subagent implementing one +module only needs the foundation types — the loop reconciles them. + +## Recipe: add a runner adapter + +1. Create `packages/ralph/src/adapters/.ts` exporting a class that + implements `RunnerAdapter` (`adapters/types.ts`): `name`, `isAvailable()` + (via `commandExists`), and `invoke(req)` returning an `AgentResult`. + - Build argv, pass the prompt via `run(..., { input })` (stdin) to avoid + argv-length limits. + - Map `req.permissionTier` (`readonly`/`edit`/`full`) to the CLI's own flags. + - Extract token/cost into `usage` when the CLI reports it; return `usage: + undefined` otherwise (the loop falls back to iteration/time budgets). + - Keep parsing in an exported pure function and unit-test it against a fixture. +2. Register it in `adapters/registry.ts` (`getAdapter` switch). +3. It's now selectable via any role in `ralph.config.json` (`"adapter": ""`). + +## Recipe: add a gate + +1. Create `packages/ralph/src/gates/.ts` exporting a class implementing + `Gate` (`gates/types.ts`): `name` + `run(ctx: GateContext): GateResult`. + Gates are git-pure — read only from `ctx` (`changedFiles`, `diffStat`, + `featuresHash*`, `baseline`); only command gates spawn a subprocess (via + `runShell`). For baseline-relative behavior, compare current failures to + `ctx.baseline` and block only on new signatures. +2. Wire it into `buildGates()` in `gates/index.ts` (respect a config toggle). +3. Add a colocated `*.test.ts`. + +## Recipe: change a prompt + +Defaults live in `packages/ralph/assets/prompts/*.md` (eta). A project can +override any of them at `/prompts/.md` — `prompts/render.ts` +prefers the override. If you add a template that emits a structured block, add a +`parse*` function + zod schema in `prompts/blocks.ts` (fail-closed). + +## Testing + +- **Unit:** colocated `*.test.ts` next to each module. Run one file with + `npx vitest run ` (esbuild transpiles per-file; no full build needed). +- **End-to-end:** `e2e/loop.e2e.test.ts` drives `runLoop` with the `MockAdapter` + against a real temp git repo — the reference for how the pieces compose + (happy path, gate/verifier failure + revert + retry + block, integrity tamper, + dependency ordering, replan). Add scenarios here when changing loop behavior. +- CI runs `build` + `test` on ubuntu **and** windows; keep both green. + +## Commits & PRs + +- Conventional-commit prefixes (`feat(ralph):`, `fix:`, `docs:`, `chore:`). +- Branch off `main`; open a PR. CI must pass on both OSes. diff --git a/README.md b/README.md index 30f4225..188292f 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,129 @@ -# create-ralph-loop +# Ralph Loop -Scaffold a [Ralph](https://ghuntley.com/ralph/) agentic automation loop for AI-driven iterative development. +An optimized, codified harness for **autonomous software building**. You describe an outcome; a guardrailed loop of AI coding agents builds toward it — checkpointing, running mechanical gates, verifying its own work with an independent model, tracking cost, and supervising itself — so you supervise by exception instead of eyeballing every iteration. -Ralph is a harness that drives Claude (or Codex) through a structured, iterative feature-implementation workflow. You describe your idea, the agents generate your specs and feature list, then Ralph loops through them one at a time — implementing, testing, and committing each feature automatically. +This is a ground-up v2 of the original [Ralph](https://ghuntley.com/ralph/) technique (a stateless coding agent re-run in a loop against a feature list). The 55-line bash driver is gone; the orchestrator is now a cross-platform TypeScript runtime with real guardrails. -## Quick Start +## Packages -### New Project +| Package | What it is | +|---|---| +| [`ralph-loop`](packages/ralph) | The runtime CLI (`ralph`). Owns the loop, adapters, gates, verifier, budgets, telemetry. | +| [`create-ralph-loop`](packages/create-ralph-loop) | Scaffolder. Writes a thin config + specs into a new or existing project. | -```bash -npx github:weststack-io/create-ralph-loop my-project -cd my-project -``` - -Then describe your idea and let the agents do the rest: +## Quick start ```bash -# 1. Edit specs/phase1/PRD.md with your idea (or generate it): -claude -p "$(cat specs/phase1/prompts/prd_prompt.md)" \ - --allowedTools "Read,Write,Edit,Glob,Grep,Bash" +# 1. Scaffold (new project) +npx github:weststack-io/create-ralph-loop my-app +cd my-app && npm install # installs the ralph-loop runtime -# 2. Generate specs, feature list, and scaffold the project: -claude -p "$(cat specs/phase1/prompts/init_prompt.md)" \ - --allowedTools "Read,Write,Edit,Glob,Grep,Bash" +# 2. Generate specs from an idea (uses the planner model) +npx ralph plan --idea "a habit-tracking PWA with streaks and reminders" -# 3. Run the Ralph loop: -./ralph.sh --claude 20 +# 3. Check the setup, then run the autonomous loop +npx ralph doctor +npx ralph run --budget 20 # build until done, $20 cost cap ``` -### Existing Project +Watch progress with `ralph status`; supervise by exception via desktop/webhook notifications. -Run adoption mode from an existing codebase: - -```bash -cd my-existing-app -npx github:weststack-io/create-ralph-loop --adopt -``` +## How the loop works (v2) -For non-interactive adoption, skip existing files and apply safe JSON/gitignore merges: +Each iteration is a state machine the **harness** drives — success is never self-graded: -```bash -npx github:weststack-io/create-ralph-loop --adopt --yes -``` - -To reverse-engineer initial Ralph specs from the current codebase, use the installed Claude CLI: - -```bash -npx github:weststack-io/create-ralph-loop --adopt --generate-specs ``` - -Use Codex for that generation pass instead: - -```bash -npx github:weststack-io/create-ralph-loop --adopt --generate-specs --codex +select next DAG-eligible feature ← harness picks it (agent no longer self-selects) + │ + git checkpoint (known-good commit to revert to) + │ + coder agent → implements ONE feature, emits a block + │ + mechanical gates (harness runs them, not the agent): + · featureIntegrity — features.json is harness-owned; edits are rejected + · diff size — sanity bound on churn + · typecheck/test/build — baseline-relative: only NEW failures block + │ + independent verifier (fresh context, cheaper model, fail-closed) + │ + ┌── all pass ─→ commit (code + status) atomically, feature = verified + └── any fail ─→ git reset --hard to checkpoint; retry (bounded) → block + │ + budgets · stall detection · periodic replan · periodic gardening ``` -## Demo +Guardrails, all mechanical: -Watch the full walkthrough of using the Ralph loop to build a working app from scratch: +- **Checkpoint + auto-revert.** A failed gate or verdict hard-reverts the iteration. No half-finished work survives. +- **Independent, fail-closed verification.** A separate context (ideally a different/cheaper model) re-checks every claimed pass by executing the feature's steps. Ambiguous or unparseable → treated as failure. +- **Baseline-relative gates.** Pre-existing test/type failures are tolerated; only failures your change *introduces* block it. +- **Bounded retries → block.** After `retries.maxAttempts`, a feature is marked blocked (with the reason) and the loop moves on instead of thrashing. +- **Budgets + stall detection.** Cost / iteration / wall-clock caps halt the run; lack of progress notifies and eventually halts. +- **Periodic self-improvement.** A strong model reviews the plan and git history and may reprioritize / block / unblock / split / prune / add dependencies (`replan.everyIterations`). A "gardener" pass periodically cleans entropy/"AI slop" (`garden.everyIterations`). +- **Structured telemetry.** Every event lands in `.ralph/progress.jsonl`; per-role token/cost accounting in `.ralph/run-state.json`. -[![Watch on YouTube](https://img.shields.io/badge/YouTube-Watch%20Walkthrough-red?logo=youtube)](https://youtu.be/InIwg8_B-2U?si=iydzSiO9k3KKZv2n) +## Model routing -The demo app built in that video is available here: [weststack-io/tether](https://github.com/weststack-io/tether) +Roles are mapped to (adapter, model, permission tier) in `ralph.config.json`. The shipped default routes a strong model to planning, a capable coder to building, and a cheap model to verification (validated by Aider's Architect/Editor split and RouteLLM): -## What You Get - -``` -my-project/ -├── ralph.sh # Main loop driver (runs N iterations of Claude/Codex) -├── init.sh # Environment setup (idempotent) -├── scripts/ -│ ├── dev-up.sh # Start dev server in background -│ ├── dev-down.sh # Stop dev server -│ └── dev-cleanup.sh # Clean stale global Ralph server registry entries -├── specs/phase1/ -│ ├── PRD.md # Product requirements (generated from your idea) -│ ├── app_spec.txt # Technical specification (generated from PRD) -│ ├── feature_list.json # Feature catalog with pass/fail tracking (generated from PRD) -│ └── prompts/ -│ ├── prd_prompt.md # Generates PRD from your idea -│ ├── init_prompt.md # Generates specs + scaffolds the project -│ └── coding_prompt.md # 10-step workflow (run each iteration) -├── progress.txt # Session log (appended by the agent) -├── CLAUDE.md # Claude Code project instructions -├── AGENTS.md # Agent behavior rules -├── .mcp.json # Playwright MCP for browser testing -├── .env.example # Environment variable template, including DEV_PORT -└── .gitignore +```jsonc +"roles": { + "coder": { "adapter": "codex", "permissionTier": "full" }, + "verifier": { "adapter": "claude", "model": "claude-haiku-4-5-20251001", "permissionTier": "readonly" }, + "planner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "edit" }, + "replanner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "readonly" } +} ``` -## How the Ralph Loop Works +Adapters are pluggable (`claude`, `codex`, `aider`). Cross-vendor coder/verifier is a recommended diversity win. -``` -ralph.sh [--claude|--codex] - │ - ├─→ dev-down.sh (kill any stale server) - ├─→ init.sh (npm install, prisma, .env) - ├─→ dev-up.sh (start dev server on DEV_PORT, wait for ready) - │ - └─→ FOR i=1 TO N: - │ - ├─→ Feed coding_prompt.md to Claude/Codex - │ │ - │ ├─ Step 1: Orient (read progress, features, git log) - │ ├─ Step 2: Verify dev server is up - │ ├─ Step 3: Regression check existing features - │ ├─ Step 4: Pick next unfinished feature by priority - │ ├─ Step 5: Implement - │ ├─ Step 6: Test (Jest + Playwright) - │ ├─ Step 7: Mark feature as passing - │ ├─ Step 8: Append progress notes - │ ├─ Step 9: Git commit - │ └─ Step 10: Verify clean state (tsc, git status) - │ - └─→ Exit early if COMPLETE found -``` - -## The Workflow +### Local LLMs -### 1. Create Your Project +Use the `aider` adapter with a local model — the coder runs offline while a cheap hosted verifier keeps the fail-closed safety: -```bash -npx github:weststack-io/create-ralph-loop my-project -cd my-project +```jsonc +"coder": { "adapter": "aider", "model": "ollama/qwen2.5-coder", "permissionTier": "full" } ``` -This scaffolds the Ralph loop structure with prompts, scripts, and template spec files. - -### 2. Generate the PRD - -Before running the PRD prompt, edit the project description at the top of `specs/phase1/prompts/prd_prompt.md` with a detailed description of your idea. The more detail you provide, the better the output. Then run: +## Commands -```bash -claude -p "$(cat specs/phase1/prompts/prd_prompt.md)" \ - --allowedTools "Read,Write,Edit,Glob,Grep,Bash" ``` - -This generates a complete `specs/phase1/PRD.md` with requirements, user stories, and feature scope. - -Review the PRD and edit it if anything is off before moving on. - -### 3. Generate Specs and Scaffold - -```bash -claude -p "$(cat specs/phase1/prompts/init_prompt.md)" \ - --allowedTools "Read,Write,Edit,Glob,Grep,Bash" +ralph run [--iterations N] [--budget USD] [--no-verify] [--fresh] +ralph plan [--idea "..."] [--prd-only] generate PRD / app_spec / features.json +ralph dev up | down | status dev-server lifecycle (cross-platform) +ralph doctor diagnose adapters / git / config / DAG +ralph status summarize the latest run +ralph migrate upgrade a v1 project (feature_list.json + ralph.sh) +ralph export --format eval-jsonl verification records for offline eval ``` -This does three things in one pass: -1. **Generates `app_spec.txt`** — data models, API routes, business logic, UI layout — all derived from your PRD -2. **Generates `feature_list.json`** — a prioritized list of features with verification steps, all marked `passes: false` -3. **Scaffolds the project** — Next.js app, database, types, layout, stub routes, test config, and initial git commit - -### 4. Run the Loop +## Migrating from v1 ```bash -./ralph.sh --claude 20 # 20 iterations with Claude -./ralph.sh --codex 10 # 10 iterations with Codex +cd my-old-ralph-project +npx ralph migrate # feature_list.json → features.json v2, writes ralph.config.json, + # parks the old bash scripts under .ralph/legacy/ +npx ralph doctor ``` -Each iteration implements one feature. The loop exits early when all features pass. - -### 5. Monitor Progress +## Requirements -- **`progress.txt`** — Read the session log to see what was done -- **`specs/phase1/feature_list.json`** — Check which features have `"passes": true` -- **`git log`** — Each feature gets its own commit +- **Node.js** ≥ 18 +- **git** +- At least one agent CLI on PATH: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), or [aider](https://aider.chat) (for local models) +- **No bash required** — the runtime is native TypeScript and works on Windows, macOS, and Linux. -## CLI Options +## Development +```bash +npm install +npm run build # builds both packages + generates the config JSON schema +npm test # vitest: unit + a real-git mock-adapter e2e suite ``` -Usage: create-ralph-loop [options] [project-directory] - -Options: - -y, --yes Use defaults for all prompts - --no-git Skip git init in greenfield mode - --no-install Reserved for compatibility - --adopt Adopt Ralph Loop into an existing project - --init Alias for --adopt - --generate-specs Generate adopted specs using an installed agent CLI - --codex Use Codex instead of Claude for --generate-specs - -h, --help Display help -``` - -## Adoption Mode - -Adoption mode layers Ralph Loop files into an existing project without replacing application code or `package.json`. It detects the package manager from lockfiles, detects common frameworks from dependencies, and adapts `init.sh`, `scripts/dev-up.sh`, and `scripts/dev-down.sh` to the detected dev command and port. - -When a Ralph-owned file already exists, interactive adoption prompts you to skip or overwrite it. For `CLAUDE.md` and `AGENTS.md`, it can also append a delimited Ralph section. `.mcp.json` is merged by adding the Playwright server while preserving existing MCP servers. `.gitignore` is appended with Ralph runtime files such as `.dev-server.pid` and `.dev-server.log`. - -`--generate-specs` does not add an SDK dependency or ask for API keys. It invokes the installed agent CLI: Claude by default, or Codex when `--codex` is provided. - -## Port Management -Each generated or adopted project gets a `DEV_PORT` entry in `.env.local`. The CLI chooses a deterministic port in the `3000-3999` range from the project name, unless an existing `DEV_PORT` or `PORT` is already present. +## Design provenance -`scripts/dev-up.sh` reads `.env` and `.env.local`, starts the dev server with `PORT` and `DEV_PORT` exported, and registers the process in `~/.ralph/servers.json`. `scripts/dev-down.sh` removes this project's registry entry when stopping the local PID. `scripts/dev-cleanup.sh` removes stale registry entries; pass `--kill-live` to stop all live registered Ralph dev servers. - -If another registered Ralph project is already using the selected port, `dev-up.sh` exits with a clear message instead of killing the other project. If an unregistered process is using the port, choose another `DEV_PORT` or stop that process manually. - -## Requirements - -- **Node.js** >= 18 (v24 LTS recommended) -- **npm** -- **git** -- **[Claude CLI](https://docs.anthropic.com/en/docs/claude-code)** or **[Codex CLI](https://github.com/openai/codex)** -- **Bash shell** (native on macOS/Linux, Git Bash or WSL on Windows) +The guardrail design is grounded in published practice: OpenAI's harness-engineering report (Ralph loops at production scale; verification externalized into the environment; recurring "gardening" agents against entropy), the hermes-agent/Nightwire fail-closed independent-verification pattern (separate context, baseline-relative regressions, bounded self-heal), Anthropic's multi-agent cost/coordination guidance (effort scaling, max-iteration + escalation), and Aider Architect/Editor + RouteLLM for criticality-based model routing. ## License diff --git a/e2e/loop.e2e.test.ts b/e2e/loop.e2e.test.ts new file mode 100644 index 0000000..7281479 --- /dev/null +++ b/e2e/loop.e2e.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +import { defaultConfig, type RalphConfig } from "../packages/ralph/src/config/schema"; +import { FeatureStore } from "../packages/ralph/src/features/store"; +import { buildGates } from "../packages/ralph/src/gates"; +import { MockAdapter } from "../packages/ralph/src/adapters/mock"; +import { EventLog } from "../packages/ralph/src/events/log"; +import { RunStateStore } from "../packages/ralph/src/run/state"; +import { NotificationHub } from "../packages/ralph/src/notify"; +import { DevServerManager } from "../packages/ralph/src/devserver/manager"; +import { runLoop } from "../packages/ralph/src/run/loop"; +import type { RunContext } from "../packages/ralph/src/run/types"; +import type { FeatureFile } from "../packages/ralph/src/features/schema"; + +// --- test scaffolding ------------------------------------------------------- + +let cwd: string; + +function git(args: string[]): void { + execFileSync("git", args, { cwd, stdio: "pipe" }); +} + +function initRepo(features: FeatureFile): void { + fs.mkdirSync(path.join(cwd, "specs"), { recursive: true }); + fs.mkdirSync(path.join(cwd, "src"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "package.json"), JSON.stringify({ name: "fixture-app", version: "0.0.0" }, null, 2)); + fs.writeFileSync(path.join(cwd, ".gitignore"), ".ralph/\nnode_modules/\n"); + fs.writeFileSync(path.join(cwd, "specs", "features.json"), JSON.stringify(features, null, 2) + "\n"); + git(["init", "-q"]); + git(["config", "user.email", "test@ralph.dev"]); + git(["config", "user.name", "Ralph Test"]); + git(["config", "commit.gpgsign", "false"]); + git(["add", "-A"]); + git(["commit", "-q", "--no-verify", "-m", "init"]); +} + +function commitLog(): string[] { + return execFileSync("git", ["log", "--format=%s"], { cwd, encoding: "utf8" }).trim().split("\n").filter(Boolean); +} + +function baseConfig(overrides: (c: RalphConfig) => void): RalphConfig { + const c = defaultConfig(); + c.specDir = "specs"; + c.devServer.enabled = false; + c.verify.enabled = false; + c.gates.typecheck = false; + c.gates.test = false; + c.gates.build = false; + c.stall.noProgressIterations = 999; // isolate from stall halting unless tested + overrides(c); + return c; +} + +function noopAdapter(): MockAdapter { + return new MockAdapter(() => ({ exitCode: 0, rawOutput: "", durationMs: 1, timedOut: false })); +} + +function makeContext( + config: RalphConfig, + coder: MockAdapter, + verifier: MockAdapter, + replanner: MockAdapter = noopAdapter(), + gardener: MockAdapter = noopAdapter(), +): RunContext { + const store = new FeatureStore(path.join(cwd, "specs", "features.json")); + store.load(); + const eventLog = new EventLog(cwd); + const stateStore = new RunStateStore(cwd); + const state = stateStore.init("test-run", store.counts().total); + return { + cwd, + config, + projectName: "fixture-app", + featuresRelPath: "specs/features.json", + store, + devServer: new DevServerManager(cwd, config.devServer), + eventLog, + stateStore, + state, + gates: buildGates(config), + notifier: new NotificationHub([]), + coder: { adapter: coder, role: { adapter: "mock", permissionTier: "full" } }, + verifier: { adapter: verifier, role: { adapter: "mock", model: "mock-verifier", permissionTier: "readonly" } }, + replanner: { adapter: replanner, role: { adapter: "mock", permissionTier: "readonly" } }, + gardener: { adapter: gardener, role: { adapter: "mock", permissionTier: "full" } }, + stream: false, + agentTimeoutMs: 30_000, + }; +} + +function result(outcome: "implemented" | "partial" | "blocked", summary = "did the thing"): string { + return `${JSON.stringify({ feature: "x", outcome, summary, blockers: [] })}`; +} +function verdict(v: "pass" | "fail" | "inconclusive"): string { + return `${JSON.stringify({ verdict: v, steps: [], concerns: v === "pass" ? [] : ["nope"] })}`; +} + +/** Coder that writes a unique source file each call and reports implemented. */ +function writingCoder(): MockAdapter { + return new MockAdapter((req, i) => { + // src/ may have been removed by `git clean -fd` on a prior revert; recreate it. + fs.mkdirSync(path.join(req.cwd, "src"), { recursive: true }); + fs.writeFileSync(path.join(req.cwd, "src", `mod_${i}.ts`), `export const v${i} = ${i};\n`); + return { exitCode: 0, rawOutput: result("implemented"), durationMs: 1, timedOut: false }; + }); +} + +function features(list: Array<{ id: string; priority: number; deps?: string[] }>): FeatureFile { + return { + version: 2, + features: list.map((f) => ({ + id: f.id, + category: "feature", + priority: f.priority, + description: `implement ${f.id}`, + steps: [`build ${f.id}`], + depends_on: f.deps ?? [], + status: "pending", + attempts: 0, + blocked_reason: null, + verification: null, + lease: null, + })), + }; +} + +beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-e2e-")); +}); +afterEach(() => { + try { + fs.rmSync(cwd, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// --- scenarios -------------------------------------------------------------- + +describe("runLoop end-to-end (mock adapters, real git)", () => { + it("happy path: implements and accepts each feature, ends complete", async () => { + initRepo(features([{ id: "F1", priority: 1 }, { id: "F2", priority: 2 }])); + const config = baseConfig(() => {}); + const ctx = makeContext(config, writingCoder(), new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + const summary = await runLoop(ctx, { maxIterations: 10 }); + + expect(summary.reason).toBe("all features complete"); + expect(summary.passed + summary.verified).toBe(2); + expect(ctx.store.get("F1")!.status).toBe("passed"); // verify disabled → passed + const log = commitLog(); + expect(log.filter((m) => m.startsWith("ralph(F1)"))).toHaveLength(1); + expect(log.filter((m) => m.startsWith("ralph(F2)"))).toHaveLength(1); + }); + + it("independent verifier: pass promotes to verified", async () => { + initRepo(features([{ id: "F1", priority: 1 }])); + const config = baseConfig((c) => { c.verify.enabled = true; }); + const ctx = makeContext(config, writingCoder(), new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + await runLoop(ctx, { maxIterations: 10 }); + expect(ctx.store.get("F1")!.status).toBe("verified"); + expect(ctx.store.get("F1")!.verification?.verdict).toBe("pass"); + }); + + it("verifier failure reverts and eventually blocks the feature", async () => { + initRepo(features([{ id: "F1", priority: 1 }])); + const config = baseConfig((c) => { c.verify.enabled = true; c.retries.maxAttempts = 1; }); + const ctx = makeContext(config, writingCoder(), new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("fail"), durationMs: 1, timedOut: false }))); + + const summary = await runLoop(ctx, { maxIterations: 10 }); + expect(ctx.store.get("F1")!.status).toBe("blocked"); + expect(summary.blocked).toBe(1); + // no accept commit for F1 + expect(commitLog().some((m) => m.startsWith("ralph(F1): verified"))).toBe(false); + }); + + it("failing gate reverts the change; retries then blocks after maxAttempts", async () => { + initRepo(features([{ id: "F1", priority: 1 }])); + const config = baseConfig((c) => { + c.retries.maxAttempts = 2; + c.gates.typecheck = { command: 'node -e "process.exit(1)"', baselineRelative: false, timeoutMs: 30_000 }; + }); + const ctx = makeContext(config, writingCoder(), new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + const summary = await runLoop(ctx, { maxIterations: 20 }); + expect(ctx.store.get("F1")!.status).toBe("blocked"); + expect(summary.iterations).toBe(3); // attempt 1,2 retry; attempt 3 blocks + // working tree clean, no orphaned src files from reverted attempts + expect(fs.existsSync(path.join(cwd, "src", "mod_0.ts"))).toBe(false); + }); + + it("integrity gate: agent editing features.json is reverted", async () => { + initRepo(features([{ id: "F1", priority: 1 }])); + const config = baseConfig((c) => { c.retries.maxAttempts = 0; }); + const tamperingCoder = new MockAdapter((req) => { + fs.writeFileSync(path.join(req.cwd, "src", "ok.ts"), "export const ok = 1;\n"); + const fp = path.join(req.cwd, "specs", "features.json"); + const f = JSON.parse(fs.readFileSync(fp, "utf8")); + f.features[0].status = "verified"; // illicit self-grade + fs.writeFileSync(fp, JSON.stringify(f, null, 2) + "\n"); + return { exitCode: 0, rawOutput: result("implemented"), durationMs: 1, timedOut: false }; + }); + const ctx = makeContext(config, tamperingCoder, new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + await runLoop(ctx, { maxIterations: 5 }); + // reverted + blocked (maxAttempts 0), status must NOT be the agent's forged "verified" + expect(ctx.store.get("F1")!.status).toBe("blocked"); + }); + + it("dependency ordering: dependent feature waits for its prerequisite", async () => { + initRepo(features([{ id: "F1", priority: 2 }, { id: "F2", priority: 1, deps: ["F1"] }])); + const config = baseConfig((c) => { c.verify.enabled = true; }); + const ctx = makeContext(config, writingCoder(), new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + await runLoop(ctx, { maxIterations: 10 }); + const transitions = ctx.eventLog.read().filter((e) => e.type === "feature_transition").map((e) => (e as { featureId: string }).featureId); + // F1 must be accepted before F2 even though F2 has the lower priority number + expect(transitions).toEqual(["F1", "F2"]); + }); + + it("replanner can block a feature mid-run (self-improvement hook)", async () => { + initRepo(features([{ id: "F1", priority: 1 }, { id: "F2", priority: 2 }])); + const config = baseConfig((c) => { c.replan.everyIterations = 1; }); + const replanner = new MockAdapter(() => ({ + exitCode: 0, + rawOutput: `${JSON.stringify({ operations: [{ op: "block", featureId: "F2", reason: "descoped" }], summary: "drop F2" })}`, + durationMs: 1, + timedOut: false, + })); + const ctx = makeContext(config, writingCoder(), noopAdapter(), replanner); + + const summary = await runLoop(ctx, { maxIterations: 10 }); + expect(ctx.store.get("F1")!.status).toBe("passed"); + expect(ctx.store.get("F2")!.status).toBe("blocked"); + expect(summary.iterations).toBe(1); // F1 done, replan blocks F2 → nothing eligible + const replans = ctx.eventLog.read().filter((e) => e.type === "replan"); + expect(replans.length).toBeGreaterThanOrEqual(1); + }); + + it("agent-reported blocked: reverts and blocks without exhausting retries", async () => { + initRepo(features([{ id: "F1", priority: 1 }])); + const config = baseConfig((c) => { c.retries.maxAttempts = 3; }); + const givingUpCoder = new MockAdapter((req) => { + fs.writeFileSync(path.join(req.cwd, "src", "partial.ts"), "// wip\n"); + return { exitCode: 0, rawOutput: result("blocked", "cannot do this"), durationMs: 1, timedOut: false }; + }); + const ctx = makeContext(config, givingUpCoder, new MockAdapter(() => ({ exitCode: 0, rawOutput: verdict("pass"), durationMs: 1, timedOut: false }))); + + const summary = await runLoop(ctx, { maxIterations: 10 }); + expect(ctx.store.get("F1")!.status).toBe("blocked"); + expect(summary.iterations).toBe(1); // no wasted retries on an explicit give-up + }); +}); diff --git a/package-lock.json b/package-lock.json index efee191..16652f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,731 +1,2815 @@ { - "name": "create-ralph-loop", - "version": "1.0.0", + "name": "ralph-workspace", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "create-ralph-loop", - "version": "1.0.0", + "name": "ralph-workspace", + "version": "0.0.0", "license": "MIT", - "dependencies": { - "chalk": "^5.0.0", - "commander": "^12.0.0", - "fs-extra": "^11.0.0", - "inquirer": "^9.0.0" - }, - "bin": { - "create-ralph-loop": "dist/cli.js" - }, + "workspaces": [ + "packages/*" + ], "devDependencies": { - "@types/fs-extra": "^11.0.0", - "@types/inquirer": "^9.0.0", - "@types/node": "^20.0.0", - "typescript": "^5.0.0" + "@types/node": "^20.14.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" }, "engines": { "node": ">=18" } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } } }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" } }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@types/inquirer": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.9.tgz", - "integrity": "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/through": "*", - "rxjs": "^7.2.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", - "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "devOptional": true, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@types/through": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", - "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" ], + "dev": true, "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12" + "node": ">=18" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.8" + "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=7.0.0" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/inquirer": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.9.tgz", + "integrity": "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/create-ralph-loop": { + "resolved": "packages/create-ralph-loop", + "link": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/growly": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", + "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", + "license": "MIT", + "optional": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "9.3.8", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", + "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.2", + "@inquirer/figures": "^1.0.3", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "optional": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-notifier": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-10.0.1.tgz", + "integrity": "sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "growly": "^1.3.0", + "is-wsl": "^2.2.0", + "semver": "^7.3.5", + "shellwords": "^0.1.1", + "uuid": "^8.3.2", + "which": "^2.0.2" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/ralph-loop": { + "resolved": "packages/ralph", + "link": true + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, - "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shellwords": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", + "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", + "license": "MIT", + "optional": true + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", "dependencies": { - "clone": "^1.0.2" + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=8" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">=0.10.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/vitest" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" ], - "license": "BSD-3-Clause" + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/inquirer": { - "version": "9.3.8", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.3.8.tgz", - "integrity": "sha512-pFGGdaHrmRKMh4WoDDSowddgjT1Vkl90atobmTeSmcPGdYiwikch/m/Ef5wRaiamHejtw0cUUMMerzDUXCci2w==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@inquirer/external-editor": "^1.0.2", - "@inquirer/figures": "^1.0.3", - "ansi-escapes": "^4.3.2", - "cli-width": "^4.1.0", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": ">=12" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=12" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">=12" } }, - "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "license": "ISC", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=12" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=12" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 6" + "node": ">=12" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.12.0" + "node": ">=12" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "license": "(MIT OR CC0-1.0)", + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, - "license": "Apache-2.0", + "hasInstallScript": true, + "license": "MIT", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=14.17" + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">= 10.0.0" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -735,6 +2819,38 @@ "defaults": "^1.0.3" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -760,6 +2876,72 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "packages/create-ralph-loop": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "commander": "^12.0.0", + "eta": "^3.4.0", + "fs-extra": "^11.0.0", + "inquirer": "^9.0.0" + }, + "bin": { + "create-ralph-loop": "dist/cli.js" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.0", + "@types/inquirer": "^9.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/ralph": { + "name": "ralph-loop", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "cross-spawn": "^7.0.6", + "eta": "^3.4.0", + "tree-kill": "^1.2.2", + "zod": "^3.23.8" + }, + "bin": { + "ralph": "dist/cli.js" + }, + "devDependencies": { + "@types/cross-spawn": "^6.0.6", + "zod-to-json-schema": "^3.23.5" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "node-notifier": "^10.0.1" + } } } } diff --git a/package.json b/package.json index 93d7416..37e3a2c 100644 --- a/package.json +++ b/package.json @@ -1,43 +1,26 @@ { - "name": "create-ralph-loop", - "version": "1.0.0", - "description": "Scaffold a Ralph agentic automation loop for AI-driven iterative development", - "bin": { - "create-ralph-loop": "./dist/cli.js" - }, - "files": [ - "dist/", - "template/" + "name": "ralph-workspace", + "private": true, + "version": "0.0.0", + "description": "Monorepo for the Ralph Loop autonomous software-building harness (ralph-loop runtime + create-ralph-loop scaffolder)", + "workspaces": [ + "packages/*" ], "scripts": { - "build": "tsc", - "prepare": "npm run build", - "prepublishOnly": "npm run build" - }, - "keywords": [ - "claude", - "ai", - "agent", - "scaffold", - "automation", - "codex", - "ralph" - ], - "author": "weststack", - "license": "MIT", - "dependencies": { - "commander": "^12.0.0", - "chalk": "^5.0.0", - "fs-extra": "^11.0.0", - "inquirer": "^9.0.0" + "build": "npm run build -w ralph-loop && npm run build -w create-ralph-loop", + "typecheck": "npm run typecheck --workspaces --if-present", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { - "@types/fs-extra": "^11.0.0", - "@types/inquirer": "^9.0.0", - "@types/node": "^20.0.0", - "typescript": "^5.0.0" + "@types/node": "^20.14.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" }, "engines": { "node": ">=18" - } + }, + "author": "weststack", + "license": "MIT" } diff --git a/.npmignore b/packages/create-ralph-loop/.npmignore similarity index 100% rename from .npmignore rename to packages/create-ralph-loop/.npmignore diff --git a/packages/create-ralph-loop/LICENSE b/packages/create-ralph-loop/LICENSE new file mode 100644 index 0000000..63e87b8 --- /dev/null +++ b/packages/create-ralph-loop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 weststack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/create-ralph-loop/README.md b/packages/create-ralph-loop/README.md new file mode 100644 index 0000000..f6de16b --- /dev/null +++ b/packages/create-ralph-loop/README.md @@ -0,0 +1,64 @@ +# create-ralph-loop + +Scaffold a **[Ralph Loop](https://github.com/weststack-io/create-ralph-loop)** autonomous-build harness into a new or existing project. Writes a thin config + specs and wires up the [`ralph-loop`](https://www.npmjs.com/package/ralph-loop) runtime — the guardrailed loop that builds toward an outcome for you. + +## Quick start + +```bash +# New project +npx create-ralph-loop my-app +cd my-app && npm install # installs the ralph-loop runtime + +npx ralph plan --idea "a habit tracker with streaks" # generate specs +npx ralph doctor +npx ralph run --budget 20 # build until done, $20 cap +``` + +## What it scaffolds + +``` +my-app/ +├── ralph.config.json # roles/models, gates, budgets, verify, dev server ($schema-validated) +├── AGENTS.md # concise table-of-contents into docs/ (golden principles, loop overview) +├── CLAUDE.md # @AGENTS.md +├── docs/ # knowledge-base skeleton (design/, plans/, tech-debt.md) +├── specs/phase1/ +│ ├── PRD.md # product requirements (fill in, or `ralph plan`) +│ ├── app_spec.txt # technical spec +│ └── features.json # v2 feature contract (dependency-minimized seed) +├── .mcp.json # Playwright MCP for browser verification +├── .env.example +└── .gitignore # includes .ralph/ (runtime state) +``` + +No bash loop scripts — the `ralph-loop` runtime owns the loop and dev-server lifecycle cross-platform. + +## Existing projects + +```bash +cd my-existing-app +npx create-ralph-loop --adopt # detects framework/package-manager/dev+test/port +npx ralph migrate # if upgrading from a v1 (feature_list.json + ralph.sh) +``` + +Adoption layers Ralph files in without touching your app code: it merges `ralph-loop` into `devDependencies`, deep-merges `.mcp.json`, appends delimited sections to `CLAUDE.md`/`AGENTS.md`, and feeds detection results into the generated `ralph.config.json` (dev command, port, test gate). + +## Options + +``` +Usage: create-ralph-loop [options] [project-directory] + + -y, --yes Use defaults for all prompts + --no-git Skip git init (greenfield) + --no-install Skip dependency install (adopt) + --adopt, --init Adopt into an existing project + -h, --help +``` + +## Requirements + +Node ≥ 18, git, and an agent CLI (`claude`, `codex`, or `aider`) for running the loop. See the [`ralph-loop`](https://www.npmjs.com/package/ralph-loop) runtime for loop details. + +## License + +MIT diff --git a/packages/create-ralph-loop/package.json b/packages/create-ralph-loop/package.json new file mode 100644 index 0000000..8f82bab --- /dev/null +++ b/packages/create-ralph-loop/package.json @@ -0,0 +1,44 @@ +{ + "name": "create-ralph-loop", + "version": "2.0.0", + "description": "Scaffold a Ralph Loop autonomous-build harness into a new or existing project", + "bin": { + "create-ralph-loop": "./dist/cli.js" + }, + "files": [ + "dist/", + "template/" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "prepare": "npm run build", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "claude", + "ai", + "agent", + "scaffold", + "automation", + "codex", + "ralph" + ], + "author": "weststack", + "license": "MIT", + "dependencies": { + "commander": "^12.0.0", + "eta": "^3.4.0", + "fs-extra": "^11.0.0", + "inquirer": "^9.0.0" + }, + "devDependencies": { + "@types/fs-extra": "^11.0.0", + "@types/inquirer": "^9.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/create-ralph-loop/src/cli.ts b/packages/create-ralph-loop/src/cli.ts new file mode 100644 index 0000000..40705b5 --- /dev/null +++ b/packages/create-ralph-loop/src/cli.ts @@ -0,0 +1,620 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import inquirer from "inquirer"; +import fs from "fs-extra"; +import path from "path"; +import { execSync } from "child_process"; +import { Eta } from "eta"; + +const TEMPLATE_DIR = path.join(__dirname, "..", "template"); +const RALPH_MARKER = "RALPH LOOP"; +const RALPH_LOOP_DEP_VERSION = "^0.1.0"; +const RALPH_GITIGNORE_ENTRIES = [ + "node_modules/", + ".env.local", + ".ralph/", + "specs/phase1/screenshots/", +]; + +// autoTrim disabled so `%>` never eats a following newline (eta gotcha). +const eta = new Eta({ autoTrim: false }); + +interface TemplateVars { + projectName: string; + projectSlug: string; + projectDescription: string; + createdAt: string; + devPort: string; + // Raw JSON fragments injected into ralph.config.json.eta. + devCommandJson: string; + installCommandJson: string; + testGate: string; +} + +interface ProjectDetection { + packageManager: "npm" | "pnpm" | "yarn"; + framework: string; + installCommand: string; + devCommand: string; + testCommand: string; + port: string; + packageName?: string; + packageDescription?: string; +} + +interface CliOptions { + yes: boolean; + git?: boolean; + install?: boolean; + adopt?: boolean; + init?: boolean; +} + +type ConflictAction = "skip" | "overwrite" | "merge"; + +function toKebabCase(str: string): string { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function toTitleCase(str: string): string { + return str + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c: string) => c.toUpperCase()); +} + +function deriveProjectPort(seed: string): string { + let hash = 0; + for (const char of seed) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return String(3000 + (hash % 1000)); +} + +function templateDestPath(entry: string): string { + const relativePath = path.relative(TEMPLATE_DIR, entry); + let destPath = relativePath; + const basename = path.basename(destPath); + if (basename.startsWith("_")) { + destPath = path.join(path.dirname(destPath), "." + basename.slice(1)); + } + if (destPath.endsWith(".eta")) { + destPath = destPath.slice(0, -4); + } + return destPath; +} + +async function renderTemplateEntry( + entry: string, + vars: TemplateVars +): Promise { + if (!entry.endsWith(".eta")) { + return fs.readFile(entry); + } + const content = await fs.readFile(entry, "utf-8"); + return eta.renderString(content, vars) as string; +} + +async function walkDir(dir: string): Promise { + const results: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...(await walkDir(fullPath))); + } else { + results.push(fullPath); + } + } + return results; +} + +async function scaffold(targetDir: string, vars: TemplateVars): Promise { + await fs.ensureDir(targetDir); + + const entries = await walkDir(TEMPLATE_DIR); + for (const entry of entries) { + const destPath = path.join(targetDir, templateDestPath(entry)); + const rendered = await renderTemplateEntry(entry, vars); + await fs.ensureDir(path.dirname(destPath)); + await fs.writeFile(destPath, rendered); + } +} + +async function readPackageJson( + targetDir: string +): Promise | null> { + const packageJsonPath = path.join(targetDir, "package.json"); + if (!(await fs.pathExists(packageJsonPath))) { + return null; + } + try { + return await fs.readJson(packageJsonPath); + } catch { + return null; + } +} + +async function readEnvPort(targetDir: string): Promise { + for (const fileName of [".env.local", ".env"]) { + const envPath = path.join(targetDir, fileName); + if (!(await fs.pathExists(envPath))) { + continue; + } + const content = await fs.readFile(envPath, "utf-8"); + const match = content.match(/^(?:DEV_PORT|PORT)=(\d+)\s*$/m); + if (match) { + return match[1]; + } + } + return undefined; +} + +function scriptCommand( + packageManager: ProjectDetection["packageManager"], + script: string +): string { + if (packageManager === "npm") { + return `npm run ${script}`; + } + if (packageManager === "pnpm") { + return `pnpm ${script}`; + } + return `yarn ${script}`; +} + +async function detectProject(targetDir: string): Promise { + const pkg = await readPackageJson(targetDir); + const deps = { + ...(pkg?.dependencies ?? {}), + ...(pkg?.devDependencies ?? {}), + }; + const scripts = pkg?.scripts ?? {}; + + let packageManager: ProjectDetection["packageManager"] = "npm"; + if (await fs.pathExists(path.join(targetDir, "pnpm-lock.yaml"))) { + packageManager = "pnpm"; + } else if (await fs.pathExists(path.join(targetDir, "yarn.lock"))) { + packageManager = "yarn"; + } + + let framework = "generic Node"; + if (deps.next) { + framework = "Next.js"; + } else if (deps.vite || deps["@vitejs/plugin-react"]) { + framework = "Vite"; + } else if (deps["@remix-run/dev"] || deps["@remix-run/react"]) { + framework = "Remix"; + } else if (deps.astro) { + framework = "Astro"; + } + + const projectSeed = pkg?.name ?? path.basename(targetDir); + const envPort = await readEnvPort(targetDir); + + return { + packageManager, + framework, + installCommand: + packageManager === "npm" + ? "npm install" + : packageManager === "pnpm" + ? "pnpm install" + : "yarn install", + devCommand: scriptCommand( + packageManager, + scripts.dev ? "dev" : scripts.start ? "start" : "dev" + ), + testCommand: scripts.test ? scriptCommand(packageManager, "test") : "", + port: envPort ?? deriveProjectPort(projectSeed), + packageName: pkg?.name, + packageDescription: pkg?.description, + }; +} + +function testGateFragment(command: string): string { + return `{ "command": ${JSON.stringify( + command + )}, "baselineRelative": true, "timeoutMs": 600000 }`; +} + +/** + * Derive the raw JSON fragments that feed ralph.config.json.eta. Greenfield + * (no detection) uses sensible npm defaults; adopt threads through the detected + * dev/install/test commands, disabling the test gate when no test script exists. + */ +function buildConfigVars(detection?: ProjectDetection): { + devCommandJson: string; + installCommandJson: string; + testGate: string; +} { + const devCommand = detection?.devCommand || "npm run dev"; + const installCommand = detection?.installCommand || "npm install"; + let testGate: string; + if (detection) { + testGate = detection.testCommand + ? testGateFragment(detection.testCommand) + : "false"; + } else { + testGate = testGateFragment("npm test"); + } + return { + devCommandJson: JSON.stringify(devCommand), + installCommandJson: JSON.stringify(installCommand), + testGate, + }; +} + +async function buildVars( + targetDir: string, + options: CliOptions, + detection?: ProjectDetection +): Promise { + const dirName = path.basename(targetDir); + let projectName = detection?.packageName ?? dirName; + let projectDescription = + detection?.packageDescription ?? "An AI-powered application"; + + if (!options.yes) { + const answers = await inquirer.prompt([ + { + type: "input", + name: "projectName", + message: "Project name:", + default: detection ? projectName : toTitleCase(dirName), + }, + { + type: "input", + name: "projectDescription", + message: "One-line description:", + default: projectDescription, + }, + ]); + projectName = answers.projectName; + projectDescription = answers.projectDescription; + } + + return { + projectName, + projectSlug: toKebabCase(projectName), + projectDescription, + createdAt: new Date().toISOString().split("T")[0], + devPort: detection?.port ?? deriveProjectPort(projectName || dirName), + ...buildConfigVars(detection), + }; +} + +// --------------------------------------------------------------------------- +// Adopt mode +// --------------------------------------------------------------------------- + +/** Files we never write directly into an existing project. */ +function adoptSkipDirect(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, "/"); + return ( + normalized === "README.md" || + normalized === ".env.example" || + normalized === ".gitignore" + ); +} + +async function adopt( + targetDir: string, + vars: TemplateVars, + options: CliOptions +): Promise { + const entries = await walkDir(TEMPLATE_DIR); + + for (const entry of entries) { + const relativePath = templateDestPath(entry); + if (adoptSkipDirect(relativePath)) { + continue; + } + const destPath = path.join(targetDir, relativePath); + const rendered = await renderTemplateEntry(entry, vars); + await writeAdoptFile(destPath, relativePath, rendered, options); + } + + await appendGitignoreEntries(path.join(targetDir, ".gitignore")); +} + +async function writeAdoptFile( + destPath: string, + relativePath: string, + content: Buffer | string, + options: CliOptions +): Promise { + const normalized = relativePath.replace(/\\/g, "/"); + + if (!(await fs.pathExists(destPath))) { + await fs.ensureDir(path.dirname(destPath)); + await fs.writeFile(destPath, content); + console.log(`Added ${normalized}`); + return; + } + + if (normalized === ".mcp.json") { + const merged = await mergeMcpJson(destPath, content.toString()); + console.log( + merged + ? "Merged .mcp.json" + : "Skipped .mcp.json (existing JSON could not be merged)" + ); + return; + } + + if (options.yes) { + console.log(`Skipped ${normalized} (already exists)`); + return; + } + + const choices = markdownMergeSupported(normalized) + ? ["skip", "overwrite", "merge"] + : ["skip", "overwrite"]; + const answer = await inquirer.prompt<{ action: ConflictAction }>([ + { + type: "list", + name: "action", + message: `${normalized} already exists. What should happen?`, + choices, + default: "skip", + }, + ]); + + if (answer.action === "skip") { + console.log(`Skipped ${normalized}`); + return; + } + + if (answer.action === "merge") { + await appendMarkdownSection(destPath, content.toString()); + console.log(`Merged ${normalized}`); + return; + } + + await fs.writeFile(destPath, content); + console.log(`Overwrote ${normalized}`); +} + +function markdownMergeSupported(relativePath: string): boolean { + return relativePath === "CLAUDE.md" || relativePath === "AGENTS.md"; +} + +async function appendMarkdownSection( + destPath: string, + content: string +): Promise { + const existing = await fs.readFile(destPath, "utf-8"); + if (existing.includes(`BEGIN ${RALPH_MARKER}`)) { + return; + } + const section = [ + "", + ``, + content.trim(), + ``, + "", + ].join("\n"); + await fs.writeFile(destPath, `${existing.trimEnd()}\n${section}`, "utf-8"); +} + +async function mergeMcpJson( + destPath: string, + incomingContent: string +): Promise { + try { + const existing = await fs.readJson(destPath); + const incoming = JSON.parse(incomingContent); + existing.mcpServers = { + ...(existing.mcpServers ?? {}), + ...(incoming.mcpServers ?? {}), + }; + await fs.writeJson(destPath, existing, { spaces: 2 }); + return true; + } catch { + return false; + } +} + +async function appendGitignoreEntries(gitignorePath: string): Promise { + let existing = ""; + if (await fs.pathExists(gitignorePath)) { + existing = await fs.readFile(gitignorePath, "utf-8"); + } + const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); + const missing = RALPH_GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry)); + if (missing.length === 0) { + return; + } + const block = ["", "# Ralph Loop", ...missing, ""].join("\n"); + await fs.writeFile(gitignorePath, `${existing.trimEnd()}${block}`, "utf-8"); + console.log("Updated .gitignore with Ralph Loop entries"); +} + +/** Add ralph-loop to devDependencies if absent. Returns true if it wrote. */ +async function ensureRalphLoopDevDep(targetDir: string): Promise { + const pkgPath = path.join(targetDir, "package.json"); + if (!(await fs.pathExists(pkgPath))) { + return false; + } + const pkg = await fs.readJson(pkgPath); + const already = + pkg.dependencies?.["ralph-loop"] || pkg.devDependencies?.["ralph-loop"]; + if (already) { + return false; + } + pkg.devDependencies = pkg.devDependencies ?? {}; + pkg.devDependencies["ralph-loop"] = RALPH_LOOP_DEP_VERSION; + await fs.writeJson(pkgPath, pkg, { spaces: 2 }); + console.log(`Added ralph-loop@${RALPH_LOOP_DEP_VERSION} to devDependencies`); + return true; +} + +// --------------------------------------------------------------------------- +// Entry flows +// --------------------------------------------------------------------------- + +export async function runGreenfield( + projectDir: string | undefined, + options: CliOptions +): Promise { + if (!projectDir && !options.yes) { + const { dir } = await inquirer.prompt([ + { + type: "input", + name: "dir", + message: "Project directory:", + default: "my-ralph-project", + }, + ]); + projectDir = dir; + } + + const resolvedProjectDir = projectDir ?? "my-ralph-project"; + const targetDir = path.resolve(resolvedProjectDir); + if (await fs.pathExists(targetDir)) { + const contents = await fs.readdir(targetDir); + if (contents.length > 0) { + console.error( + `Error: Directory "${resolvedProjectDir}" already exists and is not empty. Use --adopt to add Ralph Loop to an existing project.` + ); + process.exit(1); + } + } + + const vars = await buildVars(targetDir, options); + + console.log(""); + console.log(`Creating ${vars.projectName} in ${targetDir}...`); + console.log(""); + + await scaffold(targetDir, vars); + + if (options.git !== false) { + try { + execSync("git init", { cwd: targetDir, stdio: "pipe" }); + console.log("Initialized git repository."); + } catch { + console.log("Warning: git init failed. You can do this manually."); + } + } + + printGreenfieldNextSteps(resolvedProjectDir); +} + +export async function runAdopt( + projectDir: string | undefined, + options: CliOptions +): Promise { + const targetDir = path.resolve(projectDir ?? "."); + if (!(await fs.pathExists(targetDir))) { + console.error(`Error: Directory "${targetDir}" does not exist.`); + process.exit(1); + } + + const detection = await detectProject(targetDir); + const vars = await buildVars(targetDir, options, detection); + + console.log(""); + console.log(`Adopting Ralph Loop into ${targetDir}...`); + console.log(`Detected: ${detection.framework}, ${detection.packageManager}`); + console.log(""); + + await adopt(targetDir, vars, options); + + const addedDep = await ensureRalphLoopDevDep(targetDir); + const hasPackageJson = await fs.pathExists( + path.join(targetDir, "package.json") + ); + + if (hasPackageJson && options.install !== false) { + try { + console.log(`Installing dependencies (${detection.installCommand})...`); + execSync(detection.installCommand, { cwd: targetDir, stdio: "inherit" }); + } catch { + console.log( + `Warning: '${detection.installCommand}' failed. Run it manually to install ralph-loop.` + ); + } + } else if (addedDep) { + console.log("Skipped install (--no-install). Run install to fetch ralph-loop."); + } + + printAdoptNextSteps(); +} + +function printGreenfieldNextSteps(projectDir: string): void { + console.log(""); + console.log("Done! Your Ralph Loop project is ready."); + console.log(""); + console.log("Next steps:"); + console.log(""); + console.log(` cd ${projectDir}`); + console.log(""); + console.log(" 1. Install the runtime:"); + console.log(" npm i -D ralph-loop"); + console.log(""); + console.log(" 2. Fill in your specs:"); + console.log(" - specs/phase1/PRD.md (product requirements)"); + console.log(" - specs/phase1/app_spec.txt (technical spec)"); + console.log(" - specs/phase1/features.json (feature contract, seeded)"); + console.log(""); + console.log(" 3. Check the setup and run the loop:"); + console.log(" npx ralph doctor"); + console.log(" npx ralph run"); + console.log(""); +} + +function printAdoptNextSteps(): void { + console.log(""); + console.log("Done! Ralph Loop adoption is ready."); + console.log(""); + console.log("Next steps:"); + console.log(""); + console.log(" 1. Review ralph.config.json (roles, gates, devServer)."); + console.log(""); + console.log( + " 2. If migrating a v1 project (feature_list.json + ralph.sh):" + ); + console.log(" npx ralph migrate"); + console.log(""); + console.log(" 3. Check the setup and run the loop:"); + console.log(" npx ralph doctor"); + console.log(" npx ralph run"); + console.log(""); +} + +async function main(): Promise { + const program = new Command(); + + program + .name("create-ralph-loop") + .description( + "Scaffold or adopt a Ralph Loop (v2) autonomous-build harness for AI-driven iterative development" + ) + .argument("[project-directory]", "Directory to create or adopt") + .option("-y, --yes", "Use defaults for all prompts", false) + .option("--no-git", "Skip git init in greenfield mode") + .option("--no-install", "Skip installing dependencies in adopt mode") + .option("--adopt", "Adopt Ralph Loop into an existing project") + .option("--init", "Alias for --adopt") + .action(async (projectDir: string | undefined, options: CliOptions) => { + if (options.adopt || options.init) { + await runAdopt(projectDir, options); + } else { + await runGreenfield(projectDir, options); + } + }); + + await program.parseAsync(process.argv); +} + +if (require.main === module) { + main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); +} diff --git a/packages/create-ralph-loop/src/scaffold.test.ts b/packages/create-ralph-loop/src/scaffold.test.ts new file mode 100644 index 0000000..8e2746b --- /dev/null +++ b/packages/create-ralph-loop/src/scaffold.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import fs from "fs-extra"; +import os from "node:os"; +import path from "node:path"; +import { runGreenfield } from "./cli"; +import { RalphConfigSchema } from "../../ralph/src/config/schema"; +import { FeatureFileSchema } from "../../ralph/src/features/schema"; + +describe("create-ralph-loop greenfield scaffold", () => { + let tmpRoot: string; + let projectDir: string; + + beforeAll(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "crl-scaffold-")); + projectDir = path.join(tmpRoot, "my-app"); + await runGreenfield(projectDir, { yes: true, git: false, install: false }); + }); + + afterAll(async () => { + await fs.remove(tmpRoot); + }); + + it("renders a valid ralph.config.json", async () => { + const cfgPath = path.join(projectDir, "ralph.config.json"); + expect(await fs.pathExists(cfgPath)).toBe(true); + const raw = JSON.parse(await fs.readFile(cfgPath, "utf-8")); + const parsed = RalphConfigSchema.parse(raw); + expect(parsed.version).toBe(2); + expect(parsed.specDir).toBe("specs/phase1"); + expect(parsed.devServer.command).toBe("npm run dev"); + expect(parsed.devServer.port).toBeGreaterThanOrEqual(3000); + }); + + it("renders a valid v2 features.json", async () => { + const featuresPath = path.join(projectDir, "specs", "phase1", "features.json"); + expect(await fs.pathExists(featuresPath)).toBe(true); + const raw = JSON.parse(await fs.readFile(featuresPath, "utf-8")); + const parsed = FeatureFileSchema.parse(raw); + expect(parsed.version).toBe(2); + const ids = parsed.features.map((f) => f.id); + expect(ids).toContain("INFRA-001"); + expect(ids).toContain("INFRA-002"); + expect(ids).toContain("UI-001"); + }); + + it("does not emit legacy bash scaffolding", async () => { + expect(await fs.pathExists(path.join(projectDir, "ralph.sh"))).toBe(false); + expect(await fs.pathExists(path.join(projectDir, "init.sh"))).toBe(false); + expect(await fs.pathExists(path.join(projectDir, "scripts"))).toBe(false); + }); + + it("writes a .gitignore that ignores .ralph/", async () => { + const gitignore = await fs.readFile( + path.join(projectDir, ".gitignore"), + "utf-8" + ); + expect(gitignore).toContain(".ralph/"); + }); + + it("renders docs skeleton and no leftover template markers", async () => { + expect( + await fs.pathExists(path.join(projectDir, "docs", "tech-debt.md")) + ).toBe(true); + const claude = await fs.readFile( + path.join(projectDir, "CLAUDE.md"), + "utf-8" + ); + expect(claude).not.toContain("<%"); + expect(claude).not.toContain("{{"); + }); +}); diff --git a/packages/create-ralph-loop/template/AGENTS.md.eta b/packages/create-ralph-loop/template/AGENTS.md.eta new file mode 100644 index 0000000..f171fb8 --- /dev/null +++ b/packages/create-ralph-loop/template/AGENTS.md.eta @@ -0,0 +1,75 @@ +# <%~ it.projectName %> — Agent Operating Guide + +<%~ it.projectDescription %> + +This project is built by the **Ralph Loop** (`ralph-loop`) — an autonomous, +guardrailed, multi-model build harness. You (the agent) implement **one feature +per iteration** against a machine-readable contract. This file is the table of +contents; the details live in `docs/`. + +--- + +## Golden principles + +1. **The harness owns state, not you.** Never hand-edit `specs/phase1/features.json`, + `.ralph/`, or git history to fake progress. Status transitions are enforced by + the harness and an independent verifier. +2. **One feature at a time.** Pick the highest-priority `pending` feature whose + `depends_on` are all satisfied. Implement it fully, then stop. +3. **Make it verifiable.** Every feature has `steps[]` an independent verifier will + check. Build so those steps observably pass (real routes, real data, real UI). +4. **Respect the spec.** `specs/phase1/app_spec.txt` and `PRD.md` are the source of + truth for stack, data models, routes, and UI. When in doubt, follow them. +5. **Keep the tree green.** Typecheck, tests, and the diff-size gate run every + iteration. Don't introduce new failures; don't sprawl the diff. +6. **Leave a trail.** Record design decisions in `docs/design/`, plans in + `docs/plans/`, and shortcuts you had to take in `docs/tech-debt.md`. + +--- + +## Where things live + +| Path | Purpose | +|---|---| +| `ralph.config.json` | Harness config — roles/models, gates, budgets, dev server. Owned by you to tune; validated against the `ralph-loop` schema. | +| `specs/phase1/PRD.md` | Product requirements — what and why. | +| `specs/phase1/app_spec.txt` | Technical spec — stack, data models, routes, business logic, UI. | +| `specs/phase1/features.json` | The v2 feature contract (DAG). **Harness-owned.** | +| `docs/design/` | Architecture and design notes (ADR-style). | +| `docs/plans/` | Multi-step implementation plans. | +| `docs/tech-debt.md` | Known shortcuts and follow-ups. | +| `.mcp.json` | Playwright MCP server for browser-based UI verification. | +| `.ralph/` | Harness telemetry, run state, event log. **Do not edit.** | + +--- + +## How the loop works (high level) + +Each iteration the harness: + +1. **Selects** the next unblocked feature from `features.json` (priority + DAG order). +2. **Plans / builds** using the configured coder role (see `ralph.config.json`). +3. **Runs gates** — typecheck, tests, and a diff-size guard. New failures block the claim. +4. **Verifies** — an independent verifier confirms the feature's `steps[]` against a + live dev server (started for you; see `docs/dev-server.md` if present). +5. **Records** progress into `.ralph/` and commits. Repeats until done, budget, or stall. + +You do not start/stop the dev server or the loop yourself — the runtime does. Run +`ralph run` to drive it, `ralph status` to inspect, `ralph doctor` to diagnose setup. + +--- + +## Do / Don't + +- **Do** read `specs/phase1/app_spec.txt` before writing code for a feature. +- **Do** keep changes scoped to the current feature. +- **Do** write or update tests so gates meaningfully protect the feature. +- **Don't** edit `features.json`, `.ralph/`, lockfiles, or CI config to game status. +- **Don't** mark work "done" that a verifier couldn't independently confirm. +- **Don't** install dependencies or change framework config unless the spec requires it. + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/template/CLAUDE.md.hbs b/packages/create-ralph-loop/template/CLAUDE.md.eta similarity index 61% rename from template/CLAUDE.md.hbs rename to packages/create-ralph-loop/template/CLAUDE.md.eta index d4d4a34..fd9d70e 100644 --- a/template/CLAUDE.md.hbs +++ b/packages/create-ralph-loop/template/CLAUDE.md.eta @@ -1,7 +1,7 @@ @AGENTS.md -# {{projectName}} +# <%~ it.projectName %> -{{projectDescription}} +<%~ it.projectDescription %> diff --git a/packages/create-ralph-loop/template/README.md.eta b/packages/create-ralph-loop/template/README.md.eta new file mode 100644 index 0000000..73c8938 --- /dev/null +++ b/packages/create-ralph-loop/template/README.md.eta @@ -0,0 +1,79 @@ +# <%~ it.projectName %> + +<%~ it.projectDescription %> + +Built with the [Ralph Loop](https://github.com/weststack/create-ralph-loop) — an +autonomous, guardrailed, multi-model harness that builds your app one verified +feature at a time using the `ralph-loop` runtime. + +## Quick Start + +```bash +# 1. Install the runtime (and your app deps) +npm install +npm i -D ralph-loop # if not already in devDependencies + +# 2. Write your specs (what to build) +# specs/phase1/PRD.md — product requirements +# specs/phase1/app_spec.txt — technical spec (stack, models, routes, UI) +# specs/phase1/features.json — the feature contract (seeded for you) + +# 3. Sanity-check the setup +npx ralph doctor + +# 4. Run the loop +npx ralph run +``` + +`ralph run` iterates: it picks the next unblocked feature, builds it, runs the +gates (typecheck / tests / diff-size), spins up your dev server, and has an +independent verifier confirm each feature's steps before marking it done. + +## The workflow + +1. **Scaffold** — you've done this with `create-ralph-loop`. +2. **Install** — `npm install` pulls in `ralph-loop` (the `ralph` CLI). +3. **Specify** — fill in `specs/phase1/PRD.md` and `app_spec.txt`; refine the + seeded `features.json` (or generate specs with your own planning pass). +4. **Run** — `npx ralph run` drives the autonomous loop. +5. **Observe** — `npx ralph status` shows iterations, cost, and feature progress. + +## Useful commands + +| Command | What it does | +|---|---| +| `npx ralph run` | Run the autonomous build loop until complete, budget, or stall. | +| `npx ralph run -n 20 -b 5` | Cap at 20 iterations / $5. | +| `npx ralph doctor` | Diagnose setup: adapters on PATH, git repo, config, feature DAG. | +| `npx ralph status` | Summarize the latest run from `.ralph/` telemetry. | +| `npx ralph dev up` / `down` / `status` | Manually control the dev server. | +| `npx ralph migrate` | Migrate a v1 project (`feature_list.json` + `ralph.sh`) to v2. | + +## Configuration + +Everything lives in **`ralph.config.json`** (validated against the `ralph-loop` +schema): + +- **`roles`** — which model plays coder / verifier / planner. Ships with Codex for + building, Claude Haiku for cheap fail-closed verification, and Fable for planning. +- **`gates`** — `typecheck`, `test`, `build`, and a `diff` size guard. A failing + gate blocks a feature claim (tests are baseline-relative: only *new* failures block). +- **`budgets`** — optional `maxCostUsd`, `maxIterations`, `maxWallClockMinutes`. +- **`devServer`** — command, port, readiness path, and install command. The runtime + starts/stops this for you; the verifier hits it to confirm UI features. +- **`retries`**, **`replan`**, **`stall`**, **`verify`** — loop-control guardrails. + +## Guardrails + +- The harness — not the agent — owns `specs/phase1/features.json` and all status + transitions, so progress can't be faked. +- An independent verifier confirms each feature's `steps[]` against a live dev + server before it counts as `verified`. +- Gates keep the tree green and the diff bounded every iteration. + +## Requirements + +- **Node.js** >= 18 +- **git** (run inside a git repo) +- **`ralph-loop`** (installed via `npm install`) +- A coder CLI on PATH for the configured adapter (e.g. **Codex** and/or **Claude**) diff --git a/template/_env.example.hbs b/packages/create-ralph-loop/template/_env.example.eta similarity index 68% rename from template/_env.example.hbs rename to packages/create-ralph-loop/template/_env.example.eta index 5a6e0d7..70c2b34 100644 --- a/template/_env.example.hbs +++ b/packages/create-ralph-loop/template/_env.example.eta @@ -1,10 +1,10 @@ -# {{projectName}} — Environment Variables +# <%~ it.projectName %> — Environment Variables # Copy this file to .env.local and fill in your values. DATABASE_URL=file:./dev.db ANTHROPIC_API_KEY=sk-ant-your-key-here LOG_LEVEL=info -DEV_PORT={{devPort}} +# The dev-server port is set in ralph.config.json (devServer.port). # Add additional environment variables below as needed. # Reference specs/phase1/app_spec.txt Section 7 for the full list. diff --git a/template/_gitignore b/packages/create-ralph-loop/template/_gitignore similarity index 56% rename from template/_gitignore rename to packages/create-ralph-loop/template/_gitignore index de242ce..5a3bd60 100644 --- a/template/_gitignore +++ b/packages/create-ralph-loop/template/_gitignore @@ -1,10 +1,16 @@ node_modules/ .next/ +dist/ +*.tsbuildinfo + +# Local env +.env +.env.local + +# Prisma prisma/dev.db prisma/dev.db-journal -.env.local -.env -*.tsbuildinfo -.dev-server.pid -.dev-server.log -dist/ + +# Ralph Loop +.ralph/ +specs/phase1/screenshots/ diff --git a/template/_mcp.json b/packages/create-ralph-loop/template/_mcp.json similarity index 100% rename from template/_mcp.json rename to packages/create-ralph-loop/template/_mcp.json diff --git a/template/progress.txt b/packages/create-ralph-loop/template/docs/design/.gitkeep similarity index 100% rename from template/progress.txt rename to packages/create-ralph-loop/template/docs/design/.gitkeep diff --git a/packages/create-ralph-loop/template/docs/plans/.gitkeep b/packages/create-ralph-loop/template/docs/plans/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/create-ralph-loop/template/docs/tech-debt.md b/packages/create-ralph-loop/template/docs/tech-debt.md new file mode 100644 index 0000000..b58d5b9 --- /dev/null +++ b/packages/create-ralph-loop/template/docs/tech-debt.md @@ -0,0 +1,10 @@ +# Tech Debt + +Track shortcuts, known gaps, and follow-ups here. When the loop takes a pragmatic +shortcut to land a feature, add a dated entry so it can be revisited. + + diff --git a/packages/create-ralph-loop/template/ralph.config.json.eta b/packages/create-ralph-loop/template/ralph.config.json.eta new file mode 100644 index 0000000..6e8e474 --- /dev/null +++ b/packages/create-ralph-loop/template/ralph.config.json.eta @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/ralph-loop/schema/ralph.config.schema.json", + "version": 2, + "specDir": "specs/phase1", + "roles": { + "coder": { "adapter": "codex", "permissionTier": "full" }, + "verifier": { "adapter": "claude", "model": "claude-haiku-4-5-20251001", "permissionTier": "readonly" }, + "planner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "edit" }, + "replanner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "readonly" } + }, + "gates": { + "typecheck": { "command": "npx tsc --noEmit", "baselineRelative": false, "timeoutMs": 600000 }, + "test": <%~ it.testGate %>, + "diff": { "maxFiles": 40, "maxLines": 3000 } + }, + "retries": { "maxAttempts": 2 }, + "budgets": {}, + "replan": { "everyIterations": 10 }, + "stall": { "noProgressIterations": 4 }, + "verify": { "enabled": true, "unlockOn": "verified" }, + "devServer": { + "command": <%~ it.devCommandJson %>, + "port": <%~ it.devPort %>, + "readinessPath": "/", + "readyTimeoutMs": 120000, + "installCommand": <%~ it.installCommandJson %>, + "env": {} + }, + "notifications": [{ "type": "desktop" }] +} diff --git a/template/specs/phase1/PRD.md.hbs b/packages/create-ralph-loop/template/specs/phase1/PRD.md.eta similarity index 91% rename from template/specs/phase1/PRD.md.hbs rename to packages/create-ralph-loop/template/specs/phase1/PRD.md.eta index 1900a0b..6d6975d 100644 --- a/template/specs/phase1/PRD.md.hbs +++ b/packages/create-ralph-loop/template/specs/phase1/PRD.md.eta @@ -1,14 +1,14 @@ -# Product Requirements Document: {{projectName}} +# Product Requirements Document: <%~ it.projectName %> **Version:** 1.0 (MVP) **Status:** Draft -**Date:** {{createdAt}} +**Date:** <%~ it.createdAt %> --- ## 1. Executive Summary -{{projectDescription}} +<%~ it.projectDescription %> TODO: Expand on what this product does, who it's for, and what the MVP scope includes. diff --git a/template/specs/phase1/app_spec.txt.hbs b/packages/create-ralph-loop/template/specs/phase1/app_spec.txt.eta similarity index 91% rename from template/specs/phase1/app_spec.txt.hbs rename to packages/create-ralph-loop/template/specs/phase1/app_spec.txt.eta index aefa49b..3450d70 100644 --- a/template/specs/phase1/app_spec.txt.hbs +++ b/packages/create-ralph-loop/template/specs/phase1/app_spec.txt.eta @@ -1,13 +1,12 @@ ================================================================================ - {{projectName}} — Application Specification + <%~ it.projectName %> — Application Specification ================================================================================ This document is the technical reference for coding agents. It specifies the -tech stack, data models, API routes, business logic, and UI layout. The coding -prompt (specs/phase1/prompts/coding_prompt.md) directs agents to reference this -file when implementing features. +tech stack, data models, API routes, business logic, and UI layout. The loop +directs agents to reference this file when implementing each feature. -Write this spec BEFORE running the initializer or the ralph loop. +Write this spec BEFORE running `ralph run`. ================================================================================ 1. TECH STACK @@ -16,11 +15,11 @@ Write this spec BEFORE running the initializer or the ralph loop. Framework: Next.js (App Router, TypeScript, Tailwind CSS) UI: shadcn/ui Database: Prisma + SQLite (or your choice) -Testing: Jest + Playwright MCP +Testing: Vitest/Jest + Playwright MCP AI: Claude API (@anthropic-ai/sdk) TODO: Add or modify the tech stack for your project. List every dependency -the initializer should install. +the first infrastructure feature should install. ================================================================================ 2. PROJECT STRUCTURE diff --git a/packages/create-ralph-loop/template/specs/phase1/features.json.eta b/packages/create-ralph-loop/template/specs/phase1/features.json.eta new file mode 100644 index 0000000..e400eb9 --- /dev/null +++ b/packages/create-ralph-loop/template/specs/phase1/features.json.eta @@ -0,0 +1,88 @@ +{ + "version": 2, + "features": [ + { + "id": "INFRA-001", + "category": "infrastructure", + "priority": 1, + "description": "Project scaffolded with required dependencies installed and the dev server running", + "steps": [ + "package.json exists with the required dependencies", + "`npx tsc --noEmit` reports zero errors", + "The dev server responds at http://localhost:<%~ it.devPort %>/" + ], + "depends_on": [], + "status": "pending", + "attempts": 0, + "blocked_reason": null, + "verification": null, + "lease": null + }, + { + "id": "INFRA-002", + "category": "infrastructure", + "priority": 2, + "description": "Database schema defined with Prisma and migrations applied successfully", + "steps": [ + "prisma/schema.prisma exists with the required models", + "`npx prisma migrate dev` (or `db push`) applies without errors", + "A database client singleton is exported from src/lib/db.ts" + ], + "depends_on": ["INFRA-001"], + "status": "pending", + "attempts": 0, + "blocked_reason": null, + "verification": null, + "lease": null + }, + { + "id": "UI-001", + "category": "ui", + "priority": 3, + "description": "Application shell with navigation layout and placeholder pages", + "steps": [ + "The main layout renders at http://localhost:<%~ it.devPort %>/", + "Navigation links are present and route correctly", + "All placeholder pages load without errors" + ], + "depends_on": ["INFRA-001"], + "status": "pending", + "attempts": 0, + "blocked_reason": null, + "verification": null, + "lease": null + }, + { + "id": "FEAT-001", + "category": "feature", + "priority": 4, + "description": "TODO: Replace with your first product feature", + "steps": [ + "TODO: Define an observable verification step", + "TODO: Define a second observable verification step" + ], + "depends_on": ["UI-001"], + "status": "pending", + "attempts": 0, + "blocked_reason": null, + "verification": null, + "lease": null + }, + { + "id": "FEAT-002", + "category": "feature", + "priority": 5, + "description": "TODO: Replace with your second product feature", + "steps": [ + "TODO: Define an observable verification step", + "TODO: Define a second observable verification step" + ], + "depends_on": [], + "status": "pending", + "attempts": 0, + "blocked_reason": null, + "verification": null, + "lease": null + } + ] +} diff --git a/tsconfig.json b/packages/create-ralph-loop/tsconfig.json similarity index 83% rename from tsconfig.json rename to packages/create-ralph-loop/tsconfig.json index 111f85b..3f2f0c3 100644 --- a/tsconfig.json +++ b/packages/create-ralph-loop/tsconfig.json @@ -13,5 +13,5 @@ "declaration": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "template"] + "exclude": ["node_modules", "dist", "template", "src/**/*.test.ts"] } diff --git a/packages/ralph/LICENSE b/packages/ralph/LICENSE new file mode 100644 index 0000000..63e87b8 --- /dev/null +++ b/packages/ralph/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 weststack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ralph/README.md b/packages/ralph/README.md new file mode 100644 index 0000000..3cd3b05 --- /dev/null +++ b/packages/ralph/README.md @@ -0,0 +1,95 @@ +# ralph-loop + +The runtime for the **Ralph Loop** autonomous software-building harness — a cross-platform TypeScript CLI (`ralph`) that drives AI coding agents toward an outcome with real, mechanical guardrails. You supervise by exception instead of eyeballing every iteration. + +> Scaffold a project with [`create-ralph-loop`](https://www.npmjs.com/package/create-ralph-loop), or add this runtime to an existing one. + +## Install + +```bash +npm i -D ralph-loop +npx ralph doctor # verify adapters / git / config / feature DAG +npx ralph run # start the autonomous loop +``` + +Requires Node ≥ 18, git, and at least one agent CLI on PATH (`claude`, `codex`, or `aider`). **No bash required** — native on Windows, macOS, and Linux. + +## How the loop works + +Each iteration is a state machine the harness drives — success is never self-graded: + +``` +select next DAG-eligible feature ← the harness picks it, not the agent + │ git checkpoint (known-good commit) + │ coder agent → implements ONE feature, emits + │ mechanical gates (run by the harness): + │ · featureIntegrity — features.json is harness-owned; edits rejected + │ · diff size · baseline-relative typecheck/test/build (only NEW failures block) + │ independent verifier (fresh context, cheaper model, fail-closed) + ├── all pass → commit code + status atomically → feature = verified + └── any fail → git reset --hard to checkpoint → retry (bounded) → block + │ budgets · stall detection · periodic replan · periodic gardening +``` + +Guardrails, all mechanical: + +- **Checkpoint + auto-revert** — failed gates/verdict hard-revert the iteration; no half-finished work survives. +- **Independent fail-closed verification** — a separate context re-executes each feature's steps; unparseable/ambiguous → treated as failure. +- **Baseline-relative gates** — pre-existing test/type failures are tolerated; only failures your change *introduces* block it. +- **Bounded retries → block** — after `retries.maxAttempts`, a feature is blocked (with reason) and the loop moves on. +- **Budgets + notifications** — cost / iteration / wall-clock caps halt the run; webhook + desktop sinks. +- **Periodic self-improvement** — a strong model may reprioritize/block/unblock/split/prune/add-dependency (`replan.everyIterations`); a gardener pass cleans entropy/"AI slop" (`garden.everyIterations`). +- **Structured telemetry** — every event in `.ralph/progress.jsonl`; per-role token/cost in `.ralph/run-state.json`. + +## Configuration + +`ralph.config.json` (validated against the shipped JSON schema at `ralph-loop/schema/ralph.config.schema.json`). Roles map to `(adapter, model, permission tier)`: + +```jsonc +{ + "specDir": "specs/phase1", + "roles": { + "coder": { "adapter": "codex", "permissionTier": "full" }, + "verifier": { "adapter": "claude", "model": "claude-haiku-4-5-20251001", "permissionTier": "readonly" }, + "planner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "edit" }, + "replanner": { "adapter": "claude", "model": "claude-fable-5", "permissionTier": "readonly" } + }, + "gates": { "typecheck": { "command": "npx tsc --noEmit" }, "test": { "command": "npm test", "baselineRelative": true }, "diff": { "maxFiles": 40, "maxLines": 3000 } }, + "retries": { "maxAttempts": 2 }, + "budgets": { "maxCostUsd": 25, "maxIterations": 50 }, + "verify": { "enabled": true, "unlockOn": "verified" }, + "devServer": { "command": "npm run dev", "port": 3000, "readinessPath": "/" } +} +``` + +### Local LLMs + +Route the coder to a local model via the `aider` adapter while keeping a cheap hosted verifier for fail-closed safety: + +```jsonc +"coder": { "adapter": "aider", "model": "ollama/qwen2.5-coder", "permissionTier": "full" } +``` + +## Commands + +``` +ralph run [--iterations N] [--budget USD] [--no-verify] [--fresh] +ralph plan [--idea "..."] [--prd-only] generate PRD / app_spec / features.json +ralph dev up | down | status dev-server lifecycle +ralph doctor diagnose adapters / git / config / DAG +ralph status summarize the latest run +ralph migrate upgrade a v1 project (feature_list.json + ralph.sh) +ralph export --format eval-jsonl verification records for offline eval +``` + +## Programmatic use + +The package also exports the config and feature schemas for tooling: + +```ts +import { RalphConfigSchema, FeatureFileSchema } from "ralph-loop"; +``` + +## License + +MIT · Part of [weststack-io/create-ralph-loop](https://github.com/weststack-io/create-ralph-loop). diff --git a/packages/ralph/assets/prompts/coding.md b/packages/ralph/assets/prompts/coding.md new file mode 100644 index 0000000..3317ecd --- /dev/null +++ b/packages/ralph/assets/prompts/coding.md @@ -0,0 +1,143 @@ +# <%= it.projectName %> — Coding Session (iteration <%= it.iteration %>, attempt <%= it.attempt %>) + +You are a coding agent working on **<%= it.projectName %>**<% if (it.projectDescription) { %>, <%~ it.projectDescription %><% } %>. + +The harness has already selected exactly ONE feature for you to implement this +session. You do NOT choose features and you do NOT manage the plan. Implement the +injected feature below, verify it, commit it, and leave the tree merge-ready. + +--- + +## The feature you are implementing + +**<%= it.feature.id %>: <%~ it.feature.description %>** + +Verification steps (each must actually pass): + +<% ;(it.feature.steps || []).forEach(function (s) { %><%~ "- " + s + "\n" %><% }) %> + +<% if (it.previousFailure) { %> +## Previous attempt failed — fix ONLY the reported issues + +A prior attempt on this exact feature did not pass. Do not start over and do not +refactor unrelated code. Address precisely what is reported below, then re-verify. + +<% if (it.previousFailure.gates && it.previousFailure.gates.length) { %><%~ "Failed gates:\n" %><% it.previousFailure.gates.forEach(function (g) { %><%~ "- " + g + "\n" %><% }) %><%~ "\n" %><% } %><% if (it.previousFailure.verifierConcerns && it.previousFailure.verifierConcerns.length) { %><%~ "Verifier concerns:\n" %><% it.previousFailure.verifierConcerns.forEach(function (c) { %><%~ "- " + c + "\n" %><% }) %><%~ "\n" %><% } %>Detail: + +<%~ it.previousFailure.detail %> + +Fix ONLY the reported issues; do not refactor unrelated code. +<% } %> + +<% if (it.recentProgress) { %> +## Recent progress (context only) + +<%~ it.recentProgress %> +<% } %> + +--- + +Follow these steps in order. Do not skip any step. Do not do them out of order. + +## Step 1: Orientation + +Get your bearings without changing anything: + +```bash +pwd +git log --oneline -20 +``` + +Read `<%= it.specDir %>/app_spec.txt` for the models, routes, and rules you need. +You may read `<%= it.specDir %>/features.json` for context, but see the hard rule +below — you must never write to it. + +## Step 2: Server check + +The dev server is started by the harness. Verify it responds on port +<%= it.devPort %>: + +```bash +curl -sS --connect-timeout 5 -o /dev/null -w "%{http_code}" "http://localhost:<%= it.devPort %>/" +``` + +A 200 means proceed. If it is not responding, run `./scripts/dev-up.sh`. Do NOT +run `npm run dev` directly (it blocks), and do NOT run `npm install` unless you +hit a genuine missing-dependency error. + +## Step 3: Regression check + +Before writing new code, spot-check 1–2 already-verified features in the area you +are about to touch. If you find a regression, fix and commit it first, and note it +in your result summary. Do not build on a broken tree. + +## Step 4: Implementation + +Write the minimum code needed to satisfy **<%= it.feature.id %>** and its steps. + +- Keep changes focused on this one feature; do not add scope beyond its steps. +- Reuse existing utilities and follow existing patterns/conventions. +- Do not refactor unrelated code. + +## Step 5: Testing + +Test thoroughly — premature victory is the most common failure mode. + +- **Unit / integration**: add or update Jest tests for any pure logic or API + routes this feature introduces. +- **Manual verification**: execute the feature's steps exactly. Chrome and the + Playwright MCP tools are available and functional — use them to drive the UI on + port <%= it.devPort %> and save screenshots to `<%= it.specDir %>/screenshots/` + where a step is visual. + +```bash +npm test +``` + +Do not claim the feature is implemented unless every verification step passes. + +## Step 6: Git commit + +Commit your work with a descriptive message, staging only the files you changed: + +```bash +git add +git commit -m "feat: + +- Implements <%= it.feature.id %>: <%~ it.feature.description %> +- " +``` + +Use `git checkout -- ` or `git revert` to undo mistakes rather than leaving +a mess. + +## Step 7: Clean state (tsc) + +```bash +npx tsc --noEmit +git status +``` + +TypeScript must compile cleanly and the working tree must be clean (everything +committed). If `tsc` fails, fix it and commit the fix. Do NOT stop the dev server +— the harness owns its lifecycle. + +--- + +## Hard rules + +- You MUST NOT edit `<%= it.specDir %>/features.json` — the harness owns it and + records all status transitions. Editing it will be rejected. +- You MUST NOT self-select or add features. Implement only <%= it.feature.id %>. +- Leave the codebase merge-ready: no stray console.logs, no commented-out + experiments, no half-finished work. + +## Final output — REQUIRED + +End your response with exactly one machine-readable result block on its own line. +Use `implemented` only if every step passed, `partial` if you made progress but +could not finish, `blocked` if you could not proceed. List concrete blockers. + +``` +{"feature":"<%= it.feature.id %>","outcome":"implemented|partial|blocked","summary":"one-line summary of what you did","blockers":[]} +``` diff --git a/packages/ralph/assets/prompts/gardener.md b/packages/ralph/assets/prompts/gardener.md new file mode 100644 index 0000000..60a789c --- /dev/null +++ b/packages/ralph/assets/prompts/gardener.md @@ -0,0 +1,44 @@ +# <%= it.projectName %> — Gardening Pass + +You are the gardener. Between feature work you keep the codebase healthy: you scan +for drift, duplication, dead code, and inconsistencies with the project's golden +principles, and you make small, safe, committed refactors. You do NOT change +feature behavior and you do NOT implement new features. + +<% if (it.recentProgress) { %> +## Recent activity (context) + +<%~ it.recentProgress %> +<% } %> + +## What to garden + +- **Duplication** — collapse copy-pasted logic into a shared utility, preserving + behavior. +- **Dead code** — remove unreferenced functions, files, and exports (confirm they + are truly unused first). +- **Drift** — reconcile code that has diverged from the app spec or the golden + principles; prefer aligning code to the documented contract. +- **Inconsistencies** — naming, error handling, and directory conventions that + have fragmented across sessions. +- **AGENTS.md** — keep it a concise table-of-contents for the codebase: accurate, + short, pointing to where things live. Trim anything stale. + +## Rules + +- Behavior-preserving ONLY. If a change could alter feature behavior, do not make + it — note it instead. +- Keep each refactor small and independently reviewable. Commit related changes + together with a clear message. +- Do NOT edit `<%= it.specDir %>/features.json` — the harness owns it. +- You are subject to the same gates as coding sessions: `npx tsc --noEmit` must + pass and the full test suite (`npm test`) must stay green. Run both before you + finish, and leave the working tree clean. + +## Workflow + +1. Orient: `git log --oneline -20`, then skim the tree for the issues above. +2. Make one focused improvement at a time; run `npm test` and `npx tsc --noEmit` + after each; commit. +3. Stop while the tree is clean. Prefer a few high-confidence cleanups over a + sweeping change. diff --git a/packages/ralph/assets/prompts/init.md b/packages/ralph/assets/prompts/init.md new file mode 100644 index 0000000..6d7175d --- /dev/null +++ b/packages/ralph/assets/prompts/init.md @@ -0,0 +1,112 @@ +# <%= it.projectName %> — Initialization + +You are the initializer agent for **<%= it.projectName %>**, <%~ it.projectDescription %>. +Your job is to turn the PRD into the technical spec **and** a dependency-minimized +`features.json`, then scaffold the project to a clean starting point. You do NOT +implement features — you build the foundation that coding agents build on. + +Your primary source of truth is the PRD at `<%= it.specDir %>/PRD.md`. + +--- + +## Deliverable 1: Application specification + +Read `<%= it.specDir %>/PRD.md` thoroughly, then read the template at +`<%= it.specDir %>/app_spec.txt` and replace every TODO with real, specific +content derived from the PRD: + +1. **Tech stack** — default to Next.js (App Router, TypeScript, strict), + shadcn/ui, Prisma + SQLite, Jest + Playwright. Keep these unless the PRD + demands otherwise; list every extra dependency. +2. **Project structure** — the directory layout implied by the features. +3. **Data models** — complete Prisma models for every entity (fields, types, + relations, constraints). +4. **API routes** — every endpoint: method, path, request/response shape, status + codes. +5. **Business logic** — algorithms, validation, scoring, pipelines. +6. **UI layout** — navigation, page layouts, key components. +7. **Environment variables** — every required var with an example value. + +Write the completed spec back to `<%= it.specDir %>/app_spec.txt`. + +## Deliverable 2: features.json (v2 — dependency-minimized) + +Generate `<%= it.specDir %>/features.json` as a v2 feature file: + +```json +{ "version": 2, "features": [ /* ... */ ] } +``` + +Each feature object has exactly these fields: + +- `id` — category-prefixed: `INFRA-*` (shared foundation), `UI-*`, `API-*`, + `FEAT-*` (full-stack vertical slice). +- `category` — one of `infra`, `ui`, `api`, `feature`. +- `priority` — integer; lower runs earlier. +- `description` — one testable unit of work an agent can finish in one session. +- `steps` — 2–5 concrete, checkable verification steps (curl an endpoint, click a + button, assert a DB row). +- `depends_on` — array of feature ids (see the minimization rule below). +- `status` — `"pending"`. +- `attempts` — `0`. +- `blocked_reason` — `null`. +- `verification` — `null`. +- `lease` — `null`. + +### Dependency-minimization rule (important for future parallel execution) + +- Concentrate shared setup into a few explicit `INFRA-*` foundation nodes (e.g. + `INFRA-001` scaffold, `INFRA-002` database). Feature slices depend on these. +- Design the rest as **independent vertical slices** so they can be built in + parallel. +- Declare `depends_on` ONLY when a real ordering constraint exists. If two + features could be built in either order, neither depends on the other. +- Put API routes before the UI that consumes them only when the UI genuinely + cannot be verified without the route. + +Do not start feature work. `features.json` becomes the harness-owned contract. + +## Scaffold steps + +1. Initialize Next.js if `package.json` does not already exist: + ```bash + npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm + ``` + Confirm `tsconfig.json` has `"strict": true`. Install extra deps from the app + spec. Initialize shadcn/ui (`npx shadcn@latest init`) and add the specified + components. +2. If the spec defines data models: `npx prisma init --datasource-provider sqlite`, + write `prisma/schema.prisma` per the spec, then `npx prisma generate` and + `npx prisma db push`. Create the Prisma singleton at `src/lib/db.ts`. +3. Create `.env.example` with every required var; copy to `.env.local` if absent. +4. Create `src/types/index.ts` mirroring the data models and enums. +5. Build the application shell: root layout, navigation, and placeholder pages for + every route. Fully implement `src/app/api/health/route.ts` returning + `{ status: "ok", timestamp }`; stub the other API routes so they respond. +6. Configure Jest (ts-jest, path aliases matching tsconfig) and add `"test": "jest"`. + +## Verify and commit + +```bash +npx tsc --noEmit +npm test +./scripts/dev-up.sh +curl "http://localhost:${DEV_PORT:-3000}" +curl "http://localhost:${DEV_PORT:-3000}/api/health" +./scripts/dev-down.sh +``` + +Then make one clean commit: + +```bash +git add -A +git commit -m "chore: scaffold <%= it.projectName %> project" +``` + +## Rules + +- Do NOT implement business logic — coding agents handle that. +- DO produce `app_spec.txt` and `features.json`; after that, do not modify them. +- Leave `npx tsc --noEmit` passing with zero errors and the dev server startable. +- If a decision is not covered by the spec, make a reasonable choice, document it + in a comment, and move on — do not block on open questions. diff --git a/packages/ralph/assets/prompts/prd.md b/packages/ralph/assets/prompts/prd.md new file mode 100644 index 0000000..9ad6d0b --- /dev/null +++ b/packages/ralph/assets/prompts/prd.md @@ -0,0 +1,46 @@ +# <%= it.projectName %> — PRD Generation + +You are a product analyst. Turn the raw idea below into a complete, MVP-focused +Product Requirements Document for **<%= it.projectName %>**. Your only output is +`<%= it.specDir %>/PRD.md`. + +## The idea + +<%~ it.projectDescription %> + +## Your task + +Read the PRD template at `<%= it.specDir %>/PRD.md` (it has section headers with +TODO placeholders). Replace every TODO with real, specific content based on the +idea above, and write the completed PRD back to the same file. + +### Sections + +1. **Executive summary** — 2–3 sentences: what it is, who uses it, what the MVP + includes. +2. **Problem statement** — the pain point, who feels it, and the cost of not + solving it. +3. **Goals and non-goals** — 3–5 concrete MVP goals. Non-goals must explicitly + fence off v2+ scope so coding agents do not over-build. +4. **User stories** — 5–10 in "As a [role], I want to [action] so that [benefit]" + form, covering the core workflows. +5. **Functional requirements** — be specific and estimable: + - Core features (enough detail to size the work). + - Data requirements (key entities the system stores/processes/displays). + - API requirements (endpoints, what they accept and return). + - UI requirements (pages, and what a user can do on each). +6. **Non-functional requirements** — practical for an MVP: basic performance, + security basics (auth only if needed), and scalability scope (e.g. + "single-user demo" vs "multi-tenant"). +7. **Tech stack guidance** — reference `<%= it.specDir %>/app_spec.txt`; note any + library/API/service the idea needs beyond the default stack (Next.js, Prisma, + shadcn/ui). +8. **Open questions** — 2–5 genuine decisions that could go either way, so coding + agents know where they have latitude. + +## Rules + +- Write for a coding-agent audience — precise and unambiguous. +- Scope strictly to MVP; push nice-to-haves into Non-goals. +- Do NOT modify any file other than `<%= it.specDir %>/PRD.md`. +- Do NOT start implementing code — this prompt only produces the PRD. diff --git a/packages/ralph/assets/prompts/replanner.md b/packages/ralph/assets/prompts/replanner.md new file mode 100644 index 0000000..569b72c --- /dev/null +++ b/packages/ralph/assets/prompts/replanner.md @@ -0,0 +1,55 @@ +# Replanner — plan health review + +You are the replanner. You periodically inspect the loop's plan and recent +activity, detect problems (stalls, loops, drift from the spec), and emit a set of +plan operations for the harness to apply. You do NOT write code and you do NOT +edit `features.json` directly — you propose operations; the harness applies them. + +## Current plan (`<%= it.specDir %>/features.json`) + +```json +<%~ it.featuresJson %> +``` + +## Recent events + +<%~ it.recentEvents %> + +## Recent git log + +``` +<%~ it.gitLog %> +``` + +## What to look for + +- **Stalls**: a feature with rising `attempts` and no progress → `block` it with a + reason, or `split` it into smaller independent slices. +- **Loops**: repeated churn on the same area with no net advance → `reprioritize` + to break the cycle, or add a missing `add_dependency` edge that was causing + rework. +- **Drift**: work diverging from the spec, or newly discovered prerequisites → + `add` new features (as `newFeatures`) or `add_dependency`. +- **Dead weight**: a feature that is redundant or out of scope and NOT yet + verified → `prune`. +- **Unblocking**: a `block`ed feature whose blocker is now resolved → `unblock`. + +## Rules + +- Never delete or prune a feature whose status is `verified`. +- Prefer the smallest change that fixes the observed problem. +- Every operation must carry a `reason` explaining why. + +## Available operations + +`reprioritize` (set `priority`), `block`, `unblock`, `split` (provide +`newFeatures`), `prune`, `add_dependency` (set `dependsOn`). Each references a +`featureId` where applicable. + +## Final output — REQUIRED + +End your response with exactly one plan-update block on its own line. + +``` +{"operations":[{"op":"reprioritize","featureId":"FEAT-003","priority":2,"reason":"..."}],"summary":"one-line summary of the changes"} +``` diff --git a/packages/ralph/assets/prompts/verifier.md b/packages/ralph/assets/prompts/verifier.md new file mode 100644 index 0000000..efced52 --- /dev/null +++ b/packages/ralph/assets/prompts/verifier.md @@ -0,0 +1,47 @@ +# <%= it.projectName %> — Independent Verification + +You are a FRESH-CONTEXT verifier. You did NOT write this code and you have no +stake in it passing. Your job is to independently determine whether feature +**<%= it.feature.id %>** genuinely works, by executing its steps against the +running application and reading the tests and diff — not by trusting anyone's +claims. + +## What you are verifying + +**<%= it.feature.id %>: <%~ it.feature.description %>** + +Steps that must hold: + +<% ;(it.feature.steps || []).forEach(function (s) { %><%~ "- " + s + "\n" %><% }) %> + +## Diff summary (what the coder claims to have changed) + +<%~ it.diffSummary %> + +## How to verify + +1. The app is running on port <%= it.devPort %>. Exercise each step directly: + `curl` endpoints, and use the Playwright MCP tools to drive the UI. +2. Run the test suite (`npm test`) and confirm the relevant tests exist and pass. +3. Read the changed code. You may NOT trust code comments, commit messages, or + progress notes as evidence — only observed behavior and passing tests count. + +## Rules + +- READ-ONLY: do not modify code, tests, or configuration. If something is broken, + report it — do not fix it. +- If ANY security flaw or logic error exists (e.g. missing authz, injection, + incorrect calculation), the verdict is **fail** regardless of whether the steps + appeared to pass. +- If you cannot conclusively confirm the feature — the app won't run, a step is + ambiguous, or you lack evidence — the verdict is **inconclusive**. Do not guess + **pass**. + +## Final output — REQUIRED + +End your response with exactly one verdict block on its own line. Include one +entry per step with what you observed, and list every concern you found. + +``` +{"verdict":"pass|fail|inconclusive","steps":[{"step":"","ok":true,"evidence":"what you observed"}],"concerns":[]} +``` diff --git a/packages/ralph/package.json b/packages/ralph/package.json new file mode 100644 index 0000000..15dec84 --- /dev/null +++ b/packages/ralph/package.json @@ -0,0 +1,52 @@ +{ + "name": "ralph-loop", + "version": "0.1.0", + "description": "Autonomous software-building harness — the Ralph loop codified into a guardrailed, multi-model orchestrator.", + "bin": { + "ralph": "./dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/", + "assets/", + "schema/" + ], + "scripts": { + "build": "tsc && tsx scripts/gen-schema.ts", + "gen-schema": "tsx scripts/gen-schema.ts", + "typecheck": "tsc --noEmit", + "prepare": "npm run build" + }, + "keywords": [ + "ai", + "agent", + "autonomous", + "coding", + "harness", + "ralph", + "claude", + "codex", + "loop", + "orchestrator" + ], + "license": "MIT", + "author": "weststack", + "dependencies": { + "commander": "^12.1.0", + "cross-spawn": "^7.0.6", + "eta": "^3.4.0", + "tree-kill": "^1.2.2", + "zod": "^3.23.8" + }, + "optionalDependencies": { + "node-notifier": "^10.0.1" + }, + "devDependencies": { + "@types/cross-spawn": "^6.0.6", + "zod-to-json-schema": "^3.23.5" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/ralph/schema/ralph.config.schema.json b/packages/ralph/schema/ralph.config.schema.json new file mode 100644 index 0000000..aa29d21 --- /dev/null +++ b/packages/ralph/schema/ralph.config.schema.json @@ -0,0 +1,484 @@ +{ + "$ref": "#/definitions/RalphConfig", + "definitions": { + "RalphConfig": { + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "number", + "const": 2, + "default": 2 + }, + "specDir": { + "type": "string", + "default": "specs/phase1" + }, + "roles": { + "type": "object", + "properties": { + "coder": { + "type": "object", + "properties": { + "adapter": { + "type": "string" + }, + "model": { + "type": "string" + }, + "permissionTier": { + "type": "string", + "enum": [ + "readonly", + "edit", + "full" + ] + } + }, + "required": [ + "adapter" + ], + "additionalProperties": false + }, + "verifier": { + "type": "object", + "properties": { + "adapter": { + "type": "string" + }, + "model": { + "type": "string" + }, + "permissionTier": { + "type": "string", + "enum": [ + "readonly", + "edit", + "full" + ] + } + }, + "required": [ + "adapter" + ], + "additionalProperties": false + }, + "planner": { + "type": "object", + "properties": { + "adapter": { + "type": "string" + }, + "model": { + "type": "string" + }, + "permissionTier": { + "type": "string", + "enum": [ + "readonly", + "edit", + "full" + ] + } + }, + "required": [ + "adapter" + ], + "additionalProperties": false + }, + "replanner": { + "type": "object", + "properties": { + "adapter": { + "type": "string" + }, + "model": { + "type": "string" + }, + "permissionTier": { + "type": "string", + "enum": [ + "readonly", + "edit", + "full" + ] + } + }, + "required": [ + "adapter" + ], + "additionalProperties": false + }, + "gardener": { + "type": "object", + "properties": { + "adapter": { + "type": "string" + }, + "model": { + "type": "string" + }, + "permissionTier": { + "type": "string", + "enum": [ + "readonly", + "edit", + "full" + ] + } + }, + "required": [ + "adapter" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "default": {} + }, + "gates": { + "type": "object", + "properties": { + "typecheck": { + "anyOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "baselineRelative": { + "type": "boolean", + "default": false + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 600000 + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + { + "type": "boolean", + "const": false + } + ], + "default": { + "command": "npx tsc --noEmit", + "baselineRelative": false, + "timeoutMs": 600000 + } + }, + "test": { + "anyOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "baselineRelative": { + "type": "boolean", + "default": false + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 600000 + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + { + "type": "boolean", + "const": false + } + ], + "default": { + "command": "npm test", + "baselineRelative": true, + "timeoutMs": 600000 + } + }, + "build": { + "anyOf": [ + { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "baselineRelative": { + "type": "boolean", + "default": false + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 600000 + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + { + "type": "boolean", + "const": false + } + ], + "default": false + }, + "diff": { + "anyOf": [ + { + "type": "object", + "properties": { + "maxFiles": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxLines": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": [ + "maxFiles", + "maxLines" + ], + "additionalProperties": false + }, + { + "type": "boolean", + "const": false + } + ], + "default": { + "maxFiles": 40, + "maxLines": 3000 + } + } + }, + "additionalProperties": false, + "default": {} + }, + "retries": { + "type": "object", + "properties": { + "maxAttempts": { + "type": "integer", + "minimum": 0, + "default": 2 + } + }, + "additionalProperties": false, + "default": { + "maxAttempts": 2 + } + }, + "budgets": { + "type": "object", + "properties": { + "maxCostUsd": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maxIterations": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxWallClockMinutes": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false, + "default": {} + }, + "replan": { + "type": "object", + "properties": { + "everyIterations": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false, + "default": {} + }, + "garden": { + "type": "object", + "properties": { + "everyIterations": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false, + "default": {} + }, + "stall": { + "type": "object", + "properties": { + "noProgressIterations": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 4 + } + }, + "additionalProperties": false, + "default": { + "noProgressIterations": 4 + } + }, + "verify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "unlockOn": { + "type": "string", + "enum": [ + "verified", + "passed" + ], + "default": "verified" + } + }, + "additionalProperties": false, + "default": { + "enabled": true, + "unlockOn": "verified" + } + }, + "devServer": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "installCommand": { + "type": "string" + }, + "command": { + "type": "string", + "default": "npm run dev" + }, + "port": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 3000 + }, + "readinessPath": { + "type": "string", + "default": "/" + }, + "readyTimeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "default": 120000 + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "portRange": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "integer", + "exclusiveMinimum": 0 + } + ] + } + }, + "additionalProperties": false, + "default": { + "enabled": true, + "command": "npm run dev", + "port": 3000, + "readinessPath": "/", + "readyTimeoutMs": 120000, + "env": {} + } + }, + "notifications": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "desktop" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "webhook" + }, + "url": { + "type": "string", + "format": "uri" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] + }, + "default": [] + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/ralph/scripts/gen-schema.ts b/packages/ralph/scripts/gen-schema.ts new file mode 100644 index 0000000..7d3474f --- /dev/null +++ b/packages/ralph/scripts/gen-schema.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { RalphConfigSchema } from "../src/config/schema"; + +/** + * Emit schema/ralph.config.schema.json so scaffolded ralph.config.json files can + * reference it via "$schema" for editor validation + autocomplete. Run as part + * of the package build; the output is shipped in the published tarball. + */ +const schema = zodToJsonSchema(RalphConfigSchema, { + name: "RalphConfig", + $refStrategy: "none", +}); + +const outDir = path.join(__dirname, "..", "schema"); +fs.mkdirSync(outDir, { recursive: true }); +const outPath = path.join(outDir, "ralph.config.schema.json"); +fs.writeFileSync(outPath, JSON.stringify(schema, null, 2) + "\n"); +// eslint-disable-next-line no-console +console.log(`wrote ${path.relative(process.cwd(), outPath)}`); diff --git a/packages/ralph/src/adapters/aider.test.ts b/packages/ralph/src/adapters/aider.test.ts new file mode 100644 index 0000000..6a2f308 --- /dev/null +++ b/packages/ralph/src/adapters/aider.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { parseAiderOutput, permissionToAiderArgs } from "./aider"; + +describe("parseAiderOutput", () => { + it("parses tokens and cost from the footer", () => { + const out = "Applied edit to src/x.ts\nTokens: 12k sent, 340 received. Cost: $0.02 message, $0.05 session."; + const { usage } = parseAiderOutput(out); + expect(usage?.inputTokens).toBe(12000); + expect(usage?.outputTokens).toBe(340); + expect(usage?.costUsd).toBe(0.02); + }); + + it("returns no usage when nothing matches", () => { + expect(parseAiderOutput("just some text").usage).toBeUndefined(); + }); + + it("maps permission tiers", () => { + expect(permissionToAiderArgs("readonly")).toEqual(["--chat-mode", "ask"]); + expect(permissionToAiderArgs("edit")).toEqual([]); + expect(permissionToAiderArgs("full")).toEqual([]); + }); +}); diff --git a/packages/ralph/src/adapters/aider.ts b/packages/ralph/src/adapters/aider.ts new file mode 100644 index 0000000..5e2829a --- /dev/null +++ b/packages/ralph/src/adapters/aider.ts @@ -0,0 +1,91 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { run, commandExists } from "../util/proc"; +import type { AgentRequest, AgentResult, AgentUsage, RunnerAdapter, PermissionTier } from "./types"; + +/** + * Aider adapter — primarily a coder adapter, and the documented path to LOCAL + * LLMs: set the role's model to e.g. "ollama/qwen2.5-coder" (with OLLAMA_API_BASE + * in devServer.env / process env) or "openrouter/…". The harness owns git, so we + * pass --no-auto-commits and let the loop commit/revert. + * + * Flag assumptions (centralized here; may need tuning to the installed aider): + * - one-shot non-interactive run via --message-file (avoids argv length limits) + * - --yes-always auto-confirms; --no-auto-commits / --no-gitignore keep git ours + */ +export class AiderAdapter implements RunnerAdapter { + readonly name = "aider"; + + async isAvailable(): Promise { + return commandExists("aider"); + } + + async invoke(req: AgentRequest): Promise { + const tmp = path.join(os.tmpdir(), `ralph-aider-${process.pid}-${Date.now()}.md`); + fs.writeFileSync(tmp, req.prompt); + try { + const args = [ + "--yes-always", + "--no-auto-commits", + "--no-pretty", + "--no-stream", + ...(req.model ? ["--model", req.model] : []), + ...permissionToAiderArgs(req.permissionTier), + "--message-file", + tmp, + ...(req.extraArgs ?? []), + ]; + const res = await run("aider", args, { + cwd: req.cwd, + timeoutMs: req.timeoutMs, + onStdout: req.onOutput, + }); + return { + exitCode: res.code, + rawOutput: res.combined, + usage: parseAiderOutput(res.stdout).usage, + durationMs: res.durationMs, + timedOut: res.timedOut, + }; + } finally { + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + } + } +} + +/** Aider is an editor; readonly maps to a chat-only run (no file edits). */ +export function permissionToAiderArgs(tier: PermissionTier): string[] { + return tier === "readonly" ? ["--chat-mode", "ask"] : []; +} + +/** + * Parse aider's token/cost footer, e.g.: + * "Tokens: 12k sent, 340 received. Cost: $0.02 message, $0.05 session." + * Best-effort; returns undefined usage when nothing matches. + */ +export function parseAiderOutput(stdout: string): { usage?: AgentUsage } { + const sent = /([\d.]+)\s*([km]?)\s*(?:tokens\s+)?sent/i.exec(stdout); + const recv = /([\d.]+)\s*([km]?)\s*(?:tokens\s+)?received/i.exec(stdout); + const cost = /Cost:\s*\$([\d.]+)\s*message/i.exec(stdout) ?? /\$([\d.]+)\s*session/i.exec(stdout); + + const inputTokens = sent ? scale(sent[1], sent[2]) : undefined; + const outputTokens = recv ? scale(recv[1], recv[2]) : undefined; + const costUsd = cost ? Number(cost[1]) : undefined; + + if (inputTokens === undefined && outputTokens === undefined && costUsd === undefined) { + return {}; + } + return { usage: { inputTokens, outputTokens, costUsd } }; +} + +function scale(num: string, suffix: string): number { + const n = Number(num); + if (suffix?.toLowerCase() === "k") return Math.round(n * 1000); + if (suffix?.toLowerCase() === "m") return Math.round(n * 1_000_000); + return Math.round(n); +} diff --git a/packages/ralph/src/adapters/claude.test.ts b/packages/ralph/src/adapters/claude.test.ts new file mode 100644 index 0000000..e5484b4 --- /dev/null +++ b/packages/ralph/src/adapters/claude.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { parseClaudeJsonOutput, permissionToAllowedTools } from "./claude"; + +describe("parseClaudeJsonOutput", () => { + it("parses a realistic result payload into text + usage", () => { + const fixture = JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: "Done — added the login form.", + total_cost_usd: 0.0123, + usage: { input_tokens: 1500, output_tokens: 320 }, + }); + + const parsed = parseClaudeJsonOutput(fixture); + expect(parsed.text).toBe("Done — added the login form."); + expect(parsed.isError).toBe(false); + expect(parsed.usage).toEqual({ + inputTokens: 1500, + outputTokens: 320, + costUsd: 0.0123, + }); + expect(parsed.structured).toBeTypeOf("object"); + }); + + it("flags is_error true", () => { + const parsed = parseClaudeJsonOutput( + JSON.stringify({ is_error: true, result: "boom" }), + ); + expect(parsed.isError).toBe(true); + expect(parsed.text).toBe("boom"); + }); + + it("degrades gracefully on malformed JSON", () => { + const parsed = parseClaudeJsonOutput("not json at all {"); + expect(parsed.text).toBe("not json at all {"); + expect(parsed.usage).toBeUndefined(); + expect(parsed.isError).toBe(false); + expect(parsed.structured).toBeUndefined(); + }); + + it("omits usage when no token/cost fields are present", () => { + const parsed = parseClaudeJsonOutput(JSON.stringify({ result: "hi" })); + expect(parsed.usage).toBeUndefined(); + expect(parsed.text).toBe("hi"); + }); +}); + +describe("permissionToAllowedTools", () => { + it("maps readonly", () => { + expect(permissionToAllowedTools("readonly")).toBe( + "Read,Glob,Grep,Bash(git diff:*),Bash(git log:*),mcp__playwright", + ); + }); + it("maps edit", () => { + expect(permissionToAllowedTools("edit")).toBe( + "Read,Write,Edit,Glob,Grep,Bash(git diff:*),Bash(git log:*),mcp__playwright", + ); + }); + it("maps full", () => { + expect(permissionToAllowedTools("full")).toBe( + "Read,Write,Edit,Glob,Grep,Bash,mcp__playwright", + ); + }); +}); diff --git a/packages/ralph/src/adapters/claude.ts b/packages/ralph/src/adapters/claude.ts new file mode 100644 index 0000000..e71ec48 --- /dev/null +++ b/packages/ralph/src/adapters/claude.ts @@ -0,0 +1,111 @@ +import { commandExists, run } from "../util/proc"; +import type { + AgentRequest, + AgentResult, + AgentUsage, + PermissionTier, + RunnerAdapter, +} from "./types"; + +/** + * Adapter for Anthropic's `claude` CLI. We run in print mode (`-p`) with + * `--output-format json` so the loop can recover assistant text, token usage + * and dollar cost from a single structured payload. The prompt is piped via + * STDIN rather than passed as an argv positional to avoid arg-length limits + * for large prompts. + */ + +/** + * Map a permission tier to claude's `--allowedTools` value. Centralized and + * exported so the mapping can be unit-tested independently of invocation. + */ +export function permissionToAllowedTools(tier: PermissionTier): string { + switch (tier) { + case "readonly": + return "Read,Glob,Grep,Bash(git diff:*),Bash(git log:*),mcp__playwright"; + case "edit": + return "Read,Write,Edit,Glob,Grep,Bash(git diff:*),Bash(git log:*),mcp__playwright"; + case "full": + return "Read,Write,Edit,Glob,Grep,Bash,mcp__playwright"; + } +} + +/** + * Parse the JSON emitted by `claude -p --output-format json`. Defensive: any + * parse failure or unexpected shape degrades to raw text with no usage, never + * throwing. + */ +export function parseClaudeJsonOutput(stdout: string): { + text: string; + usage?: AgentUsage; + isError: boolean; + structured?: unknown; +} { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return { text: stdout, isError: false }; + } + + if (parsed === null || typeof parsed !== "object") { + return { text: stdout, isError: false, structured: parsed }; + } + + const obj = parsed as Record; + + const text = typeof obj.result === "string" ? obj.result : stdout; + const isError = obj.is_error === true; + + let usage: AgentUsage | undefined; + const rawUsage = + obj.usage && typeof obj.usage === "object" + ? (obj.usage as Record) + : undefined; + const inputTokens = rawUsage && typeof rawUsage.input_tokens === "number" ? rawUsage.input_tokens : undefined; + const outputTokens = rawUsage && typeof rawUsage.output_tokens === "number" ? rawUsage.output_tokens : undefined; + const costUsd = typeof obj.total_cost_usd === "number" ? obj.total_cost_usd : undefined; + + if (inputTokens !== undefined || outputTokens !== undefined || costUsd !== undefined) { + usage = { inputTokens, outputTokens, costUsd }; + } + + return { text, usage, isError, structured: parsed }; +} + +export class ClaudeAdapter implements RunnerAdapter { + readonly name = "claude"; + + isAvailable(): Promise { + return commandExists("claude"); + } + + async invoke(req: AgentRequest): Promise { + const args: string[] = ["-p", "--output-format", "json"]; + if (req.model) { + args.push("--model", req.model); + } + args.push("--allowedTools", permissionToAllowedTools(req.permissionTier)); + if (req.extraArgs?.length) { + args.push(...req.extraArgs); + } + + const res = await run("claude", args, { + cwd: req.cwd, + input: req.prompt, + timeoutMs: req.timeoutMs, + onStdout: req.onOutput, + }); + + const parsed = parseClaudeJsonOutput(res.stdout); + + return { + exitCode: res.code, + rawOutput: res.combined, + structured: parsed.structured, + usage: parsed.usage, + durationMs: res.durationMs, + timedOut: res.timedOut, + }; + } +} diff --git a/packages/ralph/src/adapters/codex.test.ts b/packages/ralph/src/adapters/codex.test.ts new file mode 100644 index 0000000..1d1a2de --- /dev/null +++ b/packages/ralph/src/adapters/codex.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { parseCodexOutput, permissionToSandboxArgs } from "./codex"; + +describe("parseCodexOutput", () => { + it("extracts last assistant message and sums token usage from JSONL", () => { + const jsonl = [ + JSON.stringify({ type: "task_started" }), + JSON.stringify({ type: "agent_message", message: "Thinking...", usage: { input_tokens: 100, output_tokens: 20 } }), + JSON.stringify({ type: "agent_message", message: "Final answer.", usage: { input_tokens: 50, output_tokens: 80 } }), + ].join("\n"); + + const parsed = parseCodexOutput(jsonl); + expect(parsed.text).toBe("Final answer."); + expect(parsed.usage).toEqual({ inputTokens: 150, outputTokens: 100 }); + expect(Array.isArray(parsed.structured)).toBe(true); + }); + + it("handles nested msg + token_count shapes", () => { + const jsonl = [ + JSON.stringify({ msg: { text: "hello" }, token_count: { prompt_tokens: 10, completion_tokens: 5 } }), + ].join("\n"); + const parsed = parseCodexOutput(jsonl); + expect(parsed.text).toBe("hello"); + expect(parsed.usage).toEqual({ inputTokens: 10, outputTokens: 5 }); + }); + + it("falls back to raw stdout when no JSON lines parse", () => { + const parsed = parseCodexOutput("plain text output\nno json here"); + expect(parsed.text).toBe("plain text output\nno json here"); + expect(parsed.usage).toBeUndefined(); + expect(parsed.structured).toBeUndefined(); + }); + + it("omits usage when events carry no tokens but keeps text", () => { + const parsed = parseCodexOutput(JSON.stringify({ message: "no tokens here" })); + expect(parsed.text).toBe("no tokens here"); + expect(parsed.usage).toBeUndefined(); + }); +}); + +describe("permissionToSandboxArgs", () => { + it("maps readonly", () => { + expect(permissionToSandboxArgs("readonly")).toEqual(["--sandbox", "read-only"]); + }); + it("maps edit", () => { + expect(permissionToSandboxArgs("edit")).toEqual(["--sandbox", "workspace-write"]); + }); + it("maps full", () => { + expect(permissionToSandboxArgs("full")).toEqual([ + "--dangerously-bypass-approvals-and-sandbox", + ]); + }); +}); diff --git a/packages/ralph/src/adapters/codex.ts b/packages/ralph/src/adapters/codex.ts new file mode 100644 index 0000000..4cdbbbe --- /dev/null +++ b/packages/ralph/src/adapters/codex.ts @@ -0,0 +1,185 @@ +import { commandExists, run } from "../util/proc"; +import type { + AgentRequest, + AgentResult, + AgentUsage, + PermissionTier, + RunnerAdapter, +} from "./types"; + +/** + * Adapter for OpenAI's `codex` CLI. We invoke `codex exec --json` with the + * prompt piped via STDIN and consume the JSONL event stream for the assistant + * message and token usage. + * + * FLAG ASSUMPTIONS (may need tuning against the installed codex version — kept + * centralized here so there is a single place to adjust): + * - `exec` is the non-interactive subcommand; `--json` selects JSONL events. + * - permission tiers map to sandbox/approval flags: + * readonly → --sandbox read-only + * edit → --sandbox workspace-write + * full → --dangerously-bypass-approvals-and-sandbox + * (the non-interactive equivalent of the interactive loop's `--yolo`). + * - `--model ` selects the model when provided. + */ + +/** + * Map a permission tier to codex sandbox/approval argv. Exported for testing. + */ +export function permissionToSandboxArgs(tier: PermissionTier): string[] { + switch (tier) { + case "readonly": + return ["--sandbox", "read-only"]; + case "edit": + return ["--sandbox", "workspace-write"]; + case "full": + return ["--dangerously-bypass-approvals-and-sandbox"]; + } +} + +function coerceNumber(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +/** + * Pull input/output token counts out of a codex event, handling a couple of + * observed shapes: flat `input_tokens`/`output_tokens`, or nested under + * `usage` / `token_count` (which may itself use `prompt_tokens`/ + * `completion_tokens`). Returns undefined when nothing usable is found. + */ +function extractTokens(obj: Record): { input?: number; output?: number } | undefined { + const candidates: Array> = [obj]; + for (const key of ["usage", "token_count", "tokens"]) { + const nested = obj[key]; + if (nested && typeof nested === "object") { + candidates.push(nested as Record); + } + } + + for (const c of candidates) { + const input = + coerceNumber(c.input_tokens) ?? coerceNumber(c.prompt_tokens) ?? coerceNumber(c.input); + const output = + coerceNumber(c.output_tokens) ?? coerceNumber(c.completion_tokens) ?? coerceNumber(c.output); + if (input !== undefined || output !== undefined) { + return { input, output }; + } + } + return undefined; +} + +/** + * Extract a human-readable assistant message from a codex event, tolerating a + * few shapes: `{ message }`, `{ text }`, `{ delta }`, or nested + * `{ msg: { message | text } }` / `{ content: "..." }`. + */ +function extractMessage(obj: Record): string | undefined { + const direct = + (typeof obj.message === "string" && obj.message) || + (typeof obj.text === "string" && obj.text) || + (typeof obj.content === "string" && obj.content) || + (typeof obj.delta === "string" && obj.delta); + if (direct) return direct; + + const msg = obj.msg; + if (msg && typeof msg === "object") { + const m = msg as Record; + const nested = + (typeof m.message === "string" && m.message) || + (typeof m.text === "string" && m.text) || + (typeof m.content === "string" && m.content); + if (nested) return nested; + } + return undefined; +} + +/** + * Parse codex `--json` JSONL output. Best-effort and defensive: scans lines, + * JSON.parses each, keeps the last event carrying assistant text, and sums any + * token usage found. If no line parses as JSON, returns the raw stdout as text. + */ +export function parseCodexOutput(stdout: string): { + text: string; + usage?: AgentUsage; + structured?: unknown; +} { + const lines = stdout.split(/\r?\n/); + const events: unknown[] = []; + let lastText: string | undefined; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + let sawTokens = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let evt: unknown; + try { + evt = JSON.parse(trimmed); + } catch { + continue; + } + events.push(evt); + if (evt === null || typeof evt !== "object") continue; + const obj = evt as Record; + + const msg = extractMessage(obj); + if (msg !== undefined) lastText = msg; + + const tokens = extractTokens(obj); + if (tokens) { + if (tokens.input !== undefined) inputTokens = (inputTokens ?? 0) + tokens.input; + if (tokens.output !== undefined) outputTokens = (outputTokens ?? 0) + tokens.output; + sawTokens = true; + } + } + + if (events.length === 0) { + return { text: stdout }; + } + + const usage: AgentUsage | undefined = sawTokens ? { inputTokens, outputTokens } : undefined; + + return { + text: lastText ?? stdout, + usage, + structured: events, + }; +} + +export class CodexAdapter implements RunnerAdapter { + readonly name = "codex"; + + isAvailable(): Promise { + return commandExists("codex"); + } + + async invoke(req: AgentRequest): Promise { + const args: string[] = ["exec", "--json"]; + if (req.model) { + args.push("--model", req.model); + } + args.push(...permissionToSandboxArgs(req.permissionTier)); + if (req.extraArgs?.length) { + args.push(...req.extraArgs); + } + + const res = await run("codex", args, { + cwd: req.cwd, + input: req.prompt, + timeoutMs: req.timeoutMs, + onStdout: req.onOutput, + }); + + const parsed = parseCodexOutput(res.stdout); + + return { + exitCode: res.code, + rawOutput: res.combined, + structured: parsed.structured, + usage: parsed.usage, + durationMs: res.durationMs, + timedOut: res.timedOut, + }; + } +} diff --git a/packages/ralph/src/adapters/mock.ts b/packages/ralph/src/adapters/mock.ts new file mode 100644 index 0000000..2b12289 --- /dev/null +++ b/packages/ralph/src/adapters/mock.ts @@ -0,0 +1,65 @@ +import type { AgentRequest, AgentResult, AgentUsage, RunnerAdapter } from "./types"; + +/** + * In-memory adapter for unit tests and the loop e2e. It never spawns a process; + * instead a handler decides each turn's result, allowing tests to simulate + * agent output and (via `scripted`) real file mutations in the working tree. + */ + +export type MockHandler = ( + req: AgentRequest, + callIndex: number, +) => AgentResult | Promise; + +export interface MockTurn { + /** Simulate the agent editing files under `cwd` before returning output. */ + mutate?: (cwd: string) => void | Promise; + output: string; + usage?: AgentUsage; + exitCode?: number; +} + +export class MockAdapter implements RunnerAdapter { + readonly name = "mock"; + + private callIndex = 0; + private readonly handler: MockHandler; + + constructor(handler: MockHandler) { + this.handler = handler; + } + + /** + * Build a MockAdapter that plays a fixed sequence of turns. Each invoke runs + * the next turn (awaiting its `mutate` to simulate file edits). Once the + * sequence is exhausted it repeats the final turn's output with no mutate. + */ + static scripted(turns: MockTurn[]): MockAdapter { + return new MockAdapter(async (req, callIndex) => { + const exhausted = callIndex >= turns.length; + const turn = exhausted ? turns[turns.length - 1] : turns[callIndex]; + if (!turn) { + return { exitCode: 0, rawOutput: "", durationMs: 1, timedOut: false }; + } + if (!exhausted && turn.mutate) { + await turn.mutate(req.cwd); + } + return { + exitCode: turn.exitCode ?? 0, + rawOutput: turn.output, + usage: turn.usage, + durationMs: 1, + timedOut: false, + }; + }); + } + + isAvailable(): Promise { + return Promise.resolve(true); + } + + async invoke(req: AgentRequest): Promise { + const idx = this.callIndex++; + return this.handler(req, idx); + } +} diff --git a/packages/ralph/src/adapters/registry.test.ts b/packages/ralph/src/adapters/registry.test.ts new file mode 100644 index 0000000..84edd64 --- /dev/null +++ b/packages/ralph/src/adapters/registry.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import type { AgentRequest } from "./types"; +import { ClaudeAdapter } from "./claude"; +import { CodexAdapter } from "./codex"; +import { MockAdapter } from "./mock"; +import { getAdapter, probeAvailability, registerAdapter } from "./registry"; + +function makeReq(cwd: string): AgentRequest { + return { + prompt: "do the thing", + cwd, + role: "coder", + permissionTier: "full", + timeoutMs: 1000, + }; +} + +describe("registry", () => { + it("returns fresh built-in adapters", () => { + expect(getAdapter("claude")).toBeInstanceOf(ClaudeAdapter); + expect(getAdapter("codex")).toBeInstanceOf(CodexAdapter); + // fresh instances each call + expect(getAdapter("claude")).not.toBe(getAdapter("claude")); + }); + + it("throws on unknown name", () => { + expect(() => getAdapter("nope")).toThrow("Unknown adapter: nope"); + }); + + it("consults registered factories (mock)", () => { + registerAdapter("mock", () => MockAdapter.scripted([{ output: "ok" }])); + const adapter = getAdapter("mock"); + expect(adapter).toBeInstanceOf(MockAdapter); + expect(adapter.name).toBe("mock"); + }); + + it("registered factories override built-ins", () => { + const sentinel = MockAdapter.scripted([{ output: "overridden" }]); + registerAdapter("claude", () => sentinel); + expect(getAdapter("claude")).toBe(sentinel); + }); + + it("probeAvailability resolves unknown names to false", async () => { + registerAdapter("mock", () => MockAdapter.scripted([{ output: "ok" }])); + const result = await probeAvailability(["mock", "definitely-not-real"]); + expect(result).toEqual({ mock: true, "definitely-not-real": false }); + }); +}); + +describe("MockAdapter.scripted", () => { + it("sequences turns, runs mutate, and repeats the last turn when exhausted", async () => { + const mutated: string[] = []; + const adapter = MockAdapter.scripted([ + { + mutate: (cwd) => { + mutated.push(cwd + "/a"); + }, + output: "turn-1", + usage: { inputTokens: 1, outputTokens: 2 }, + exitCode: 0, + }, + { + mutate: async (cwd) => { + mutated.push(cwd + "/b"); + }, + output: "turn-2", + }, + ]); + + const r1 = await adapter.invoke(makeReq("/work")); + expect(r1.rawOutput).toBe("turn-1"); + expect(r1.usage).toEqual({ inputTokens: 1, outputTokens: 2 }); + expect(r1.exitCode).toBe(0); + expect(r1.timedOut).toBe(false); + + const r2 = await adapter.invoke(makeReq("/work")); + expect(r2.rawOutput).toBe("turn-2"); + + // exhausted → repeats last output, no further mutate + const r3 = await adapter.invoke(makeReq("/work")); + expect(r3.rawOutput).toBe("turn-2"); + + expect(mutated).toEqual(["/work/a", "/work/b"]); + }); + + it("custom handler receives incrementing callIndex", async () => { + const seen: number[] = []; + const adapter = new MockAdapter((_req, callIndex) => { + seen.push(callIndex); + return { exitCode: 0, rawOutput: String(callIndex), durationMs: 1, timedOut: false }; + }); + await adapter.invoke(makeReq("/w")); + await adapter.invoke(makeReq("/w")); + expect(seen).toEqual([0, 1]); + }); +}); diff --git a/packages/ralph/src/adapters/registry.ts b/packages/ralph/src/adapters/registry.ts new file mode 100644 index 0000000..2e84379 --- /dev/null +++ b/packages/ralph/src/adapters/registry.ts @@ -0,0 +1,59 @@ +import { ClaudeAdapter } from "./claude"; +import { CodexAdapter } from "./codex"; +import { AiderAdapter } from "./aider"; +import type { RunnerAdapter } from "./types"; + +/** + * Adapter registry. Built-in providers ("claude", "codex") are constructed + * fresh on demand; additional providers can be registered at runtime (tests + * register "mock"; a future phase can add "aider"). Registered factories take + * precedence over built-ins so a test can override behavior by name. + */ + +type AdapterFactory = () => RunnerAdapter; + +const registry = new Map(); + +/** Register (or override) an adapter factory by name. */ +export function registerAdapter(name: string, factory: AdapterFactory): void { + registry.set(name, factory); +} + +/** Resolve a fresh adapter instance by name. Throws for unknown names. */ +export function getAdapter(name: string): RunnerAdapter { + const factory = registry.get(name); + if (factory) return factory(); + + switch (name) { + case "claude": + return new ClaudeAdapter(); + case "codex": + return new CodexAdapter(); + case "aider": + return new AiderAdapter(); + default: + throw new Error("Unknown adapter: " + name); + } +} + +/** + * Probe availability of multiple adapters in parallel. Unknown names (and any + * adapter whose isAvailable throws) resolve to false rather than rejecting. + */ +export async function probeAvailability(names: string[]): Promise> { + const entries = await Promise.all( + names.map(async (name) => { + try { + const available = await getAdapter(name).isAvailable(); + return [name, available] as const; + } catch { + return [name, false] as const; + } + }), + ); + const result: Record = {}; + for (const [name, available] of entries) { + result[name] = available; + } + return result; +} diff --git a/packages/ralph/src/adapters/types.ts b/packages/ralph/src/adapters/types.ts new file mode 100644 index 0000000..ed0df3b --- /dev/null +++ b/packages/ralph/src/adapters/types.ts @@ -0,0 +1,57 @@ +/** + * The adapter layer abstracts over agent CLIs (claude, codex, aider, …). Each + * adapter owns invocation flags, model selection, permission mapping and + * output/usage parsing. The orchestrator only ever talks to this interface, so + * adding a provider (or a local-LLM CLI) never touches the loop. + */ + +export type Role = "coder" | "verifier" | "planner" | "replanner" | "gardener"; + +/** + * Permission tiers are mapped by each adapter to its own flags: + * - readonly: read/search/inspect only (verifier, replanner) + * - edit: read + write files, no arbitrary shell (planner) + * - full: read + write + shell (coder; maps to codex --yolo / claude Bash) + */ +export type PermissionTier = "readonly" | "edit" | "full"; + +export interface AgentUsage { + inputTokens?: number; + outputTokens?: number; + /** Provider-reported dollar cost for this invocation, when available. */ + costUsd?: number; +} + +export interface AgentRequest { + prompt: string; + cwd: string; + role: Role; + model?: string; + permissionTier: PermissionTier; + timeoutMs: number; + /** Extra provider-specific args appended verbatim after the adapter's own. */ + extraArgs?: string[]; + /** Stream stdout live to this sink (for `ralph run` console). */ + onOutput?: (chunk: string) => void; +} + +export interface AgentResult { + exitCode: number | null; + /** Full captured text output (stdout, plus stderr appended). */ + rawOutput: string; + /** Parsed structured payload when the adapter used a JSON output format. */ + structured?: unknown; + /** Token/cost usage when the adapter can extract it; undefined otherwise. */ + usage?: AgentUsage; + durationMs: number; + timedOut: boolean; +} + +export interface RunnerAdapter { + /** Stable identifier used in config `roles[*].adapter`. */ + readonly name: string; + /** True if the underlying CLI is installed and usable. */ + isAvailable(): Promise; + /** Run one agent turn to completion. */ + invoke(req: AgentRequest): Promise; +} diff --git a/packages/ralph/src/budget/tracker.ts b/packages/ralph/src/budget/tracker.ts new file mode 100644 index 0000000..edea276 --- /dev/null +++ b/packages/ralph/src/budget/tracker.ts @@ -0,0 +1,89 @@ +import type { RalphConfig } from "../config/schema"; +import type { RunState } from "../run/state"; +import type { BudgetEvent, StallEvent } from "../events/types"; +import { nowIso } from "../events/types"; + +/** + * Budget + stall evaluation. Pure functions over run state so they are trivially + * testable; the loop applies the decisions (append events, notify, halt). + */ + +export interface BudgetDecision { + halt: boolean; + reason?: string; + events: BudgetEvent[]; +} + +function budgetEvent( + metric: BudgetEvent["metric"], + spent: number, + limit: number, + halted: boolean, +): BudgetEvent { + return { type: "budget", ts: nowIso(), metric, spent, limit, halted }; +} + +/** + * Check hard budgets. effectiveMaxIterations is the CLI override or config value + * (0/undefined = unlimited). Returns halt=true with a reason once any budget is + * exhausted. + */ +export function checkBudget( + state: RunState, + config: RalphConfig, + effectiveMaxIterations: number | undefined, + elapsedMs: number, +): BudgetDecision { + const events: BudgetEvent[] = []; + + if (effectiveMaxIterations && state.iteration >= effectiveMaxIterations) { + events.push(budgetEvent("iterations", state.iteration, effectiveMaxIterations, true)); + return { halt: true, reason: `iteration budget reached (${effectiveMaxIterations})`, events }; + } + + const maxCost = config.budgets.maxCostUsd; + if (maxCost && state.totalCostUsd >= maxCost) { + events.push(budgetEvent("cost", round(state.totalCostUsd), maxCost, true)); + return { halt: true, reason: `cost budget reached ($${maxCost})`, events }; + } + + const maxMin = config.budgets.maxWallClockMinutes; + if (maxMin && elapsedMs >= maxMin * 60_000) { + events.push(budgetEvent("time", round(elapsedMs / 60_000), maxMin, true)); + return { halt: true, reason: `time budget reached (${maxMin} min)`, events }; + } + + return { halt: false, events }; +} + +export interface StallDecision { + stalled: boolean; + halt: boolean; + event?: StallEvent; +} + +/** + * Detect lack of progress. At `noProgressIterations` we notify; at 2x we halt to + * cap runaway spend when the loop is thrashing on unblockable work. + */ +export function checkStall(state: RunState, config: RalphConfig): StallDecision { + const gap = state.iteration - state.lastProgressIteration; + const threshold = config.stall.noProgressIterations; + if (threshold <= 0) return { stalled: false, halt: false }; + + if (gap >= threshold * 2) { + return { stalled: true, halt: true, event: stallEvent(gap, "halt") }; + } + if (gap >= threshold) { + return { stalled: true, halt: false, event: stallEvent(gap, "notify") }; + } + return { stalled: false, halt: false }; +} + +function stallEvent(gap: number, action: StallEvent["action"]): StallEvent { + return { type: "stall", ts: nowIso(), iterationsWithoutProgress: gap, action }; +} + +function round(n: number): number { + return Math.round(n * 10000) / 10000; +} diff --git a/packages/ralph/src/cli.ts b/packages/ralph/src/cli.ts new file mode 100644 index 0000000..05b13c1 --- /dev/null +++ b/packages/ralph/src/cli.ts @@ -0,0 +1,415 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { Command } from "commander"; +import { loadConfig, findConfig } from "./config/load"; +import { resolveRole } from "./config/schema"; +import { FeatureStore } from "./features/store"; +import { migrateV1File } from "./features/migrate"; +import { buildGates } from "./gates"; +import { getAdapter, probeAvailability } from "./adapters/registry"; +import { DevServerManager } from "./devserver/manager"; +import { EventLog } from "./events/log"; +import { RunStateStore } from "./run/state"; +import { NotificationHub } from "./notify"; +import { runLoop } from "./run/loop"; +import type { RunContext } from "./run/types"; +import { commandExists } from "./util/proc"; +import { renderPrompt, type PlanPromptContext } from "./prompts/render"; +import { isRepo } from "./util/git"; +import { featuresPath, configPath, legacyDir, CONFIG_FILENAME } from "./util/paths"; +import { defaultConfig } from "./config/schema"; +import { log, color } from "./util/logger"; +import { VERSION } from "./index"; + +const program = new Command(); +program + .name("ralph") + .description("Autonomous software-building harness — the Ralph loop, codified.") + .version(VERSION); + +// -------------------------------------------------------------------------- +// ralph run +// -------------------------------------------------------------------------- +program + .command("run") + .description("Run the autonomous build loop until complete, budget, or stall.") + .option("-n, --iterations ", "max iterations (overrides config budget)", (v) => parseInt(v, 10)) + .option("-b, --budget ", "max cost in USD (overrides config budget)", (v) => parseFloat(v)) + .option("--no-verify", "skip the independent verifier (faster, less safe)") + .option("--no-stream", "do not stream agent output to the console") + .option("--fresh", "clear prior run state and progress log before starting") + .action(async (opts) => { + const cwd = process.cwd(); + try { + await runCommand(cwd, opts); + } catch (e) { + log.error((e as Error).message); + process.exitCode = 1; + } + }); + +async function runCommand(cwd: string, opts: Record): Promise { + const config = loadConfig(cwd); + if (opts.verify === false) config.verify.enabled = false; + if (typeof opts.budget === "number") config.budgets.maxCostUsd = opts.budget as number; + + if (!(await isRepo(cwd))) { + throw new Error("ralph run must be executed inside a git repository (run `git init` first)."); + } + ensureRalphGitignored(cwd); + + const specDir = config.specDir; + const featuresAbs = featuresPath(cwd, specDir); + if (!fs.existsSync(featuresAbs)) { + throw new Error(`No features file at ${featuresAbs}. Generate specs first (create-ralph-loop / ralph plan).`); + } + const store = new FeatureStore(featuresAbs); + store.load(); + const dag = store.validate(); + if (!dag.ok) { + throw new Error(`features.json is invalid:\n${dag.errors.map((e) => " - " + e).join("\n")}`); + } + + const coderRole = resolveRole(config, "coder"); + const verifierRole = resolveRole(config, "verifier"); + const replannerRole = resolveRole(config, "replanner"); + const gardenerRole = resolveRole(config, "gardener"); + const coderAdapter = getAdapter(coderRole.adapter); + const verifierAdapter = getAdapter(verifierRole.adapter); + const replannerAdapter = getAdapter(replannerRole.adapter); + const gardenerAdapter = getAdapter(gardenerRole.adapter); + if (!(await coderAdapter.isAvailable())) { + throw new Error(`coder adapter '${coderRole.adapter}' CLI is not installed or not on PATH.`); + } + if (config.verify.enabled && !(await verifierAdapter.isAvailable())) { + throw new Error(`verifier adapter '${verifierRole.adapter}' CLI is not installed or not on PATH.`); + } + + const meta = readProjectMeta(cwd); + const eventLog = new EventLog(cwd); + if (opts.fresh) eventLog.clear(); + const stateStore = new RunStateStore(cwd); + let state = stateStore.load(); + if (opts.fresh || !state || state.done) { + state = stateStore.init(randomUUID(), store.counts().total); + } + + const devServer = new DevServerManager(cwd, config.devServer); + const notifier = new NotificationHub(config.notifications); + + const ctx: RunContext = { + cwd, + config, + projectName: meta.name, + projectDescription: meta.description, + featuresRelPath: path.posix.join(specDir.replace(/\\/g, "/"), "features.json"), + store, + devServer, + eventLog, + stateStore, + state, + gates: buildGates(config), + notifier, + coder: { adapter: coderAdapter, role: coderRole }, + verifier: { adapter: verifierAdapter, role: verifierRole }, + replanner: { adapter: replannerAdapter, role: replannerRole }, + gardener: { adapter: gardenerAdapter, role: gardenerRole }, + stream: opts.stream !== false, + agentTimeoutMs: Number(process.env.RALPH_AGENT_TIMEOUT_MS) || 30 * 60 * 1000, + }; + + let stopping = false; + const shutdown = async () => { + if (stopping) return; + stopping = true; + log.warn("\nShutting down — stopping dev server…"); + await devServer.down().catch(() => {}); + process.exit(130); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + log.step("Starting dev server…"); + try { + await devServer.up(); + } catch (e) { + throw new Error(`dev server failed to start: ${(e as Error).message}`); + } + + try { + await runLoop(ctx, { + maxIterations: typeof opts.iterations === "number" ? (opts.iterations as number) : undefined, + }); + } finally { + await devServer.down().catch(() => {}); + } +} + +// -------------------------------------------------------------------------- +// ralph dev up|down|status +// -------------------------------------------------------------------------- +const dev = program.command("dev").description("Manage the project dev server."); +dev + .command("up") + .description("Start the dev server in the background.") + .action(async () => withConfig(async (cwd, config) => { + await new DevServerManager(cwd, config.devServer).up(); + log.success("Dev server up."); + })); +dev + .command("down") + .description("Stop the dev server.") + .action(async () => withConfig(async (cwd, config) => { + await new DevServerManager(cwd, config.devServer).down(); + log.success("Dev server down."); + })); +dev + .command("status") + .description("Show dev server status.") + .action(async () => withConfig(async (cwd, config) => { + const s = new DevServerManager(cwd, config.devServer).status(); + log.info(s.running ? `running (pid ${s.pid}, port ${s.port})` : "not running"); + })); + +// -------------------------------------------------------------------------- +// ralph doctor +// -------------------------------------------------------------------------- +program + .command("doctor") + .description("Diagnose the project setup (adapters, git, config, DAG).") + .action(async () => { + const cwd = process.cwd(); + const rows: { name: string; ok: boolean; detail: string }[] = []; + const add = (name: string, ok: boolean, detail = "") => rows.push({ name, ok, detail }); + + add("git installed", await commandExists("git")); + add("inside git repo", await isRepo(cwd)); + + const cfgPath = findConfig(cwd); + add("ralph.config.json", !!cfgPath, cfgPath ?? "missing"); + + if (cfgPath) { + try { + const config = loadConfig(cwd); + add("config valid", true); + const featuresAbs = featuresPath(cwd, config.specDir); + if (fs.existsSync(featuresAbs)) { + const store = new FeatureStore(featuresAbs); + store.load(); + const dag = store.validate(); + add("features.json DAG", dag.ok, dag.ok ? `${store.counts().total} features` : dag.errors.join("; ")); + } else { + add("features.json", false, `missing at ${featuresAbs}`); + } + const adapters = [resolveRole(config, "coder").adapter, resolveRole(config, "verifier").adapter]; + const avail = await probeAvailability([...new Set(adapters)]); + for (const [name, ok] of Object.entries(avail)) add(`adapter: ${name}`, ok, ok ? "" : "not on PATH"); + } catch (e) { + add("config valid", false, (e as Error).message.split("\n")[0]); + } + } + + log.info(""); + for (const r of rows) { + const mark = r.ok ? color.green("✓") : color.red("✗"); + log.info(` ${mark} ${r.name}${r.detail ? color.dim(" — " + r.detail) : ""}`); + } + const failed = rows.filter((r) => !r.ok).length; + log.info(""); + log.info(failed ? color.yellow(`${failed} check(s) need attention.`) : color.green("All checks passed.")); + process.exitCode = failed ? 1 : 0; + }); + +// -------------------------------------------------------------------------- +// ralph status +// -------------------------------------------------------------------------- +program + .command("status") + .description("Summarize the latest run from .ralph/ telemetry.") + .action(() => { + const cwd = process.cwd(); + const state = new RunStateStore(cwd).load(); + if (!state) { + log.info("No run state yet. Start one with `ralph run`."); + return; + } + log.info(color.bold(`Run ${state.runId.slice(0, 8)} — ${state.done ? state.haltReason || "done" : "in progress"}`)); + log.info(` iterations: ${state.iteration}`); + log.info(` features: ${state.features.verified} verified, ${state.features.passed} passed, ${state.features.blocked} blocked of ${state.features.total}`); + log.info(` cost: $${state.totalCostUsd.toFixed(4)} (${state.totalInputTokens} in / ${state.totalOutputTokens} out tokens)`); + for (const [role, u] of Object.entries(state.perRole)) { + log.info(color.dim(` ${role}: ${u.invocations} calls, $${u.costUsd.toFixed(4)}, ${Math.round(u.durationMs / 1000)}s`)); + } + const events = new EventLog(cwd).read().slice(-8); + if (events.length) { + log.info(""); + log.info(color.dim(" recent events:")); + for (const e of events) log.info(color.dim(` ${(e as { ts: string }).ts?.slice(11, 19) ?? ""} ${e.type}`)); + } + }); + +// -------------------------------------------------------------------------- +// ralph migrate (v1 feature_list.json + ralph.sh → v2) +// -------------------------------------------------------------------------- +program + .command("migrate") + .description("Migrate a v1 Ralph project (feature_list.json + ralph.sh) to v2.") + .option("--spec-dir ", "spec directory", "specs/phase1") + .action((opts) => { + const cwd = process.cwd(); + const specDir = opts.specDir as string; + const v1 = path.join(cwd, specDir, "feature_list.json"); + if (!fs.existsSync(v1)) { + log.error(`No v1 feature_list.json found at ${v1}.`); + process.exitCode = 1; + return; + } + const dest = path.join(cwd, specDir, "features.json"); + fs.copyFileSync(v1, path.join(cwd, specDir, "feature_list.json.v1.bak")); + const migrated = migrateV1File(v1, dest); + log.success(`Migrated ${migrated.features.length} features → ${dest} (v1 backed up as feature_list.json.v1.bak).`); + + const cfg = configPath(cwd); + if (!fs.existsSync(cfg)) { + const base = defaultConfig(); + base.specDir = specDir; + const json = { $schema: "./node_modules/ralph-loop/schema/ralph.config.schema.json", ...base }; + fs.writeFileSync(cfg, JSON.stringify(json, null, 2) + "\n"); + log.success(`Wrote ${CONFIG_FILENAME} (review roles/devServer before running).`); + } + + // Park legacy bash scripts. + const legacy = legacyDir(cwd); + const toPark = ["ralph.sh", "init.sh", path.join("scripts", "dev-up.sh"), path.join("scripts", "dev-down.sh"), path.join("scripts", "dev-cleanup.sh")]; + let parked = 0; + for (const rel of toPark) { + const src = path.join(cwd, rel); + if (fs.existsSync(src)) { + fs.mkdirSync(path.dirname(path.join(legacy, rel)), { recursive: true }); + fs.renameSync(src, path.join(legacy, rel)); + parked++; + } + } + if (parked) log.info(color.dim(` Parked ${parked} legacy bash script(s) under .ralph/legacy/. The 'ralph' CLI replaces them.`)); + ensureRalphGitignored(cwd); + log.info("Next: review ralph.config.json, then `ralph doctor` and `ralph run`."); + }); + +// -------------------------------------------------------------------------- +// ralph plan (generate PRD + app_spec + features.json via the planner role) +// -------------------------------------------------------------------------- +program + .command("plan") + .description("Generate/refresh specs (PRD, app_spec, features.json) using the planner model.") + .option("--idea ", "one-line product idea (seeds the PRD)") + .option("--prd-only", "only (re)generate the PRD, not app_spec/features") + .action(async (opts) => { + await withConfig(async (cwd, config) => { + const role = resolveRole(config, "planner"); + const adapter = getAdapter(role.adapter); + if (!(await adapter.isAvailable())) { + throw new Error(`planner adapter '${role.adapter}' CLI is not installed or not on PATH.`); + } + const meta = readProjectMeta(cwd); + const context: PlanPromptContext = { + projectName: meta.name, + projectDescription: (opts.idea as string) ?? meta.description ?? "", + specDir: config.specDir, + }; + const prdPath = path.join(cwd, config.specDir, "PRD.md"); + const runPrompt = async (name: "prd" | "init", label: string) => { + const prompt = renderPrompt(name, context, { cwd, specDir: config.specDir }); + log.step(`Planning: ${label} (${role.adapter}/${role.model ?? "default"})…`); + const res = await adapter.invoke({ + prompt, cwd, role: "planner", model: role.model, permissionTier: role.permissionTier, + timeoutMs: Number(process.env.RALPH_AGENT_TIMEOUT_MS) || 30 * 60 * 1000, + onOutput: (c) => log.raw(c), + }); + if (res.exitCode !== 0 && res.exitCode !== null) log.warn(` planner exited ${res.exitCode}`); + }; + if (opts.idea || !fs.existsSync(prdPath)) await runPrompt("prd", "PRD"); + if (!opts.prdOnly) await runPrompt("init", "app_spec + features.json"); + log.success("Planning complete. Review the specs, then `ralph doctor` and `ralph run`."); + }); + }); + +// -------------------------------------------------------------------------- +// ralph export (verification records → eval-friendly JSONL) +// -------------------------------------------------------------------------- +program + .command("export") + .description("Export verification results as JSONL (for offline eval / analysis).") + .option("--format ", "output format", "eval-jsonl") + .option("--out ", "write to a file instead of stdout") + .action((opts) => { + const cwd = process.cwd(); + try { + const config = loadConfig(cwd); + const store = new FeatureStore(featuresPath(cwd, config.specDir)); + store.load(); + const lines = store.all().map((f) => + JSON.stringify({ + feature_id: f.id, + description: f.description, + steps: f.steps, + status: f.status, + verdict: f.verification?.verdict ?? null, + stepResults: f.verification?.stepResults ?? [], + concerns: f.verification?.concerns ?? [], + verified_at: f.verification?.at ?? null, + verifier: f.verification?.verifier ?? null, + }), + ); + const out = lines.join("\n") + (lines.length ? "\n" : ""); + const outFile = opts.out as string | undefined; + if (outFile) { + fs.writeFileSync(outFile, out); + log.success(`Wrote ${lines.length} record(s) to ${outFile}`); + } else { + process.stdout.write(out); + } + } catch (e) { + log.error((e as Error).message); + process.exitCode = 1; + } + }); + +program.parseAsync(process.argv); + + +// -------------------------------------------------------------------------- +// helpers +// -------------------------------------------------------------------------- +async function withConfig(fn: (cwd: string, config: ReturnType) => Promise): Promise { + const cwd = process.cwd(); + try { + await fn(cwd, loadConfig(cwd)); + } catch (e) { + log.error((e as Error).message); + process.exitCode = 1; + } +} + +function readProjectMeta(cwd: string): { name: string; description?: string } { + try { + const pj = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); + return { name: pj.name ?? path.basename(cwd), description: pj.description }; + } catch { + return { name: path.basename(cwd) }; + } +} + +function ensureRalphGitignored(cwd: string): void { + const gi = path.join(cwd, ".gitignore"); + let text = ""; + try { + text = fs.readFileSync(gi, "utf8"); + } catch { + /* no gitignore yet */ + } + if (!/^\.ralph\/?\s*$/m.test(text)) { + fs.writeFileSync(gi, (text && !text.endsWith("\n") ? text + "\n" : text) + ".ralph/\n"); + } +} diff --git a/packages/ralph/src/config/load.ts b/packages/ralph/src/config/load.ts new file mode 100644 index 0000000..cbce6e2 --- /dev/null +++ b/packages/ralph/src/config/load.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { z } from "zod"; +import { configPath } from "../util/paths"; +import { RalphConfig, parseConfig } from "./schema"; + +/** Locate ralph.config.json for a project root; null if absent. */ +export function findConfig(cwd: string): string | null { + const p = configPath(cwd); + return fs.existsSync(p) ? p : null; +} + +/** + * Load + validate ralph.config.json. Throws a readable Error (including zod + * issue paths) rather than a raw ZodError so the CLI can print it cleanly. + */ +export function loadConfig(cwd: string): RalphConfig { + const p = findConfig(cwd); + if (!p) { + throw new Error( + `No ${configPath(cwd)} found. Scaffold with 'create-ralph-loop' or run 'ralph migrate' in an existing Ralph project.`, + ); + } + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(p, "utf8")); + } catch (e) { + throw new Error(`Failed to parse ${p}: ${(e as Error).message}`); + } + try { + return parseConfig(raw); + } catch (e) { + if (e instanceof z.ZodError) { + const lines = e.issues.map( + (i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`, + ); + throw new Error(`Invalid ${p}:\n${lines.join("\n")}`); + } + throw e; + } +} diff --git a/packages/ralph/src/config/schema.ts b/packages/ralph/src/config/schema.ts new file mode 100644 index 0000000..4f7ee0b --- /dev/null +++ b/packages/ralph/src/config/schema.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; +import type { PermissionTier, Role } from "../adapters/types"; + +/** + * Schema for ralph.config.json — the single infrastructure file in a scaffolded + * project. Every field has a sensible default so a minimal (even empty) config + * resolves to a working setup. Role adapters/models encode the shipped default: + * Fable for planning, Codex for building, Haiku for cheap fail-closed verify. + */ + +const PermissionTierSchema = z.enum(["readonly", "edit", "full"]); + +const RoleConfigSchema = z.object({ + adapter: z.string(), + model: z.string().optional(), + permissionTier: PermissionTierSchema.optional(), +}); +export type RoleConfig = z.infer; + +/** Shipped default routing. See AskUserQuestion decision: Fable/Codex/Haiku. */ +export const DEFAULT_ROLES: Record = { + coder: { adapter: "codex", permissionTier: "full" }, + verifier: { adapter: "claude", model: "claude-haiku-4-5-20251001", permissionTier: "readonly" }, + planner: { adapter: "claude", model: "claude-fable-5", permissionTier: "edit" }, + replanner: { adapter: "claude", model: "claude-fable-5", permissionTier: "readonly" }, + gardener: { adapter: "claude", model: "claude-haiku-4-5-20251001", permissionTier: "full" }, +}; + +const DEFAULT_PERMISSION_BY_ROLE: Record = { + coder: "full", + verifier: "readonly", + planner: "edit", + replanner: "readonly", + gardener: "full", +}; + +const RolesSchema = z + .object({ + coder: RoleConfigSchema.optional(), + verifier: RoleConfigSchema.optional(), + planner: RoleConfigSchema.optional(), + replanner: RoleConfigSchema.optional(), + gardener: RoleConfigSchema.optional(), + }) + .default({}); + +const GateCommandSchema = z.object({ + command: z.string(), + /** When true, a failure only blocks if it introduces NEW failures vs baseline. */ + baselineRelative: z.boolean().default(false), + timeoutMs: z.number().int().positive().default(600_000), +}); +export type GateCommandConfig = z.infer; + +const DiffGateSchema = z.object({ + maxFiles: z.number().int().positive(), + maxLines: z.number().int().positive(), +}); +export type DiffGateConfig = z.infer; + +/** A gate set to `false` is disabled. */ +const GatesSchema = z + .object({ + typecheck: z + .union([GateCommandSchema, z.literal(false)]) + .default({ command: "npx tsc --noEmit", baselineRelative: false, timeoutMs: 600_000 }), + test: z + .union([GateCommandSchema, z.literal(false)]) + .default({ command: "npm test", baselineRelative: true, timeoutMs: 600_000 }), + build: z.union([GateCommandSchema, z.literal(false)]).default(false), + diff: z.union([DiffGateSchema, z.literal(false)]).default({ maxFiles: 40, maxLines: 3000 }), + }) + .default({}); + +const RetriesSchema = z + .object({ maxAttempts: z.number().int().min(0).default(2) }) + .default({ maxAttempts: 2 }); + +const BudgetsSchema = z + .object({ + maxCostUsd: z.number().positive().optional(), + maxIterations: z.number().int().positive().optional(), + maxWallClockMinutes: z.number().positive().optional(), + }) + .default({}); + +const ReplanSchema = z + .object({ everyIterations: z.number().int().positive().optional() }) + .default({}); + +/** Periodic "gardening" agent (entropy/slop cleanup). Disabled unless set. */ +const GardenSchema = z + .object({ everyIterations: z.number().int().positive().optional() }) + .default({}); + +const StallSchema = z + .object({ noProgressIterations: z.number().int().positive().default(4) }) + .default({ noProgressIterations: 4 }); + +const VerifySchema = z + .object({ + enabled: z.boolean().default(true), + /** Dependencies unlock when a feature reaches this status. */ + unlockOn: z.enum(["verified", "passed"]).default("verified"), + }) + .default({ enabled: true, unlockOn: "verified" }); + +const DevServerSchema = z + .object({ + enabled: z.boolean().default(true), + installCommand: z.string().optional(), + command: z.string().default("npm run dev"), + port: z.number().int().positive().default(3000), + readinessPath: z.string().default("/"), + readyTimeoutMs: z.number().int().positive().default(120_000), + env: z.record(z.string()).default({}), + /** Reserved for parallel tracks: per-track ports allocated from this range. */ + portRange: z.tuple([z.number().int().positive(), z.number().int().positive()]).optional(), + }) + .default({ + enabled: true, + command: "npm run dev", + port: 3000, + readinessPath: "/", + readyTimeoutMs: 120_000, + env: {}, + }); +export type DevServerConfig = z.infer; + +const NotificationSinkSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("desktop"), events: z.array(z.string()).optional() }), + z.object({ + type: z.literal("webhook"), + url: z.string().url(), + events: z.array(z.string()).optional(), + }), +]); +export type NotificationSinkConfig = z.infer; + +export const RalphConfigSchema = z.object({ + $schema: z.string().optional(), + version: z.literal(2).default(2), + specDir: z.string().default("specs/phase1"), + roles: RolesSchema, + gates: GatesSchema, + retries: RetriesSchema, + budgets: BudgetsSchema, + replan: ReplanSchema, + garden: GardenSchema, + stall: StallSchema, + verify: VerifySchema, + devServer: DevServerSchema, + notifications: z.array(NotificationSinkSchema).default([]), +}); + +export type RalphConfig = z.infer; + +export interface ResolvedRole { + adapter: string; + model?: string; + permissionTier: PermissionTier; +} + +/** Resolve a role's adapter/model/permission, falling back to shipped defaults. */ +export function resolveRole(config: RalphConfig, role: Role): ResolvedRole { + const provided = config.roles?.[role]; + const fallback = DEFAULT_ROLES[role]; + const adapter = provided?.adapter ?? fallback?.adapter; + if (!adapter) throw new Error(`No adapter configured for role "${role}"`); + return { + adapter, + model: provided?.model ?? fallback?.model, + permissionTier: + provided?.permissionTier ?? fallback?.permissionTier ?? DEFAULT_PERMISSION_BY_ROLE[role], + }; +} + +/** Parse + validate raw config (throws ZodError on invalid input). */ +export function parseConfig(raw: unknown): RalphConfig { + return RalphConfigSchema.parse(raw); +} + +/** A fully-resolved default config (used by tests and `ralph doctor`). */ +export function defaultConfig(): RalphConfig { + return RalphConfigSchema.parse({}); +} diff --git a/packages/ralph/src/devserver/manager.test.ts b/packages/ralph/src/devserver/manager.test.ts new file mode 100644 index 0000000..5f443fb --- /dev/null +++ b/packages/ralph/src/devserver/manager.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DevServerManager } from "./manager"; +import type { DevServerConfig } from "../config/schema"; + +/** A trivial HTTP server used AS the dev command, so up()/readiness/down() run end-to-end. */ +const SERVER_CMD = + `node -e "require('http').createServer((_,res)=>res.end('ok')).listen(process.env.PORT)"`; + +function makeConfig(port: number): DevServerConfig { + return { + enabled: true, + installCommand: undefined, + command: SERVER_CMD, + port, + readinessPath: "/", + readyTimeoutMs: 15_000, + env: {}, + }; +} + +function mkTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ralph-devserver-")); +} + +/** Windows may lag releasing the child's log-file handle after kill; retry rm. */ +async function rmDirWithRetry(dir: string): Promise { + for (let attempt = 0; ; attempt++) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (e) { + if (attempt >= 5) throw e; + await new Promise((r) => setTimeout(r, 150)); + } + } +} + +describe("DevServerManager end-to-end", () => { + it("starts a real server, reports running, serves requests, then stops", async () => { + // Pseudo-random ephemeral-ish port with one retry on collision. + const ports = [34100 + Math.floor(Math.random() * 400), 34600 + Math.floor(Math.random() * 300)]; + const cwd = mkTmpDir(); + + let lastErr: unknown; + for (const port of ports) { + const mgr = new DevServerManager(cwd, makeConfig(port)); + try { + expect(mgr.status().running).toBe(false); + + await mgr.up(); + + const st = mgr.status(); + expect(st.running).toBe(true); + expect(st.pid).toBeGreaterThan(0); + expect(st.port).toBe(port); + + const res = await fetch(`http://localhost:${port}/`); + expect(await res.text()).toBe("ok"); + + await mgr.down(); + expect(mgr.status().running).toBe(false); + + lastErr = undefined; + break; // success + } catch (err) { + lastErr = err; + await mgr.down().catch(() => {}); + // try the next port + } + } + + await rmDirWithRetry(cwd); + if (lastErr) throw lastErr; + }, 40_000); + + it("status() returns { running: false } when no state file exists", () => { + const cwd = mkTmpDir(); + try { + const mgr = new DevServerManager(cwd, makeConfig(34999)); + expect(mgr.status()).toEqual({ running: false }); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("up() is a no-op when disabled", async () => { + const cwd = mkTmpDir(); + try { + const cfg = { ...makeConfig(34998), enabled: false }; + const mgr = new DevServerManager(cwd, cfg); + await mgr.up(); + expect(mgr.status().running).toBe(false); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/ralph/src/devserver/manager.ts b/packages/ralph/src/devserver/manager.ts new file mode 100644 index 0000000..f1d9c92 --- /dev/null +++ b/packages/ralph/src/devserver/manager.ts @@ -0,0 +1,209 @@ +import fs from "node:fs"; +import { runShell, spawnDetached, killTree } from "../util/proc"; +import { ensureRalphDir, devServerStatePath, devServerLogPath } from "../util/paths"; +import { log } from "../util/logger"; +import type { DevServerConfig } from "../config/schema"; + +/** Persisted description of the running dev server, written to `.ralph/dev-server.json`. */ +export interface DevServerState { + pid: number; + port: number; + command: string; + startedAt: string; +} + +/** Milliseconds between readiness polls. */ +const POLL_INTERVAL_MS = 500; +/** Per-request abort timeout so a hanging connect can't stall the poll loop. */ +const FETCH_TIMEOUT_MS = 3000; +/** Number of trailing bytes of the dev-server log to include in error messages. */ +const LOG_TAIL_BYTES = 4000; + +/** + * Manages the lifecycle of the project's dev server: install (optional), spawn + * (detached, output redirected to a log file), readiness polling, and teardown. + * State is persisted to `.ralph/dev-server.json` so `down()`/`status()` work + * across process boundaries (e.g. a later CLI invocation). + */ +export class DevServerManager { + constructor( + private readonly cwd: string, + private readonly config: DevServerConfig, + ) {} + + /** Environment for install + server: process env, config overrides, then PORT/DEV_PORT. */ + private mergedEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + ...this.config.env, + PORT: String(this.config.port), + DEV_PORT: String(this.config.port), + }; + } + + /** + * Start the dev server (idempotent). Runs the optional install command, spawns + * the server detached, records state, then blocks until the readiness endpoint + * answers with status < 500. Throws (after tearing down) if install fails or + * readiness times out; the error includes the tail of the dev-server log. + */ + async up(): Promise { + if (!this.config.enabled) { + log.dim("Dev server disabled in config; skipping."); + return; + } + + if (this.status().running) { + log.dim("Dev server already running; reusing existing process."); + return; + } + + if (this.config.installCommand) { + log.step(`Installing dev-server deps: ${this.config.installCommand}`); + const res = await runShell(this.config.installCommand, { + cwd: this.cwd, + env: this.mergedEnv(), + timeoutMs: 600_000, + }); + if (res.code !== 0) { + throw new Error( + `Dev-server install command failed (exit ${res.code ?? "signal " + res.signal}): ` + + `${this.config.installCommand}\n${res.combined}`, + ); + } + } + + ensureRalphDir(this.cwd); + const logFile = devServerLogPath(this.cwd); + log.step(`Starting dev server on port ${this.config.port}: ${this.config.command}`); + const pid = spawnDetached(this.config.command, { + cwd: this.cwd, + env: this.mergedEnv(), + logFile, + }); + + const state: DevServerState = { + pid, + port: this.config.port, + command: this.config.command, + startedAt: new Date().toISOString(), + }; + fs.writeFileSync(devServerStatePath(this.cwd), JSON.stringify(state, null, 2)); + + const ready = await this.waitForReady(); + if (!ready) { + const tail = this.readLogTail(logFile); + await this.down(); + throw new Error( + `Dev server did not become ready within ${this.config.readyTimeoutMs}ms ` + + `(GET http://localhost:${this.config.port}${this.config.readinessPath}).\n` + + `--- dev-server.log (tail) ---\n${tail}`, + ); + } + log.success(`Dev server ready on port ${this.config.port}.`); + } + + /** + * Stop the dev server if running and remove the state file. Idempotent: does + * nothing (and never throws) when no server is recorded. + */ + async down(): Promise { + const state = this.readState(); + if (state && this.isAlive(state.pid)) { + await killTree(state.pid); + } + try { + fs.rmSync(devServerStatePath(this.cwd), { force: true }); + } catch { + /* best-effort cleanup */ + } + } + + /** Stop then start the dev server. */ + async restart(): Promise { + await this.down(); + await this.up(); + } + + /** + * Report whether a dev server is currently running, based on the recorded + * state file and OS-level process liveness. Stale state (dead pid) is treated + * as not running and best-effort removed. + */ + status(): { running: boolean; pid?: number; port?: number } { + const state = this.readState(); + if (!state) return { running: false }; + if (this.isAlive(state.pid)) { + return { running: true, pid: state.pid, port: state.port }; + } + // Stale state: process is gone. Clean it up so future calls are consistent. + try { + fs.rmSync(devServerStatePath(this.cwd), { force: true }); + } catch { + /* ignore */ + } + return { running: false }; + } + + /** Read + parse the state file, returning undefined if absent or corrupt. */ + private readState(): DevServerState | undefined { + try { + const raw = fs.readFileSync(devServerStatePath(this.cwd), "utf8"); + return JSON.parse(raw) as DevServerState; + } catch { + return undefined; + } + } + + /** True if the pid refers to a live process (signal 0 probe). */ + private isAlive(pid: number): boolean { + if (!pid || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + + /** Poll the readiness endpoint until it answers (status < 500) or we time out. */ + private async waitForReady(): Promise { + const url = `http://localhost:${this.config.port}${this.config.readinessPath}`; + const deadline = Date.now() + this.config.readyTimeoutMs; + while (Date.now() < deadline) { + if (await this.probe(url)) return true; + await sleep(POLL_INTERVAL_MS); + } + // One final probe in case the last sleep straddled the deadline. + return this.probe(url); + } + + /** Single readiness request with its own abort timeout. Returns true on status < 500. */ + private async probe(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { signal: controller.signal }); + return res.status < 500; + } catch { + return false; + } finally { + clearTimeout(timer); + } + } + + /** Read the last LOG_TAIL_BYTES of the dev-server log for error context. */ + private readLogTail(logFile: string): string { + try { + const buf = fs.readFileSync(logFile); + const start = Math.max(0, buf.length - LOG_TAIL_BYTES); + return buf.subarray(start).toString("utf8").trim() || "(log empty)"; + } catch { + return "(no dev-server.log found)"; + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/ralph/src/events/log.test.ts b/packages/ralph/src/events/log.test.ts new file mode 100644 index 0000000..d87d3ee --- /dev/null +++ b/packages/ralph/src/events/log.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventLog, appendEvent } from "./log"; +import type { RunEvent } from "./types"; +import { progressJsonlPath } from "../util/paths"; + +function makeEvent(iteration: number): RunEvent { + return { + type: "iteration_start", + ts: new Date().toISOString(), + iteration, + featureId: `f${iteration}`, + featureDescription: "desc", + attempt: 1, + }; +} + +describe("EventLog", () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-log-")); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("appends 3 events and reads them back parsed", () => { + const log = new EventLog(cwd); + log.append(makeEvent(1)); + log.append(makeEvent(2)); + log.append(makeEvent(3)); + + const events = log.read(); + expect(events).toHaveLength(3); + expect(events.map((e) => (e as any).iteration)).toEqual([1, 2, 3]); + expect(events.every((e) => e.type === "iteration_start")).toBe(true); + }); + + it("returns [] when the log file does not exist", () => { + expect(new EventLog(cwd).read()).toEqual([]); + expect(new EventLog(cwd).readRaw()).toBe(""); + }); + + it("skips malformed lines silently", () => { + const log = new EventLog(cwd); + log.append(makeEvent(1)); + // Hand-write a malformed line plus an empty line. + fs.appendFileSync(progressJsonlPath(cwd), "this is not json\n\n"); + log.append(makeEvent(2)); + + const events = log.read(); + expect(events).toHaveLength(2); + expect(events.map((e) => (e as any).iteration)).toEqual([1, 2]); + }); + + it("clear() empties the log", () => { + const log = new EventLog(cwd); + log.append(makeEvent(1)); + expect(log.read()).toHaveLength(1); + + log.clear(); + expect(fs.existsSync(progressJsonlPath(cwd))).toBe(false); + expect(log.read()).toEqual([]); + // clear() is idempotent when the file is already gone. + expect(() => log.clear()).not.toThrow(); + }); + + it("appendEvent convenience writes to the same log", () => { + appendEvent(cwd, makeEvent(7)); + const events = new EventLog(cwd).read(); + expect(events).toHaveLength(1); + expect((events[0] as any).iteration).toBe(7); + }); +}); diff --git a/packages/ralph/src/events/log.ts b/packages/ralph/src/events/log.ts new file mode 100644 index 0000000..90188ae --- /dev/null +++ b/packages/ralph/src/events/log.ts @@ -0,0 +1,63 @@ +import fs from "node:fs"; +import type { RunEvent } from "./types"; +import { RunEventSchema } from "./types"; +import { ensureRalphDir, progressJsonlPath } from "../util/paths"; + +/** + * Append-only telemetry log backing .ralph/progress.jsonl. Writers use the + * typed RunEvent union; reads are lenient so old/newer logs stay parseable. + * One JSON object per line makes appends crash-safe (a torn final line is + * simply skipped on read). + */ +export class EventLog { + constructor(private readonly cwd: string) {} + + /** Crash-safe append of a single event as one JSON line. */ + append(event: RunEvent): void { + ensureRalphDir(this.cwd); + fs.appendFileSync( + progressJsonlPath(this.cwd), + JSON.stringify(event) + "\n" + ); + } + + /** Parse all well-formed events; malformed lines are skipped silently. */ + read(): RunEvent[] { + const file = progressJsonlPath(this.cwd); + if (!fs.existsSync(file)) return []; + const raw = fs.readFileSync(file, "utf8"); + const events: RunEvent[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + const result = RunEventSchema.safeParse(parsed); + if (!result.success) continue; + events.push(result.data as unknown as RunEvent); + } + return events; + } + + /** Raw file contents ("" when the log does not exist). */ + readRaw(): string { + const file = progressJsonlPath(this.cwd); + if (!fs.existsSync(file)) return ""; + return fs.readFileSync(file, "utf8"); + } + + /** Delete the log (used by `ralph run --fresh`). */ + clear(): void { + const file = progressJsonlPath(this.cwd); + if (fs.existsSync(file)) fs.rmSync(file); + } +} + +/** Convenience one-shot append without holding an EventLog instance. */ +export function appendEvent(cwd: string, event: RunEvent): void { + new EventLog(cwd).append(event); +} diff --git a/packages/ralph/src/events/types.ts b/packages/ralph/src/events/types.ts new file mode 100644 index 0000000..73bcd19 --- /dev/null +++ b/packages/ralph/src/events/types.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import type { AgentUsage, Role } from "../adapters/types"; +import type { FeatureStatus } from "../features/schema"; + +/** + * Typed, append-only run events written to .ralph/progress.jsonl. Replaces the + * free-text progress.txt as the load-bearing record; `ralph status` renders it. + * Writers use the typed union; readers parse leniently (schema below) so old + * logs stay readable across versions. + */ + +export interface BaseEvent { + type: string; + ts: string; // ISO 8601 +} + +export interface RunStartEvent extends BaseEvent { + type: "run_start"; + runId: string; + featureCount: number; + roles: Record; + budgets?: { maxCostUsd?: number; maxIterations?: number; maxWallClockMinutes?: number }; +} + +export interface IterationStartEvent extends BaseEvent { + type: "iteration_start"; + iteration: number; + featureId: string; + featureDescription: string; + attempt: number; +} + +export interface AgentResultEvent extends BaseEvent { + type: "agent_result"; + iteration: number; + role: Role; + featureId?: string; + claimedOutcome?: "implemented" | "partial" | "blocked"; + exitCode: number | null; + timedOut: boolean; + durationMs: number; + usage?: AgentUsage; +} + +export interface GateResultEvent extends BaseEvent { + type: "gate_result"; + iteration: number; + gate: string; + passed: boolean; + newFailures?: string[]; + detail: string; +} + +export interface VerifierResultEvent extends BaseEvent { + type: "verifier_result"; + iteration: number; + featureId: string; + verdict: "pass" | "fail" | "inconclusive"; + concerns: string[]; + durationMs: number; + usage?: AgentUsage; +} + +export interface FeatureTransitionEvent extends BaseEvent { + type: "feature_transition"; + featureId: string; + from: FeatureStatus; + to: FeatureStatus; + reason?: string; +} + +export interface CheckpointEvent extends BaseEvent { + type: "checkpoint"; + iteration: number; + sha: string; +} + +export interface RevertEvent extends BaseEvent { + type: "revert"; + iteration: number; + toSha: string; + reason: string; +} + +export interface BudgetEvent extends BaseEvent { + type: "budget"; + metric: "cost" | "iterations" | "time"; + spent: number; + limit: number; + halted: boolean; +} + +export interface StallEvent extends BaseEvent { + type: "stall"; + iterationsWithoutProgress: number; + action: "notify" | "replan" | "halt"; +} + +export interface ReplanEvent extends BaseEvent { + type: "replan"; + iteration: number; + operations: string[]; + summary?: string; +} + +export interface NotifyEvent extends BaseEvent { + type: "notify"; + event: string; + message: string; + sink: string; +} + +export interface HaltEvent extends BaseEvent { + type: "halt"; + reason: string; +} + +export interface RunEndEvent extends BaseEvent { + type: "run_end"; + reason: string; + verified: number; + passed: number; + blocked: number; + total: number; + durationMs: number; + totalCostUsd?: number; +} + +export type RunEvent = + | RunStartEvent + | IterationStartEvent + | AgentResultEvent + | GateResultEvent + | VerifierResultEvent + | FeatureTransitionEvent + | CheckpointEvent + | RevertEvent + | BudgetEvent + | StallEvent + | ReplanEvent + | NotifyEvent + | HaltEvent + | RunEndEvent; + +/** Lenient reader schema — tolerates unknown/newer event shapes. */ +export const RunEventSchema = z + .object({ type: z.string(), ts: z.string() }) + .passthrough(); + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/ralph/src/features/dag.test.ts b/packages/ralph/src/features/dag.test.ts new file mode 100644 index 0000000..db160fd --- /dev/null +++ b/packages/ralph/src/features/dag.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from "vitest"; +import { FeatureFile, Feature } from "./schema"; +import { + validateDag, + selectNextEligible, + eligibleFeatures, + summarizeRemaining, +} from "./dag"; + +function feat(partial: Partial & { id: string }): Feature { + return { + id: partial.id, + category: partial.category ?? "feature", + priority: partial.priority ?? 0, + description: partial.description ?? `desc ${partial.id}`, + steps: partial.steps ?? [], + depends_on: partial.depends_on ?? [], + status: partial.status ?? "pending", + attempts: partial.attempts ?? 0, + blocked_reason: partial.blocked_reason ?? null, + verification: partial.verification ?? null, + lease: partial.lease ?? null, + }; +} + +function file(features: Feature[]): FeatureFile { + return { version: 2, features }; +} + +describe("validateDag", () => { + it("accepts a clean graph", () => { + const f = file([ + feat({ id: "a" }), + feat({ id: "b", depends_on: ["a"] }), + feat({ id: "c", depends_on: ["a", "b"] }), + ]); + expect(validateDag(f)).toEqual({ ok: true, errors: [] }); + }); + + it("flags duplicate ids", () => { + const f = file([feat({ id: "a" }), feat({ id: "a" })]); + const res = validateDag(f); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.includes("Duplicate"))).toBe(true); + }); + + it("flags unknown dependencies", () => { + const f = file([feat({ id: "a", depends_on: ["ghost"] })]); + const res = validateDag(f); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.includes("ghost"))).toBe(true); + }); + + it("detects a cycle A->B->A", () => { + const f = file([ + feat({ id: "a", depends_on: ["b"] }), + feat({ id: "b", depends_on: ["a"] }), + ]); + const res = validateDag(f); + expect(res.ok).toBe(false); + expect(res.errors.some((e) => e.toLowerCase().includes("cycle"))).toBe(true); + }); + + it("detects a longer cycle A->B->C->A", () => { + const f = file([ + feat({ id: "a", depends_on: ["c"] }), + feat({ id: "b", depends_on: ["a"] }), + feat({ id: "c", depends_on: ["b"] }), + ]); + const res = validateDag(f); + expect(res.ok).toBe(false); + expect(res.errors.filter((e) => e.toLowerCase().includes("cycle")).length).toBe(1); + }); +}); + +describe("selectNextEligible / eligibleFeatures", () => { + it("does not select a pending feature whose dep is still pending", () => { + const f = file([ + feat({ id: "dep", status: "pending" }), + feat({ id: "x", depends_on: ["dep"] }), + ]); + expect(selectNextEligible(f, "verified")).not.toBeNull(); + // The only selectable one is "dep" (no deps); "x" is blocked by dep. + expect(selectNextEligible(f, "verified")!.id).toBe("dep"); + }); + + it("selects a feature once its dep becomes verified", () => { + const f = file([ + feat({ id: "dep", status: "verified" }), + feat({ id: "x", depends_on: ["dep"] }), + ]); + expect(selectNextEligible(f, "verified")!.id).toBe("x"); + }); + + it("lowest priority wins, id tiebreak", () => { + const f = file([ + feat({ id: "b", priority: 5 }), + feat({ id: "a", priority: 5 }), + feat({ id: "c", priority: 1 }), + ]); + expect(selectNextEligible(f, "verified")!.id).toBe("c"); + const ids = eligibleFeatures(f, "verified").map((x) => x.id); + expect(ids).toEqual(["c", "a", "b"]); + }); + + it("unlockOn:passed lets a passed dep unlock", () => { + const f = file([ + feat({ id: "dep", status: "passed" }), + feat({ id: "x", depends_on: ["dep"] }), + ]); + // Under "verified": dep is "passed" (not pending, so not selectable) and x + // is still blocked because dep isn't verified -> nothing eligible. + expect(selectNextEligible(f, "verified")).toBeNull(); + expect(selectNextEligible(f, "passed")!.id).toBe("x"); // dep now counts done + }); + + it("returns null when nothing is eligible", () => { + const f = file([feat({ id: "a", status: "verified" })]); + expect(selectNextEligible(f, "verified")).toBeNull(); + }); +}); + +describe("summarizeRemaining", () => { + it("buckets features correctly", () => { + const f = file([ + feat({ id: "done", status: "verified" }), + feat({ id: "prog", status: "in_progress" }), + feat({ id: "blk", status: "blocked" }), + feat({ id: "elig", status: "pending" }), + feat({ id: "waiting", status: "pending", depends_on: ["elig"] }), + ]); + const s = summarizeRemaining(f, "verified"); + expect(s).toEqual({ + eligible: 1, // elig + pendingBlocked: 2, // blk + waiting + inProgress: 1, + done: 1, + }); + }); +}); diff --git a/packages/ralph/src/features/dag.ts b/packages/ralph/src/features/dag.ts new file mode 100644 index 0000000..ae51cab --- /dev/null +++ b/packages/ralph/src/features/dag.ts @@ -0,0 +1,169 @@ +import { Feature, FeatureFile, isDoneStatus } from "./schema"; + +export interface DagValidation { + ok: boolean; + errors: string[]; +} + +/** + * Structural validation of the feature DAG: duplicate ids, dangling + * dependencies, and cycles. Returns *all* problems found (not fail-fast) so a + * single validate() surfaces everything wrong with the file. + */ +export function validateDag(file: FeatureFile): DagValidation { + const errors: string[] = []; + const features = file.features; + + // Duplicate ids. + const seen = new Set(); + const duplicates = new Set(); + for (const f of features) { + if (seen.has(f.id)) duplicates.add(f.id); + seen.add(f.id); + } + for (const id of duplicates) { + errors.push(`Duplicate feature id: "${id}"`); + } + + // Known id set (deduplicated) for dependency resolution. + const ids = new Set(features.map((f) => f.id)); + + // Unknown dependencies. + for (const f of features) { + for (const dep of f.depends_on) { + if (!ids.has(dep)) { + errors.push(`Feature "${f.id}" depends on unknown id "${dep}"`); + } + } + } + + // Cycle detection via DFS over a first-wins adjacency map (ignores unknown + // deps, already reported above). + const adjacency = new Map(); + for (const f of features) { + if (!adjacency.has(f.id)) { + adjacency.set(f.id, f.depends_on.filter((d) => ids.has(d))); + } + } + + const WHITE = 0; + const GRAY = 1; + const BLACK = 2; + const color = new Map(); + for (const id of adjacency.keys()) color.set(id, WHITE); + const reportedCycles = new Set(); + + const visit = (id: string, stack: string[]): void => { + color.set(id, GRAY); + stack.push(id); + for (const dep of adjacency.get(id) ?? []) { + const c = color.get(dep) ?? WHITE; + if (c === GRAY) { + // Found a back-edge -> cycle. Extract the cycle slice from the stack. + const start = stack.indexOf(dep); + const cycle = stack.slice(start).concat(dep); + const key = canonicalCycleKey(cycle); + if (!reportedCycles.has(key)) { + reportedCycles.add(key); + errors.push(`Dependency cycle detected: ${cycle.join(" -> ")}`); + } + } else if (c === WHITE) { + visit(dep, stack); + } + } + stack.pop(); + color.set(id, BLACK); + }; + + for (const id of adjacency.keys()) { + if ((color.get(id) ?? WHITE) === WHITE) visit(id, []); + } + + return { ok: errors.length === 0, errors }; +} + +/** Rotation-independent key so the same cycle isn't reported twice. */ +function canonicalCycleKey(cycle: string[]): string { + // Drop the repeated closing node, rotate so the smallest id is first. + const nodes = cycle.slice(0, -1); + if (nodes.length === 0) return ""; + let minIdx = 0; + for (let i = 1; i < nodes.length; i++) { + if (nodes[i] < nodes[minIdx]) minIdx = i; + } + const rotated = nodes.slice(minIdx).concat(nodes.slice(0, minIdx)); + return rotated.join("->"); +} + +function isEligible( + feature: Feature, + byId: Map, + unlockOn: "verified" | "passed", +): boolean { + if (feature.status !== "pending") return false; + for (const dep of feature.depends_on) { + const depFeature = byId.get(dep); + if (!depFeature) return false; // unknown dep -> never eligible + if (!isDoneStatus(depFeature.status, unlockOn)) return false; + } + return true; +} + +function byIdMap(file: FeatureFile): Map { + const map = new Map(); + for (const f of file.features) { + if (!map.has(f.id)) map.set(f.id, f); // first-wins on duplicates + } + return map; +} + +/** Eligible features sorted by priority ascending, then id ascending. */ +export function eligibleFeatures( + file: FeatureFile, + unlockOn: "verified" | "passed", +): Feature[] { + const byId = byIdMap(file); + return file.features + .filter((f) => isEligible(f, byId, unlockOn)) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); +} + +/** Lowest-priority eligible pending feature (tiebreak id ascending), or null. */ +export function selectNextEligible( + file: FeatureFile, + unlockOn: "verified" | "passed", +): Feature | null { + const eligible = eligibleFeatures(file, unlockOn); + return eligible.length > 0 ? eligible[0] : null; +} + +export function summarizeRemaining( + file: FeatureFile, + unlockOn: "verified" | "passed", +): { eligible: number; pendingBlocked: number; inProgress: number; done: number } { + const byId = byIdMap(file); + let eligible = 0; + let pendingBlocked = 0; + let inProgress = 0; + let done = 0; + + for (const f of file.features) { + if (isDoneStatus(f.status, unlockOn)) { + done++; + } else if (f.status === "in_progress") { + inProgress++; + } else if (f.status === "blocked") { + pendingBlocked++; + } else if (f.status === "pending") { + if (isEligible(f, byId, unlockOn)) eligible++; + else pendingBlocked++; + } + // Note: "passed" when unlockOn === "verified" is neither done nor any of + // the above buckets; it is intentionally uncounted as remaining-work here. + } + + return { eligible, pendingBlocked, inProgress, done }; +} diff --git a/packages/ralph/src/features/migrate.test.ts b/packages/ralph/src/features/migrate.test.ts new file mode 100644 index 0000000..951ce3d --- /dev/null +++ b/packages/ralph/src/features/migrate.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { migrateV1, migrateV1File } from "./migrate"; +import { FeatureFileSchema } from "./schema"; + +describe("migrateV1", () => { + it("maps passes:true to verified with a migrated verification", () => { + const out = migrateV1({ + features: [ + { id: "1", priority: 1, description: "done thing", passes: true }, + ], + }); + const f = out.features[0]; + expect(f.status).toBe("verified"); + expect(f.verification).toMatchObject({ verdict: "pass", migrated: true }); + expect(typeof f.verification!.at).toBe("string"); + expect(f.category).toBe("feature"); + expect(f.steps).toEqual([]); + expect(f.depends_on).toEqual([]); + }); + + it("maps passes:false to pending with null verification", () => { + const out = migrateV1({ + features: [ + { id: "2", priority: 2, description: "todo thing", passes: false }, + ], + }); + const f = out.features[0]; + expect(f.status).toBe("pending"); + expect(f.verification).toBeNull(); + expect(f.attempts).toBe(0); + expect(f.lease).toBeNull(); + }); + + it("accepts a bare array input", () => { + const out = migrateV1([ + { id: "a", priority: 1, description: "x", passes: true }, + { id: "b", priority: 2, description: "y", passes: false, category: "chore", steps: ["s1"] }, + ]); + expect(out.version).toBe(2); + expect(out.features.length).toBe(2); + expect(out.features[1].category).toBe("chore"); + expect(out.features[1].steps).toEqual(["s1"]); + }); + + it("output validates against FeatureFileSchema", () => { + const out = migrateV1([{ id: "a", priority: 1, description: "x", passes: true }]); + expect(() => FeatureFileSchema.parse(out)).not.toThrow(); + }); + + it("throws on malformed v1 data", () => { + expect(() => migrateV1({ features: [{ id: "a" }] })).toThrow(); + }); +}); + +describe("migrateV1File", () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-migrate-")); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("reads src, writes dest, returns the file", () => { + const src = path.join(dir, "v1.json"); + const dest = path.join(dir, "features.json"); + fs.writeFileSync( + src, + JSON.stringify([{ id: "a", priority: 1, description: "x", passes: true }]), + "utf8", + ); + const out = migrateV1File(src, dest); + expect(out.version).toBe(2); + const onDisk = fs.readFileSync(dest, "utf8"); + expect(onDisk.endsWith("\n")).toBe(true); + expect(JSON.parse(onDisk)).toEqual(out); + }); +}); diff --git a/packages/ralph/src/features/migrate.ts b/packages/ralph/src/features/migrate.ts new file mode 100644 index 0000000..f13c772 --- /dev/null +++ b/packages/ralph/src/features/migrate.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import { z } from "zod"; +import { Feature, FeatureFile, FeatureFileSchema } from "./schema"; + +/** Shape of a legacy v1 feature entry (feature_list.json). */ +const V1FeatureSchema = z.object({ + id: z.union([z.string(), z.number()]).transform((v) => String(v)), + priority: z.number().int(), + category: z.string().optional(), + description: z.string(), + steps: z.array(z.string()).optional(), + passes: z.boolean(), +}); + +const V1FileSchema = z.union([ + z.object({ features: z.array(V1FeatureSchema) }), + z.array(V1FeatureSchema), +]); + +/** + * Convert legacy v1 feature data (either `{ features: [...] }` or a bare array) + * into a v2 FeatureFile. Passing v1 features are grandfathered to "verified" + * with a `migrated` verification stamp; the rest become fresh "pending". + */ +export function migrateV1(v1raw: unknown): FeatureFile { + const parsed = V1FileSchema.parse(v1raw); + const v1features = Array.isArray(parsed) ? parsed : parsed.features; + const now = new Date().toISOString(); + + const features: Feature[] = v1features.map((v1) => ({ + id: v1.id, + priority: v1.priority, + category: v1.category ?? "feature", + description: v1.description, + steps: v1.steps ?? [], + depends_on: [], + status: v1.passes ? "verified" : "pending", + attempts: 0, + blocked_reason: null, + verification: v1.passes + ? { verdict: "pass", at: now, migrated: true } + : null, + lease: null, + })); + + const file: FeatureFile = { version: 2, features }; + // Round-trip through the canonical schema so malformed v1 data surfaces here. + return FeatureFileSchema.parse(file); +} + +/** Read a v1 JSON file, migrate it, and write the v2 result to destPath. */ +export function migrateV1File(srcPath: string, destPath: string): FeatureFile { + const raw = fs.readFileSync(srcPath, "utf8"); + const file = migrateV1(JSON.parse(raw)); + fs.writeFileSync(destPath, JSON.stringify(file, null, 2) + "\n", "utf8"); + return file; +} diff --git a/packages/ralph/src/features/schema.ts b/packages/ralph/src/features/schema.ts new file mode 100644 index 0000000..6fc6d63 --- /dev/null +++ b/packages/ralph/src/features/schema.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +/** + * features.json v2 — the machine-readable contract for the loop. The agent + * never writes this file; the harness owns all transitions (enforced by the + * featureIntegrity gate). Parallel-ready from day one: depends_on drives DAG + * selection, and lease fields lie dormant until multi-track execution. + */ + +export const FEATURE_STATUSES = [ + "pending", + "in_progress", + "blocked", + "passed", // coder claim + mechanical gates green + "verified", // independent verifier confirmed (or grandfathered from v1) +] as const; + +export const FeatureStatusSchema = z.enum(FEATURE_STATUSES); +export type FeatureStatus = z.infer; + +export const StepResultSchema = z.object({ + step: z.string(), + ok: z.boolean(), + evidence: z.string().optional(), +}); +export type StepResult = z.infer; + +export const VerificationRecordSchema = z.object({ + verdict: z.enum(["pass", "fail", "inconclusive"]), + verifier: z.object({ adapter: z.string(), model: z.string().optional() }).optional(), + at: z.string(), + stepResults: z.array(StepResultSchema).optional(), + concerns: z.array(z.string()).optional(), + notes: z.string().optional(), + /** True when carried over from a v1 feature_list.json without re-verification. */ + migrated: z.boolean().optional(), +}); +export type VerificationRecord = z.infer; + +export const LeaseSchema = z.object({ + owner: z.string(), + acquired_at: z.string(), + expires_at: z.string(), +}); +export type Lease = z.infer; + +export const FeatureSchema = z.object({ + id: z.string().min(1), + category: z.string().default("feature"), + priority: z.number().int(), + description: z.string().min(1), + steps: z.array(z.string()).default([]), + depends_on: z.array(z.string()).default([]), + status: FeatureStatusSchema.default("pending"), + attempts: z.number().int().min(0).default(0), + blocked_reason: z.string().nullable().default(null), + verification: VerificationRecordSchema.nullable().default(null), + lease: LeaseSchema.nullable().default(null), +}); +export type Feature = z.infer; + +export const FeatureFileSchema = z.object({ + version: z.literal(2), + features: z.array(FeatureSchema), +}); +export type FeatureFile = z.infer; + +export function parseFeatureFile(raw: unknown): FeatureFile { + return FeatureFileSchema.parse(raw); +} + +/** Statuses that count a feature as "done" for dependency-unlock purposes. */ +export function isDoneStatus(status: FeatureStatus, unlockOn: "verified" | "passed"): boolean { + if (unlockOn === "passed") return status === "passed" || status === "verified"; + return status === "verified"; +} diff --git a/packages/ralph/src/features/store.test.ts b/packages/ralph/src/features/store.test.ts new file mode 100644 index 0000000..26184bc --- /dev/null +++ b/packages/ralph/src/features/store.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { FeatureStore } from "./store"; +import { FeatureFile } from "./schema"; + +function sampleFile(): FeatureFile { + return { + version: 2, + features: [ + { + id: "a", + category: "feature", + priority: 1, + description: "first", + steps: [], + depends_on: [], + status: "pending", + attempts: 0, + blocked_reason: null, + verification: null, + lease: null, + }, + { + id: "b", + category: "feature", + priority: 2, + description: "second", + steps: [], + depends_on: ["a"], + status: "pending", + attempts: 0, + blocked_reason: null, + verification: null, + lease: null, + }, + ], + }; +} + +let dir: string; +let filePath: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-store-")); + filePath = path.join(dir, "features.json"); + fs.writeFileSync(filePath, JSON.stringify(sampleFile(), null, 2) + "\n", "utf8"); +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("FeatureStore", () => { + it("loads, gets, and counts", () => { + const store = new FeatureStore(filePath); + expect(store.all().length).toBe(2); + expect(store.get("a")!.description).toBe("first"); + expect(store.get("missing")).toBeUndefined(); + const c = store.counts(); + expect(c.total).toBe(2); + expect(c.pending).toBe(2); + expect(c.verified).toBe(0); + }); + + it("transition updates status and persists to disk", () => { + const store = new FeatureStore(filePath); + store.transition("a", "verified"); + expect(store.get("a")!.status).toBe("verified"); + // Fresh store reads persisted value. + const reread = new FeatureStore(filePath); + expect(reread.get("a")!.status).toBe("verified"); + }); + + it("snapshotHash is stable across a save of identical content", () => { + const store = new FeatureStore(filePath); + const before = store.snapshotHash(); + store.load(); + store.save(); // identical content + expect(store.snapshotHash()).toBe(before); + }); + + it("snapshotHash changes when a status flips", () => { + const store = new FeatureStore(filePath); + const before = store.snapshotHash(); + store.transition("a", "verified"); + expect(store.snapshotHash()).not.toBe(before); + }); + + it("transition to blocked records reason", () => { + const store = new FeatureStore(filePath); + store.transition("b", "blocked", { reason: "dep failed" }); + expect(store.get("b")!.status).toBe("blocked"); + expect(store.get("b")!.blocked_reason).toBe("dep failed"); + }); + + it("incrementAttempts and setAttempts work", () => { + const store = new FeatureStore(filePath); + store.transition("a", "in_progress", { incrementAttempts: true }); + expect(store.get("a")!.attempts).toBe(1); + store.transition("a", "in_progress", { incrementAttempts: true }); + expect(store.get("a")!.attempts).toBe(2); + store.transition("a", "pending", { setAttempts: 0 }); + expect(store.get("a")!.attempts).toBe(0); + }); + + it("records and clears verification", () => { + const store = new FeatureStore(filePath); + store.transition("a", "verified", { + verification: { verdict: "pass", at: "2026-01-01T00:00:00.000Z" }, + }); + expect(store.get("a")!.verification!.verdict).toBe("pass"); + store.transition("a", "pending", { verification: null }); + expect(store.get("a")!.verification).toBeNull(); + }); + + it("nextEligible and validate delegate to the dag", () => { + const store = new FeatureStore(filePath); + expect(store.nextEligible("verified")!.id).toBe("a"); + expect(store.validate().ok).toBe(true); + }); + + it("throws on transition of unknown id", () => { + const store = new FeatureStore(filePath); + expect(() => store.transition("nope", "verified")).toThrow(/not found/i); + }); +}); diff --git a/packages/ralph/src/features/store.ts b/packages/ralph/src/features/store.ts new file mode 100644 index 0000000..1983d66 --- /dev/null +++ b/packages/ralph/src/features/store.ts @@ -0,0 +1,154 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; +import { + Feature, + FeatureFile, + FeatureStatus, + FEATURE_STATUSES, + VerificationRecord, + parseFeatureFile, +} from "./schema"; +import { + DagValidation, + selectNextEligible, + validateDag, +} from "./dag"; + +export interface TransitionMeta { + reason?: string; + verification?: VerificationRecord | null; + setAttempts?: number; + incrementAttempts?: boolean; + lease?: Feature["lease"]; +} + +/** + * Read/modify/persist wrapper around a features.json file. The harness owns all + * transitions; the agent never writes this file. Serialization is deterministic + * (`JSON.stringify(file, null, 2) + "\n"`) so the featureIntegrity gate can hash + * the on-disk bytes before/after each turn and detect illicit edits. + */ +export class FeatureStore { + private readonly absFilePath: string; + private cache: FeatureFile | null = null; + + constructor(absFilePath: string) { + this.absFilePath = absFilePath; + } + + /** Path this store reads/writes. */ + get path(): string { + return this.absFilePath; + } + + /** Load from disk if not already cached; otherwise return the cache. */ + load(): FeatureFile { + if (this.cache === null) return this.reload(); + return this.cache; + } + + /** Force a fresh read from disk, replacing the cache. */ + reload(): FeatureFile { + const raw = fs.readFileSync(this.absFilePath, "utf8"); + this.cache = parseFeatureFile(JSON.parse(raw)); + return this.cache; + } + + private serialize(file: FeatureFile): string { + return JSON.stringify(file, null, 2) + "\n"; + } + + /** Write the cached file to disk with deterministic formatting. */ + save(): void { + const file = this.load(); + fs.writeFileSync(this.absFilePath, this.serialize(file), "utf8"); + } + + /** sha256 (hex) of the current on-disk bytes — read fresh, not the cache. */ + snapshotHash(): string { + const bytes = fs.readFileSync(this.absFilePath); + return crypto.createHash("sha256").update(bytes).digest("hex"); + } + + get(id: string): Feature | undefined { + return this.load().features.find((f) => f.id === id); + } + + /** Like get() but throws if the id is unknown. */ + getRequired(id: string): Feature { + const feature = this.get(id); + if (!feature) throw new Error(`Feature not found: "${id}"`); + return feature; + } + + all(): Feature[] { + return this.load().features; + } + + nextEligible(unlockOn: "verified" | "passed"): Feature | null { + return selectNextEligible(this.load(), unlockOn); + } + + validate(): DagValidation { + return validateDag(this.load()); + } + + /** + * Mutate a single feature in the cache and persist. Only the fields implied + * by `meta` are touched; everything else is preserved so serialization stays + * stable. + */ + transition(id: string, to: FeatureStatus, meta: TransitionMeta = {}): void { + const feature = this.getRequired(id); + + feature.status = to; + + if (to === "blocked") { + feature.blocked_reason = meta.reason ?? feature.blocked_reason; + } + + if (meta.verification !== undefined) { + feature.verification = meta.verification; + } + + if (typeof meta.setAttempts === "number") { + feature.attempts = meta.setAttempts; + } + if (meta.incrementAttempts) { + feature.attempts += 1; + } + + if (meta.lease !== undefined) { + feature.lease = meta.lease; + } + + this.save(); + } + + /** + * Replace the entire feature set (used by the replanner). Validates schema + + * DAG before persisting; throws on invalid input so a bad replan is rejected + * rather than corrupting the contract. + */ + replaceAll(file: FeatureFile): void { + const parsed = parseFeatureFile(file); + const dag = validateDag(parsed); + if (!dag.ok) { + throw new Error(`replan produced an invalid feature DAG: ${dag.errors.join("; ")}`); + } + this.cache = parsed; + this.save(); + } + + /** Count features by status, plus a total. */ + counts(): Record & { total: number } { + const result = {} as Record & { total: number }; + for (const status of FEATURE_STATUSES) result[status] = 0; + result.total = 0; + for (const f of this.load().features) { + result[f.status] += 1; + result.total += 1; + } + return result; + } +} diff --git a/packages/ralph/src/garden/gardener.ts b/packages/ralph/src/garden/gardener.ts new file mode 100644 index 0000000..56fa9c0 --- /dev/null +++ b/packages/ralph/src/garden/gardener.ts @@ -0,0 +1,77 @@ +import type { GateContext } from "../gates/types"; +import type { BaselineSnapshot } from "../gates/types"; +import { renderPrompt, type GardenerPromptContext } from "../prompts/render"; +import { stageAll, stagedFiles, stagedDiffStat } from "../util/git"; +import { ensureCheckpoint, rollback, acceptCommit } from "../run/checkpoint"; +import { nowIso } from "../events/types"; +import { log } from "../util/logger"; +import type { RunContext } from "../run/types"; + +/** + * Periodic "gardening" pass (OpenAI harness-engineering pattern) to fight the + * dominant failure mode of Ralph loops — entropy / "AI slop". Runs a full- + * permission cleanup agent, then gates it exactly like a coding turn: accept + * (commit) if the mechanical gates pass, hard-revert otherwise. It touches no + * feature status and runs no verifier. + */ +export async function runGarden( + ctx: RunContext, + baseline: BaselineSnapshot, +): Promise<{ committed: boolean; detail: string }> { + const { cwd, config, store, eventLog, stateStore, state } = ctx; + const iteration = state.iteration; + + const checkpointSha = await ensureCheckpoint(cwd, `garden ${iteration}`); + const featuresHashBefore = store.snapshotHash(); + + const context: GardenerPromptContext = { + projectName: ctx.projectName, + specDir: config.specDir, + }; + const prompt = renderPrompt("gardener", context, { cwd, specDir: config.specDir }); + + log.step(`Gardening pass (entropy cleanup) at iteration ${iteration}…`); + const res = await ctx.gardener.adapter.invoke({ + prompt, + cwd, + role: "gardener", + model: ctx.gardener.role.model, + permissionTier: ctx.gardener.role.permissionTier, + timeoutMs: ctx.agentTimeoutMs, + onOutput: ctx.stream ? (c) => log.raw(c) : undefined, + }); + stateStore.addUsage(state, "gardener", res.usage, res.durationMs); + + await stageAll(cwd); + const changedFiles = await stagedFiles(cwd); + const diffStat = await stagedDiffStat(cwd); + const featuresHashAfter = store.snapshotHash(); + + if (changedFiles.length === 0) { + return { committed: false, detail: "no changes" }; + } + + const gateCtx: GateContext = { + cwd, + config, + featuresRelPath: ctx.featuresRelPath, + changedFiles, + diffStat, + featuresHashBefore, + featuresHashAfter, + baseline, + }; + for (const gate of ctx.gates) { + const result = await gate.run(gateCtx); + eventLog.append({ type: "gate_result", ts: nowIso(), iteration, gate: `garden:${result.gate}`, passed: result.passed, newFailures: result.newFailures, detail: result.detail }); + if (!result.passed) { + await rollback(cwd, checkpointSha); + log.warn(` gardening reverted (gate ${result.gate} failed)`); + return { committed: false, detail: `gate ${result.gate} failed` }; + } + } + + const sha = await acceptCommit(cwd, "ralph(garden): entropy cleanup"); + log.success(` gardening committed ${sha.slice(0, 8)} (${changedFiles.length} files)`); + return { committed: true, detail: `committed ${sha.slice(0, 8)}` }; +} diff --git a/packages/ralph/src/gates/baseline.ts b/packages/ralph/src/gates/baseline.ts new file mode 100644 index 0000000..890e904 --- /dev/null +++ b/packages/ralph/src/gates/baseline.ts @@ -0,0 +1,112 @@ +import { runShell } from "../util/proc"; +import type { RalphConfig, GateCommandConfig } from "../config/schema"; +import type { BaselineSnapshot } from "./types"; +import { emptyBaseline } from "./types"; + +/** + * Baseline capture + command evaluation for the command gates (typecheck/test/ + * build). Everything here is best-effort and deterministic: failure "signatures" + * are stable strings so that a set-diff against the baseline reveals only the + * failures a coding turn newly introduced. + */ + +export interface CommandEval { + passed: boolean; + failureCount: number; + failures: string[]; +} + +/** Run a configured gate command and parse its failure signatures. */ +export async function evaluateCommand( + command: string, + cwd: string, + timeoutMs: number, +): Promise { + const res = await runShell(command, { cwd, timeoutMs }); + const passed = res.code === 0; + if (passed) return { passed: true, failureCount: 0, failures: [] }; + // A failing command always yields at least the generic fallback signature, + // even when it emitted no parseable output (e.g. a bare non-zero exit). + let failures = parseFailures(command, res.combined); + if (failures.length === 0) failures = [`${command} exited non-zero`]; + return { passed: false, failureCount: failures.length, failures }; +} + +const TSC_ERROR_RE = /\S.*error TS\d+.*/; +// vitest/jest failing test lines, e.g. "× adds numbers" / "✕ adds" / "FAIL src/x". +const FAIL_TITLE_RE = /^\s*(?:[×✕✗]|FAIL|✖)\s+(.*\S)\s*$/; +// Summary line variants: "Tests: 1 failed, 3 passed" or "1 failed". +const SUMMARY_FAILED_RE = /(\d+)\s+failed/i; + +/** + * Best-effort, deterministic extraction of failure signatures from a gate + * command's combined output. Used both at baseline and post-turn; only the + * set-difference of signatures matters, so exact wording need not be perfect. + */ +export function parseFailures(command: string, output: string): string[] { + const cmd = command.toLowerCase(); + const text = output ?? ""; + + // --- tsc --------------------------------------------------------------- + if (cmd.includes("tsc")) { + const seen = new Set(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (TSC_ERROR_RE.test(line)) seen.add(line); + } + if (seen.size > 0) return [...seen]; + } + + // --- jest / vitest ----------------------------------------------------- + const looksLikeTestRunner = + cmd.includes("jest") || cmd.includes("vitest") || /\bTests:/.test(text); + if (looksLikeTestRunner) { + const titles = new Set(); + for (const raw of text.split(/\r?\n/)) { + const m = raw.match(FAIL_TITLE_RE); + if (m) titles.add(m[1].trim()); + } + if (titles.size > 0) return [...titles]; + + // No parseable titles — fall back to the summary "N failed" count and + // synthesize N generic signatures so the count is still meaningful. + const summary = text.match(SUMMARY_FAILED_RE); + if (summary) { + const n = Number.parseInt(summary[1], 10); + if (n > 0) { + return Array.from({ length: n }, (_, i) => `test-failure-${i + 1}`); + } + } + } + + // --- fallback ---------------------------------------------------------- + if (text.trim() === "") return []; + return [`${command} exited non-zero`]; +} + +/** + * Capture the pre-turn baseline for every ENABLED, baseline-relative command + * gate so the loop can later tell newly-introduced failures from pre-existing + * ones. Disabled gates (config === false) and non-baseline-relative gates are + * skipped. + */ +export async function captureBaseline(config: RalphConfig, cwd: string): Promise { + const snapshot = emptyBaseline(); + + const commandGates: Array<[string, GateCommandConfig | false]> = [ + ["typecheck", config.gates.typecheck], + ["test", config.gates.test], + ["build", config.gates.build], + ]; + + for (const [name, cfg] of commandGates) { + if (cfg === false) continue; + if (!cfg.baselineRelative) continue; + const evalResult = await evaluateCommand(cfg.command, cwd, cfg.timeoutMs); + snapshot.passed[name] = evalResult.passed; + snapshot.failureCounts[name] = evalResult.failureCount; + snapshot.failures[name] = evalResult.failures; + } + + return snapshot; +} diff --git a/packages/ralph/src/gates/command.ts b/packages/ralph/src/gates/command.ts new file mode 100644 index 0000000..0f58cbb --- /dev/null +++ b/packages/ralph/src/gates/command.ts @@ -0,0 +1,58 @@ +import type { GateCommandConfig } from "../config/schema"; +import type { Gate, GateContext, GateResult } from "./types"; +import { evaluateCommand } from "./baseline"; + +/** + * A command gate runs a configured subprocess (typecheck/test/build) and, when + * `baselineRelative`, only blocks on failure signatures that did not already + * exist at baseline — pre-existing failures are tolerated (hermes-agent pattern). + */ +export class CommandGate implements Gate { + readonly name: string; + private readonly cfg: GateCommandConfig; + + constructor(name: string, cfg: GateCommandConfig) { + this.name = name; + this.cfg = cfg; + } + + async run(ctx: GateContext): Promise { + const cur = await evaluateCommand(this.cfg.command, ctx.cwd, this.cfg.timeoutMs); + + if (!this.cfg.baselineRelative) { + const passed = cur.passed; + const newFailures = passed ? [] : cur.failures; + const detail = passed + ? `${this.name}: command exited 0` + : `${this.name}: command exited non-zero (${cur.failureCount} failure${ + cur.failureCount === 1 ? "" : "s" + })`; + return { gate: this.name, passed, newFailures, detail }; + } + + const baseFailures = ctx.baseline.failures[this.name] ?? []; + const newFailures = setDiff(cur.failures, baseFailures); + const passed = cur.passed || newFailures.length === 0; + const detail = passed + ? `${this.name}: no new failures (${cur.failureCount} current, ${baseFailures.length} at baseline)` + : `${this.name}: ${newFailures.length} new failure${ + newFailures.length === 1 ? "" : "s" + } introduced (${cur.failureCount} current, ${baseFailures.length} at baseline)`; + + return { gate: this.name, passed, newFailures, detail }; + } +} + +/** Elements of `a` not present in `b` (order-preserving, de-duplicated). */ +export function setDiff(a: string[], b: string[]): string[] { + const bset = new Set(b); + const out: string[] = []; + const seen = new Set(); + for (const x of a) { + if (!bset.has(x) && !seen.has(x)) { + seen.add(x); + out.push(x); + } + } + return out; +} diff --git a/packages/ralph/src/gates/diffSize.ts b/packages/ralph/src/gates/diffSize.ts new file mode 100644 index 0000000..5bc016d --- /dev/null +++ b/packages/ralph/src/gates/diffSize.ts @@ -0,0 +1,30 @@ +import type { DiffGateConfig } from "../config/schema"; +import type { Gate, GateContext, GateResult } from "./types"; + +/** + * Bounds the size of a single iteration's diff so a runaway turn can't rewrite + * the world. Purely reads the diff stat the loop precomputed — never calls git. + */ +export class DiffSizeGate implements Gate { + readonly name = "diff"; + private readonly cfg: DiffGateConfig; + + constructor(cfg: DiffGateConfig) { + this.cfg = cfg; + } + + async run(ctx: GateContext): Promise { + const { files, insertions, deletions } = ctx.diffStat; + const lines = insertions + deletions; + const passed = files <= this.cfg.maxFiles && lines <= this.cfg.maxLines; + const detail = `diff: ${files} file${files === 1 ? "" : "s"} (limit ${ + this.cfg.maxFiles + }), ${lines} line${lines === 1 ? "" : "s"} changed (limit ${this.cfg.maxLines})`; + return { + gate: this.name, + passed, + newFailures: passed ? [] : [detail], + detail, + }; + } +} diff --git a/packages/ralph/src/gates/featureIntegrity.ts b/packages/ralph/src/gates/featureIntegrity.ts new file mode 100644 index 0000000..aa94184 --- /dev/null +++ b/packages/ralph/src/gates/featureIntegrity.ts @@ -0,0 +1,29 @@ +import type { Gate, GateContext, GateResult } from "./types"; + +/** + * The harness owns features.json (the source of truth for feature state). + * Agents must never edit it; this gate fails the iteration if its hash changed + * during the coding turn. + */ +export class FeatureIntegrityGate implements Gate { + readonly name = "featureIntegrity"; + + async run(ctx: GateContext): Promise { + const passed = ctx.featuresHashBefore === ctx.featuresHashAfter; + if (passed) { + return { + gate: this.name, + passed: true, + newFailures: [], + detail: "features.json unchanged", + }; + } + return { + gate: this.name, + passed: false, + newFailures: ["features.json modified"], + detail: + "features.json was modified during the coding turn — the harness owns this file; agents must not edit it.", + }; + } +} diff --git a/packages/ralph/src/gates/gates.test.ts b/packages/ralph/src/gates/gates.test.ts new file mode 100644 index 0000000..0200bac --- /dev/null +++ b/packages/ralph/src/gates/gates.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { defaultConfig, parseConfig } from "../config/schema"; +import { emptyBaseline } from "./types"; +import type { GateContext } from "./types"; +import * as baseline from "./baseline"; +import { parseFailures, evaluateCommand } from "./baseline"; +import { CommandGate, setDiff } from "./command"; +import { DiffSizeGate } from "./diffSize"; +import { FeatureIntegrityGate } from "./featureIntegrity"; +import { buildGates } from "./index"; + +function ctx(overrides: Partial = {}): GateContext { + const config = defaultConfig(); + return { + cwd: process.cwd(), + config, + featuresRelPath: "features.json", + changedFiles: [], + diffStat: { files: 0, insertions: 0, deletions: 0 }, + featuresHashBefore: "h", + featuresHashAfter: "h", + baseline: emptyBaseline(), + ...overrides, + }; +} + +describe("parseFailures", () => { + it("extracts unique tsc error signatures", () => { + const out = [ + "src/a.ts(1,2): error TS2304: Cannot find name 'foo'.", + "src/b.ts(3,4): error TS2345: Argument of type X.", + "Found 2 errors.", + ].join("\n"); + expect(parseFailures("npx tsc --noEmit", out)).toEqual([ + "src/a.ts(1,2): error TS2304: Cannot find name 'foo'.", + "src/b.ts(3,4): error TS2345: Argument of type X.", + ]); + }); + + it("dedupes identical tsc error lines", () => { + const line = "src/a.ts(1,2): error TS2304: Cannot find name 'foo'."; + expect(parseFailures("tsc", `${line}\n${line}`)).toEqual([line]); + }); + + it("captures failing vitest test titles", () => { + const out = ["✓ passes ok", "× adds numbers", "× subtracts numbers"].join("\n"); + expect(parseFailures("vitest run", out)).toEqual(["adds numbers", "subtracts numbers"]); + }); + + it("synthesizes generic signatures from a summary count when titles missing", () => { + const out = "Tests: 1 failed, 3 passed, 4 total"; + expect(parseFailures("npm test", out)).toEqual(["test-failure-1"]); + }); + + it("synthesizes N signatures from a bare 'N failed' summary", () => { + const out = "Some noise\n2 failed\nmore noise"; + // "Tests:" not present + command is npm test -> not a runner unless summary present. + expect(parseFailures("vitest", out)).toEqual(["test-failure-1", "test-failure-2"]); + }); + + it("returns [] for empty/passing output", () => { + expect(parseFailures("npm test", "")).toEqual([]); + expect(parseFailures("tsc", "")).toEqual([]); + }); + + it("falls back to a generic signature for unknown non-empty output", () => { + expect(parseFailures("make check", "boom something broke")).toEqual([ + "make check exited non-zero", + ]); + }); +}); + +describe("setDiff", () => { + it("returns elements of a not in b, deduped and order-preserving", () => { + expect(setDiff(["A", "B", "B", "C"], ["A"])).toEqual(["B", "C"]); + expect(setDiff(["A"], ["A", "B"])).toEqual([]); + }); +}); + +describe("evaluateCommand", () => { + it("reports passed for exit 0", async () => { + const res = await evaluateCommand(`node -e "process.exit(0)"`, process.cwd(), 30_000); + expect(res.passed).toBe(true); + expect(res.failures).toEqual([]); + expect(res.failureCount).toBe(0); + }); + + it("reports a fallback failure signature for a non-tsc/non-runner command", async () => { + // Exit 2 (not 1) to sidestep the cross-spawn Windows shell quirk that + // misreports an exit code of 1 as ENOENT. Any non-zero code exercises the + // same failure path. + const res = await evaluateCommand( + `node -e "console.error('boom'); process.exit(2)"`, + process.cwd(), + 30_000, + ); + expect(res.passed).toBe(false); + expect(res.failureCount).toBe(1); + expect(res.failures[0]).toContain("exited non-zero"); + }); +}); + +describe("CommandGate (baseline-relative)", () => { + afterEach(() => vi.restoreAllMocks()); + const cfg = { command: "noop", baselineRelative: true, timeoutMs: 1000 }; + + it("blocks only newly-introduced failures", async () => { + vi.spyOn(baseline, "evaluateCommand").mockResolvedValue({ + passed: false, + failureCount: 2, + failures: ["A", "B"], + }); + const gate = new CommandGate("test", cfg); + const result = await gate.run( + ctx({ baseline: { passed: {}, failureCounts: {}, failures: { test: ["A"] } } }), + ); + expect(result.newFailures).toEqual(["B"]); + expect(result.passed).toBe(false); + }); + + it("tolerates pre-existing failures", async () => { + vi.spyOn(baseline, "evaluateCommand").mockResolvedValue({ + passed: false, + failureCount: 1, + failures: ["A"], + }); + const gate = new CommandGate("test", cfg); + const result = await gate.run( + ctx({ baseline: { passed: {}, failureCounts: {}, failures: { test: ["A"] } } }), + ); + expect(result.newFailures).toEqual([]); + expect(result.passed).toBe(true); + }); +}); + +describe("CommandGate (non-baseline)", () => { + it("passes on exit 0", async () => { + const gate = new CommandGate("typecheck", { + command: `node -e "process.exit(0)"`, + baselineRelative: false, + timeoutMs: 30_000, + }); + const r = await gate.run(ctx()); + expect(r.passed).toBe(true); + expect(r.newFailures).toEqual([]); + }); + + it("blocks on any failure", async () => { + const gate = new CommandGate("typecheck", { + command: `node -e "process.exit(2)"`, + baselineRelative: false, + timeoutMs: 30_000, + }); + const r = await gate.run(ctx()); + expect(r.passed).toBe(false); + expect(r.newFailures?.length).toBe(1); + expect(r.newFailures?.[0]).toContain("exited non-zero"); + }); +}); + +describe("DiffSizeGate", () => { + const gate = new DiffSizeGate({ maxFiles: 5, maxLines: 100 }); + + it("passes under thresholds", async () => { + const r = await gate.run(ctx({ diffStat: { files: 3, insertions: 40, deletions: 20 } })); + expect(r.passed).toBe(true); + }); + + it("fails over file threshold", async () => { + const r = await gate.run(ctx({ diffStat: { files: 6, insertions: 1, deletions: 0 } })); + expect(r.passed).toBe(false); + }); + + it("fails over line threshold", async () => { + const r = await gate.run(ctx({ diffStat: { files: 1, insertions: 90, deletions: 20 } })); + expect(r.passed).toBe(false); + }); +}); + +describe("FeatureIntegrityGate", () => { + const gate = new FeatureIntegrityGate(); + + it("passes when hashes match", async () => { + const r = await gate.run(ctx({ featuresHashBefore: "x", featuresHashAfter: "x" })); + expect(r.passed).toBe(true); + }); + + it("fails when hashes differ", async () => { + const r = await gate.run(ctx({ featuresHashBefore: "x", featuresHashAfter: "y" })); + expect(r.passed).toBe(false); + expect(r.newFailures).toEqual(["features.json modified"]); + }); +}); + +describe("buildGates", () => { + it("includes integrity + defaults in order", () => { + const gates = buildGates(defaultConfig()); + expect(gates.map((g) => g.name)).toEqual(["featureIntegrity", "diff", "typecheck", "test"]); + }); + + it("omits disabled gates but always keeps integrity", () => { + const config = parseConfig({ gates: { diff: false, typecheck: false, test: false } }); + const gates = buildGates(config); + expect(gates.map((g) => g.name)).toEqual(["featureIntegrity"]); + }); + + it("includes build when enabled", () => { + const config = parseConfig({ + gates: { build: { command: "npm run build" } }, + }); + expect(buildGates(config).map((g) => g.name)).toContain("build"); + }); +}); diff --git a/packages/ralph/src/gates/index.ts b/packages/ralph/src/gates/index.ts new file mode 100644 index 0000000..81c27ab --- /dev/null +++ b/packages/ralph/src/gates/index.ts @@ -0,0 +1,32 @@ +import type { RalphConfig } from "../config/schema"; +import type { Gate } from "./types"; +import { FeatureIntegrityGate } from "./featureIntegrity"; +import { DiffSizeGate } from "./diffSize"; +import { CommandGate } from "./command"; + +/** + * Build the ordered list of mechanical gates for a config. The feature-integrity + * gate is always present; every other gate can be disabled by setting its config + * to `false`. Order: featureIntegrity, diff, typecheck, test, build. + */ +export function buildGates(config: RalphConfig): Gate[] { + const gates: Gate[] = [new FeatureIntegrityGate()]; + + if (config.gates.diff !== false) { + gates.push(new DiffSizeGate(config.gates.diff)); + } + + const commandGates = ["typecheck", "test", "build"] as const; + for (const name of commandGates) { + const cfg = config.gates[name]; + if (cfg !== false) gates.push(new CommandGate(name, cfg)); + } + + return gates; +} + +export { captureBaseline, evaluateCommand, parseFailures } from "./baseline"; +export type { CommandEval } from "./baseline"; +export { CommandGate, setDiff } from "./command"; +export { DiffSizeGate } from "./diffSize"; +export { FeatureIntegrityGate } from "./featureIntegrity"; diff --git a/packages/ralph/src/gates/types.ts b/packages/ralph/src/gates/types.ts new file mode 100644 index 0000000..e49705e --- /dev/null +++ b/packages/ralph/src/gates/types.ts @@ -0,0 +1,53 @@ +import type { RalphConfig } from "../config/schema"; + +/** + * Mechanical gates run by the harness (not the agent) after each coding turn. + * The loop constructs a GateContext, runs each enabled gate, and reverts the + * iteration if any gate fails. + */ + +/** + * Per-gate baseline captured before the coder runs, so command gates can be + * "baseline-relative": a pre-existing failure does not block, only NEW failures + * introduced by this iteration do (hermes-agent pattern). + */ +export interface BaselineSnapshot { + /** gate name → whether its command passed (exit 0) at baseline. */ + passed: Record; + /** gate name → parsed failure count at baseline (best-effort). */ + failureCounts: Record; + /** gate name → parsed failure signatures at baseline (best-effort). */ + failures: Record; +} + +export function emptyBaseline(): BaselineSnapshot { + return { passed: {}, failureCounts: {}, failures: {} }; +} + +export interface GateContext { + cwd: string; + config: RalphConfig; + /** features.json path relative to cwd (for the integrity gate). */ + featuresRelPath: string; + /** Staged changed files (relative paths), computed by the loop. */ + changedFiles: string[]; + /** Staged diff stat vs the checkpoint, computed by the loop. */ + diffStat: { files: number; insertions: number; deletions: number }; + /** Hash of features.json at checkpoint and now (for the integrity gate). */ + featuresHashBefore: string; + featuresHashAfter: string; + baseline: BaselineSnapshot; +} + +export interface GateResult { + gate: string; + passed: boolean; + /** New failure signatures introduced this iteration (baseline-relative gates). */ + newFailures?: string[]; + detail: string; +} + +export interface Gate { + readonly name: string; + run(ctx: GateContext): Promise; +} diff --git a/packages/ralph/src/index.ts b/packages/ralph/src/index.ts new file mode 100644 index 0000000..939e8db --- /dev/null +++ b/packages/ralph/src/index.ts @@ -0,0 +1,9 @@ +// Public API surface for programmatic consumers (and the create-ralph-loop scaffolder). +// Populated as modules land; kept intentionally small. + +export const VERSION = "0.1.0"; + +export * from "./config/schema"; +export * from "./features/schema"; +export * from "./adapters/types"; +export * from "./events/types"; diff --git a/packages/ralph/src/notify/index.ts b/packages/ralph/src/notify/index.ts new file mode 100644 index 0000000..7e76cba --- /dev/null +++ b/packages/ralph/src/notify/index.ts @@ -0,0 +1,64 @@ +import type { NotificationSinkConfig } from "../config/schema"; +import { log } from "../util/logger"; + +/** + * Notification hub — lets the operator supervise by exception. Webhook sinks + * POST a generic JSON body (fields cover Slack `text`, Discord `content`, and + * plain `message` for ntfy.sh); desktop uses node-notifier if installed. + * Failures never interrupt the run. + */ +export class NotificationHub { + constructor(private readonly sinks: NotificationSinkConfig[]) {} + + get enabled(): boolean { + return this.sinks.length > 0; + } + + /** Deliver an event to every sink subscribed to it. Returns delivered sinks. */ + async notify(event: string, message: string): Promise { + const delivered: string[] = []; + for (const sink of this.sinks) { + if (sink.events && !sink.events.includes(event)) continue; + try { + if (sink.type === "webhook") { + await this.webhook(sink.url, event, message); + } else if (sink.type === "desktop") { + this.desktop(event, message); + } + delivered.push(sink.type); + } catch (e) { + log.warn(`notify ${sink.type} failed: ${(e as Error).message}`); + } + } + return delivered; + } + + private async webhook(url: string, event: string, message: string): Promise { + const title = `Ralph: ${event}`; + const body = JSON.stringify({ event, title, message, text: `${title}\n${message}`, content: `${title}\n${message}` }); + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 10_000); + try { + await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body, + signal: ctrl.signal, + }); + } finally { + clearTimeout(t); + } + } + + private desktop(event: string, message: string): void { + let notifier: { notify: (o: object) => void } | undefined; + try { + // Optional dependency; absent installs simply skip desktop toasts. + // eslint-disable-next-line @typescript-eslint/no-var-requires + notifier = require("node-notifier"); + } catch { + return; + } + notifier?.notify({ title: `Ralph: ${event}`, message }); + } +} diff --git a/packages/ralph/src/prompts/blocks.test.ts b/packages/ralph/src/prompts/blocks.test.ts new file mode 100644 index 0000000..65ec158 --- /dev/null +++ b/packages/ralph/src/prompts/blocks.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + extractBlock, + parseRalphResult, + parseRalphVerdict, + parseRalphPlanUpdate, +} from "./blocks"; + +describe("extractBlock", () => { + it("returns the inner content of a single block, trimmed", () => { + expect(extractBlock("before hi after", "t")).toBe("hi"); + }); + + it("returns the LAST occurrence when a block is restated", () => { + const text = "first noise second"; + expect(extractBlock(text, "t")).toBe("second"); + }); + + it("tolerates surrounding markdown and newlines", () => { + const text = "## heading\n\nsome prose\n\n\n{\"a\":1}\n\n\ndone"; + expect(extractBlock(text, "ralph-result")).toBe('{"a":1}'); + }); + + it("returns null when the tag is absent", () => { + expect(extractBlock("no tags here", "ralph-result")).toBeNull(); + }); + + it("is case-sensitive", () => { + expect(extractBlock("hi", "t")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(extractBlock("", "t")).toBeNull(); + }); +}); + +describe("parseRalphResult", () => { + it("parses a valid result block and applies defaults", () => { + const text = `blah\n{"feature":"FEAT-1","outcome":"implemented"}`; + const out = parseRalphResult(text); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.value.feature).toBe("FEAT-1"); + expect(out.value.outcome).toBe("implemented"); + expect(out.value.summary).toBe(""); + expect(out.value.blockers).toEqual([]); + } + }); + + it("parses a block wrapped in a ```json fence", () => { + const text = [ + "", + "```json", + '{"feature":"FEAT-2","outcome":"blocked","blockers":["db down"]}', + "```", + "", + ].join("\n"); + const out = parseRalphResult(text); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.value.outcome).toBe("blocked"); + expect(out.value.blockers).toEqual(["db down"]); + } + }); + + it("uses the LAST block when the agent restates", () => { + const text = + `{"feature":"A","outcome":"partial"}` + + `\n...\n` + + `{"feature":"B","outcome":"implemented"}`; + const out = parseRalphResult(text); + expect(out.ok).toBe(true); + if (out.ok) expect(out.value.feature).toBe("B"); + }); + + it("fails closed on malformed JSON", () => { + const text = `{feature: not json}`; + const out = parseRalphResult(text); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/invalid JSON/); + }); + + it("fails closed on a schema violation (bad outcome)", () => { + const text = `{"feature":"X","outcome":"done"}`; + const out = parseRalphResult(text); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/schema/); + }); + + it("fails closed when the block is absent", () => { + const out = parseRalphResult("nothing here"); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/missing/); + }); + + it("fails closed on empty input", () => { + expect(parseRalphResult("").ok).toBe(false); + }); +}); + +describe("parseRalphVerdict", () => { + it("parses a valid verdict with steps", () => { + const text = `{"verdict":"pass","steps":[{"step":"loads","ok":true}]}`; + const out = parseRalphVerdict(text); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.value.verdict).toBe("pass"); + expect(out.value.steps[0]).toEqual({ step: "loads", ok: true }); + expect(out.value.concerns).toEqual([]); + } + }); + + it("fails closed on a bad verdict value", () => { + const text = `{"verdict":"maybe"}`; + expect(parseRalphVerdict(text).ok).toBe(false); + }); +}); + +describe("parseRalphPlanUpdate", () => { + it("parses operations and passes through extra keys", () => { + const text = `{"operations":[{"op":"reprioritize","featureId":"F1","priority":2,"extra":"keep"}],"summary":"tidy"}`; + const out = parseRalphPlanUpdate(text); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.value.summary).toBe("tidy"); + expect(out.value.operations[0].op).toBe("reprioritize"); + expect((out.value.operations[0] as Record).extra).toBe("keep"); + } + }); + + it("defaults operations to an empty array", () => { + const text = `{"summary":"noop"}`; + const out = parseRalphPlanUpdate(text); + expect(out.ok).toBe(true); + if (out.ok) expect(out.value.operations).toEqual([]); + }); + + it("fails closed on an unknown op", () => { + const text = `{"operations":[{"op":"nuke"}]}`; + expect(parseRalphPlanUpdate(text).ok).toBe(false); + }); + + it("fails closed when absent", () => { + expect(parseRalphPlanUpdate("no block").ok).toBe(false); + }); +}); diff --git a/packages/ralph/src/prompts/blocks.ts b/packages/ralph/src/prompts/blocks.ts new file mode 100644 index 0000000..e096a7a --- /dev/null +++ b/packages/ralph/src/prompts/blocks.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; + +/** + * Structured block parsing for the loop's machine-readable agent handoffs. + * + * Agents end their output with a single tagged JSON block that the harness + * consumes. Parsing is FAIL-CLOSED: any malformed / missing / schema-invalid + * block yields `{ ok: false }` rather than throwing, so a chatty or broken + * agent can never crash the orchestrator or be mistaken for a success. + */ + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +/** Emitted by the coder agent at the end of an implementation attempt. */ +export const RalphResultSchema = z.object({ + feature: z.string(), + outcome: z.enum(["implemented", "partial", "blocked"]), + summary: z.string().default(""), + blockers: z.array(z.string()).default([]), + notes: z.string().optional(), +}); +export type RalphResult = z.infer; + +/** Emitted by the fresh-context verifier agent after re-checking the work. */ +export const RalphVerdictSchema = z.object({ + verdict: z.enum(["pass", "fail", "inconclusive"]), + steps: z + .array( + z.object({ + step: z.string(), + ok: z.boolean(), + evidence: z.string().optional(), + }) + ) + .default([]), + concerns: z.array(z.string()).default([]), + notes: z.string().optional(), +}); +export type RalphVerdict = z.infer; + +/** Emitted by the periodic replanner to mutate the plan (via the harness). */ +export const RalphPlanUpdateSchema = z.object({ + operations: z + .array( + z + .object({ + op: z.enum([ + "reprioritize", + "block", + "unblock", + "split", + "prune", + "add_dependency", + ]), + featureId: z.string().optional(), + priority: z.number().optional(), + reason: z.string().optional(), + dependsOn: z.string().optional(), + newFeatures: z.array(z.any()).optional(), + }) + .passthrough() + ) + .default([]), + summary: z.string().optional(), +}); +export type RalphPlanUpdate = z.infer; + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Return the inner content of the LAST ` ... ` occurrence in `text`. + * + * Agents may restate a block several times (e.g. once in reasoning, once at the + * end); the last one wins. Surrounding markdown / whitespace is tolerated. The + * tag match is case-sensitive. Returns null when the tag is absent. + */ +export function extractBlock(text: string, tag: string): string | null { + if (!text) return null; + const open = escapeRegExp(tag); + // Non-greedy body, case-sensitive, dot matches newlines. + const re = new RegExp(`<${open}>([\\s\\S]*?)<\\/${open}>`, "g"); + let match: RegExpExecArray | null; + let last: string | null = null; + while ((match = re.exec(text)) !== null) { + last = match[1]; + } + return last === null ? null : last.trim(); +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +export type ParseOutcome = { ok: true; value: T } | { ok: false; error: string }; + +/** + * Strip an optional ```json ... ``` (or bare ``` ... ```) fence and surrounding + * whitespace, leaving the raw JSON payload. + */ +function stripFences(raw: string): string { + let s = raw.trim(); + const fence = /^```[^\n]*\n([\s\S]*?)\n?```$/; + const m = fence.exec(s); + if (m) s = m[1].trim(); + return s; +} + +function parseBlock( + text: string, + tag: string, + schema: S +): ParseOutcome> { + const inner = extractBlock(text, tag); + if (inner === null) { + return { ok: false, error: `missing <${tag}> block` }; + } + const payload = stripFences(inner); + let json: unknown; + try { + json = JSON.parse(payload); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, error: `invalid JSON in <${tag}>: ${detail}` }; + } + const parsed = schema.safeParse(json); + if (!parsed.success) { + const first = parsed.error.issues[0]; + const where = first?.path.length ? ` at ${first.path.join(".")}` : ""; + return { + ok: false, + error: `schema validation failed for <${tag}>${where}: ${ + first?.message ?? "unknown error" + }`, + }; + } + return { ok: true, value: parsed.data }; +} + +export function parseRalphResult(text: string): ParseOutcome { + return parseBlock(text, "ralph-result", RalphResultSchema); +} + +export function parseRalphVerdict(text: string): ParseOutcome { + return parseBlock(text, "ralph-verdict", RalphVerdictSchema); +} + +export function parseRalphPlanUpdate(text: string): ParseOutcome { + return parseBlock(text, "ralph-plan-update", RalphPlanUpdateSchema); +} diff --git a/packages/ralph/src/prompts/render.test.ts b/packages/ralph/src/prompts/render.test.ts new file mode 100644 index 0000000..7e98632 --- /dev/null +++ b/packages/ralph/src/prompts/render.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Feature } from "../features/schema"; +import { + PROMPT_NAMES, + renderPrompt, + resolveTemplatePath, + type PromptName, +} from "./render"; + +const tmpDirs: string[] = []; + +function mkTmp(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-prompts-")); + tmpDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tmpDirs.length) { + const d = tmpDirs.pop()!; + fs.rmSync(d, { recursive: true, force: true }); + } +}); + +function sampleFeature(): Feature { + return { + id: "FEAT-042", + category: "feature", + priority: 1, + description: "Users can reset their password", + steps: ["Request a reset email", "Follow the link", "Set a new password"], + depends_on: [], + status: "pending", + attempts: 0, + blocked_reason: null, + verification: null, + lease: null, + }; +} + +describe("resolveTemplatePath", () => { + it("prefers a project override over the packaged default", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const overrideDir = path.join(cwd, specDir, "prompts"); + fs.mkdirSync(overrideDir, { recursive: true }); + fs.writeFileSync(path.join(overrideDir, "coding.md"), "override body"); + + const resolved = resolveTemplatePath("coding", { cwd, specDir }); + expect(resolved.source).toBe("override"); + expect(resolved.path).toBe(path.join(overrideDir, "coding.md")); + }); + + it("prefers a .eta override over a .md override", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const overrideDir = path.join(cwd, specDir, "prompts"); + fs.mkdirSync(overrideDir, { recursive: true }); + fs.writeFileSync(path.join(overrideDir, "coding.eta"), "eta body"); + fs.writeFileSync(path.join(overrideDir, "coding.md"), "md body"); + + const resolved = resolveTemplatePath("coding", { cwd, specDir }); + expect(resolved.path.endsWith("coding.eta")).toBe(true); + }); + + it("falls back to the packaged default when there is no override", () => { + const cwd = mkTmp(); + const resolved = resolveTemplatePath("coding", { cwd, specDir: "specs/phase1" }); + expect(resolved.source).toBe("default"); + expect(fs.existsSync(resolved.path)).toBe(true); + }); +}); + +describe("renderPrompt", () => { + it("renders the coding prompt with the injected feature", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const out = renderPrompt( + "coding", + { + projectName: "Acme", + projectDescription: "a widget shop", + devPort: 4321, + specDir, + feature: sampleFeature(), + iteration: 3, + attempt: 1, + }, + { cwd, specDir } + ); + expect(out).toContain("FEAT-042"); + expect(out).toContain("Users can reset their password"); + expect(out).toContain("4321"); + // steps rendered + expect(out).toContain("Request a reset email"); + // hard rule about features.json ownership + expect(out).toContain("features.json"); + }); + + it("renders the retry section only when previousFailure is present", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const base = { + projectName: "Acme", + devPort: 3000, + specDir, + feature: sampleFeature(), + iteration: 2, + attempt: 2, + }; + const without = renderPrompt("coding", base, { cwd, specDir }); + expect(without).not.toContain("Previous attempt failed"); + + const withFailure = renderPrompt( + "coding", + { + ...base, + previousFailure: { + gates: ["tsc"], + verifierConcerns: ["missing authz"], + detail: "The reset token was not validated.", + }, + }, + { cwd, specDir } + ); + expect(withFailure).toContain("Previous attempt failed"); + expect(withFailure).toContain("The reset token was not validated."); + }); + + it("uses a project override when present", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const overrideDir = path.join(cwd, specDir, "prompts"); + fs.mkdirSync(overrideDir, { recursive: true }); + fs.writeFileSync(path.join(overrideDir, "coding.md"), "OVERRIDE <%= it.projectName %>"); + + const out = renderPrompt( + "coding", + { projectName: "Zeta", devPort: 3000, specDir, feature: sampleFeature(), iteration: 1, attempt: 1 }, + { cwd, specDir } + ); + expect(out).toBe("OVERRIDE Zeta"); + }); + + it("renders every packaged default template with a minimal context without throwing", () => { + const cwd = mkTmp(); + const specDir = "specs/phase1"; + const ctx = { + projectName: "Acme", + projectDescription: "a widget shop", + devPort: 3000, + specDir, + feature: sampleFeature(), + iteration: 1, + attempt: 1, + diffSummary: "changed src/auth.ts", + featuresJson: '{"version":2,"features":[]}', + recentEvents: "nothing notable", + gitLog: "abc123 feat: x", + }; + for (const name of PROMPT_NAMES as PromptName[]) { + const out = renderPrompt(name, ctx, { cwd, specDir }); + expect(out.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/ralph/src/prompts/render.ts b/packages/ralph/src/prompts/render.ts new file mode 100644 index 0000000..0c6e384 --- /dev/null +++ b/packages/ralph/src/prompts/render.ts @@ -0,0 +1,130 @@ +import path from "node:path"; +import fs from "node:fs"; +import { Eta } from "eta"; +import type { Feature } from "../features/schema"; + +/** + * Prompt rendering: resolve a template (project override or packaged default) + * and render it with eta. eta exposes the context object as `it` inside the + * template (e.g. `<%= it.projectName %>`). + */ + +export type PromptName = + | "coding" + | "verifier" + | "replanner" + | "init" + | "prd" + | "gardener"; + +export const PROMPT_NAMES: readonly PromptName[] = [ + "coding", + "verifier", + "replanner", + "init", + "prd", + "gardener", +]; + +// --------------------------------------------------------------------------- +// Typed context interfaces (documentation of what each template may reference) +// --------------------------------------------------------------------------- + +export interface CodingPromptContext { + projectName: string; + projectDescription?: string; + devPort: number; + specDir: string; + feature: Feature; + iteration: number; + attempt: number; + previousFailure?: { + gates: string[]; + verifierConcerns: string[]; + detail: string; + }; + recentProgress?: string; +} + +export interface VerifierPromptContext { + projectName: string; + devPort: number; + specDir: string; + feature: Feature; + diffSummary: string; +} + +export interface ReplannerPromptContext { + specDir: string; + featuresJson: string; + recentEvents: string; + gitLog: string; +} + +/** Shared by the init and prd planning prompts. */ +export interface PlanPromptContext { + projectName: string; + projectDescription: string; + specDir: string; +} + +export interface GardenerPromptContext { + projectName: string; + specDir: string; + recentProgress?: string; +} + +// --------------------------------------------------------------------------- +// Template resolution + render +// --------------------------------------------------------------------------- + +export interface ResolveOpts { + cwd: string; + specDir: string; +} + +export interface ResolvedTemplate { + path: string; + source: "override" | "default"; +} + +/** Directory holding the packaged default templates. Sits at the package root + * in both the source tree (__dirname = /src/prompts) and the built output + * (__dirname = /dist/prompts), because assets/ is one level above each. */ +function packagedPromptsDir(): string { + return path.join(__dirname, "..", "..", "assets", "prompts"); +} + +/** + * Resolve the template file for `name`, preferring a project override in + * `${cwd}/${specDir}/prompts/${name}.eta` (then `.md`) over the packaged + * default at `assets/prompts/${name}.md`. + */ +export function resolveTemplatePath( + name: PromptName, + opts: ResolveOpts +): ResolvedTemplate { + const overrideDir = path.join(opts.cwd, opts.specDir, "prompts"); + for (const ext of [".eta", ".md"]) { + const candidate = path.join(overrideDir, `${name}${ext}`); + if (fs.existsSync(candidate)) { + return { path: candidate, source: "override" }; + } + } + return { path: path.join(packagedPromptsDir(), `${name}.md`), source: "default" }; +} + +/** + * Resolve, read and render the prompt named `name` with `context`. + * Returns the rendered string. Throws if no template can be found (a + * misconfiguration the caller should surface, not swallow). + */ +export function renderPrompt( + name: PromptName, + context: object, + opts: ResolveOpts +): string { + const resolved = resolveTemplatePath(name, opts); + const template = fs.readFileSync(resolved.path, "utf8"); + return new Eta().renderString(template, context); +} diff --git a/packages/ralph/src/replan/replanner.test.ts b/packages/ralph/src/replan/replanner.test.ts new file mode 100644 index 0000000..a257ac4 --- /dev/null +++ b/packages/ralph/src/replan/replanner.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { applyPlanUpdate } from "./replanner"; +import type { FeatureFile } from "../features/schema"; + +function file(): FeatureFile { + return { + version: 2, + features: [ + { id: "A", category: "feature", priority: 1, description: "a", steps: [], depends_on: [], status: "verified", attempts: 0, blocked_reason: null, verification: null, lease: null }, + { id: "B", category: "feature", priority: 2, description: "b", steps: [], depends_on: [], status: "pending", attempts: 3, blocked_reason: null, verification: null, lease: null }, + { id: "C", category: "feature", priority: 3, description: "c", steps: [], depends_on: [], status: "blocked", attempts: 2, blocked_reason: "x", verification: null, lease: null }, + ], + }; +} + +describe("applyPlanUpdate", () => { + it("reprioritizes a feature", () => { + const { applied, next } = applyPlanUpdate(file(), { operations: [{ op: "reprioritize", featureId: "B", priority: 9 }] }); + expect(applied).toContain("reprioritize B→9"); + expect(next.features.find((f) => f.id === "B")!.priority).toBe(9); + }); + + it("blocks a pending feature but never a verified one", () => { + const { applied, skipped, next } = applyPlanUpdate(file(), { + operations: [ + { op: "block", featureId: "B", reason: "descoped" }, + { op: "block", featureId: "A", reason: "should be ignored" }, + ], + }); + expect(next.features.find((f) => f.id === "B")!.status).toBe("blocked"); + expect(next.features.find((f) => f.id === "A")!.status).toBe("verified"); + expect(applied).toContain("block B"); + expect(skipped.join()).toMatch(/block A/); + }); + + it("unblocks and resets attempts", () => { + const { next } = applyPlanUpdate(file(), { operations: [{ op: "unblock", featureId: "C" }] }); + const c = next.features.find((f) => f.id === "C")!; + expect(c.status).toBe("pending"); + expect(c.attempts).toBe(0); + expect(c.blocked_reason).toBeNull(); + }); + + it("adds a dependency (existing target only)", () => { + const { next } = applyPlanUpdate(file(), { operations: [{ op: "add_dependency", featureId: "B", dependsOn: "A" }] }); + expect(next.features.find((f) => f.id === "B")!.depends_on).toContain("A"); + }); + + it("prunes non-verified features but protects verified", () => { + const { next, applied, skipped } = applyPlanUpdate(file(), { + operations: [ + { op: "prune", featureId: "C" }, + { op: "prune", featureId: "A" }, + ], + }); + expect(next.features.some((f) => f.id === "C")).toBe(false); + expect(next.features.some((f) => f.id === "A")).toBe(true); + expect(applied).toContain("prune C"); + expect(skipped.join()).toMatch(/prune A/); + }); + + it("splits in new valid features and rejects invalid ones", () => { + const { next, applied } = applyPlanUpdate(file(), { + operations: [ + { op: "split", newFeatures: [{ id: "D", priority: 5, description: "new" }, { id: "bad" /* no desc/priority */ }] }, + ], + }); + expect(next.features.some((f) => f.id === "D")).toBe(true); + expect(next.features.some((f) => f.id === "bad")).toBe(false); + expect(applied).toContain("split +1"); + }); +}); diff --git a/packages/ralph/src/replan/replanner.ts b/packages/ralph/src/replan/replanner.ts new file mode 100644 index 0000000..bb7d1c6 --- /dev/null +++ b/packages/ralph/src/replan/replanner.ts @@ -0,0 +1,150 @@ +import type { RunnerAdapter, AgentUsage } from "../adapters/types"; +import type { ResolvedRole } from "../config/schema"; +import { FeatureStore } from "../features/store"; +import type { FeatureFile, Feature } from "../features/schema"; +import { FeatureSchema } from "../features/schema"; +import { renderPrompt, type ReplannerPromptContext } from "../prompts/render"; +import { parseRalphPlanUpdate, type RalphPlanUpdate } from "../prompts/blocks"; + +/** + * Periodic self-improvement: a strong model reviews the plan + recent history + * and proposes a constrained set of operations (reprioritize / block / unblock / + * split / prune / add_dependency). The harness validates every operation against + * the schema and DAG invariants before applying; it never lets the replanner + * delete or downgrade a verified feature. + */ + +export interface ReplanOptions { + adapter: RunnerAdapter; + role: ResolvedRole; + cwd: string; + specDir: string; + store: FeatureStore; + gitLog: string; + recentEvents: string; + timeoutMs: number; + onOutput?: (chunk: string) => void; +} + +export interface ReplanResult { + applied: string[]; + skipped: string[]; + summary?: string; + usage?: AgentUsage; + durationMs: number; +} + +export async function runReplan(opts: ReplanOptions): Promise { + const current = opts.store.load(); + const context: ReplannerPromptContext = { + specDir: opts.specDir, + featuresJson: JSON.stringify(current, null, 2), + recentEvents: opts.recentEvents, + gitLog: opts.gitLog, + }; + const prompt = renderPrompt("replanner", context, { cwd: opts.cwd, specDir: opts.specDir }); + + const res = await opts.adapter.invoke({ + prompt, + cwd: opts.cwd, + role: "replanner", + model: opts.role.model, + permissionTier: "readonly", + timeoutMs: opts.timeoutMs, + onOutput: opts.onOutput, + }); + + const parsed = parseRalphPlanUpdate(res.rawOutput); + if (!parsed.ok) { + return { applied: [], skipped: [], summary: `unparseable plan update: ${parsed.error}`, usage: res.usage, durationMs: res.durationMs }; + } + + const { applied, skipped, next } = applyPlanUpdate(current, parsed.value); + if (applied.length > 0) { + try { + opts.store.replaceAll(next); // re-validates schema + DAG; throws if the result is invalid + } catch (e) { + return { applied: [], skipped: [...skipped, `rejected: ${(e as Error).message}`], summary: parsed.value.summary, usage: res.usage, durationMs: res.durationMs }; + } + } + return { applied, skipped, summary: parsed.value.summary, usage: res.usage, durationMs: res.durationMs }; +} + +/** Apply operations to a clone; verified features are protected. */ +export function applyPlanUpdate( + file: FeatureFile, + update: RalphPlanUpdate, +): { applied: string[]; skipped: string[]; next: FeatureFile } { + const next: FeatureFile = JSON.parse(JSON.stringify(file)); + const byId = (id?: string) => next.features.find((f) => f.id === id); + const applied: string[] = []; + const skipped: string[] = []; + + for (const op of update.operations) { + const target = byId(op.featureId); + try { + switch (op.op) { + case "reprioritize": + if (target && typeof op.priority === "number") { + target.priority = op.priority; + applied.push(`reprioritize ${target.id}→${op.priority}`); + } else skipped.push(`reprioritize (bad target/priority)`); + break; + case "block": + if (target && target.status !== "verified") { + target.status = "blocked"; + target.blocked_reason = op.reason ?? "blocked by replanner"; + applied.push(`block ${target.id}`); + } else skipped.push(`block ${op.featureId} (missing or verified)`); + break; + case "unblock": + if (target && target.status === "blocked") { + target.status = "pending"; + target.blocked_reason = null; + target.attempts = 0; + applied.push(`unblock ${target.id}`); + } else skipped.push(`unblock ${op.featureId} (not blocked)`); + break; + case "add_dependency": + if (target && op.dependsOn && byId(op.dependsOn) && !target.depends_on.includes(op.dependsOn)) { + target.depends_on.push(op.dependsOn); + applied.push(`add_dependency ${target.id}←${op.dependsOn}`); + } else skipped.push(`add_dependency (bad target/dep)`); + break; + case "prune": + if (target && target.status !== "verified") { + next.features = next.features.filter((f) => f.id !== target.id); + applied.push(`prune ${target.id}`); + } else skipped.push(`prune ${op.featureId} (missing or verified)`); + break; + case "split": + if (Array.isArray(op.newFeatures)) { + let added = 0; + for (const raw of op.newFeatures) { + const parsed = FeatureSchema.safeParse(normalizeNewFeature(raw)); + if (parsed.success && !byId(parsed.data.id)) { + next.features.push(parsed.data as Feature); + added++; + } + } + if (added) applied.push(`split +${added}`); + else skipped.push(`split (no valid new features)`); + } else skipped.push(`split (no newFeatures)`); + break; + default: + skipped.push(`unknown op ${(op as { op: string }).op}`); + } + } catch (e) { + skipped.push(`${op.op}: ${(e as Error).message}`); + } + } + + return { applied, skipped, next }; +} + +function normalizeNewFeature(raw: unknown): unknown { + if (raw && typeof raw === "object") { + return { status: "pending", attempts: 0, depends_on: [], steps: [], ...(raw as object) }; + } + return raw; +} diff --git a/packages/ralph/src/run/checkpoint.ts b/packages/ralph/src/run/checkpoint.ts new file mode 100644 index 0000000..50a6795 --- /dev/null +++ b/packages/ralph/src/run/checkpoint.ts @@ -0,0 +1,25 @@ +import { currentSha, isClean, commitAll, rollbackTo } from "../util/git"; + +/** + * Git checkpoint/rollback used by the iteration state machine. Every iteration + * runs against a known-good commit; a failed gate or verifier reverts hard to + * it (hermes-agent checkpoint pattern). + */ + +/** Ensure a clean checkpoint commit exists and return its SHA. */ +export async function ensureCheckpoint(cwd: string, label: string): Promise { + if (!(await isClean(cwd))) { + return commitAll(cwd, `ralph: checkpoint ${label}`); + } + return currentSha(cwd); +} + +/** Hard-revert the working tree to a checkpoint SHA (discards all changes). */ +export async function rollback(cwd: string, sha: string): Promise { + await rollbackTo(cwd, sha); +} + +/** Stage everything and commit with a message; returns the new SHA. */ +export async function acceptCommit(cwd: string, message: string): Promise { + return commitAll(cwd, message); +} diff --git a/packages/ralph/src/run/iteration.ts b/packages/ralph/src/run/iteration.ts new file mode 100644 index 0000000..8a02808 --- /dev/null +++ b/packages/ralph/src/run/iteration.ts @@ -0,0 +1,232 @@ +import type { Feature, VerificationRecord } from "../features/schema"; +import type { BaselineSnapshot, GateContext } from "../gates/types"; +import { renderPrompt, type CodingPromptContext } from "../prompts/render"; +import { parseRalphResult } from "../prompts/blocks"; +import { runVerifier } from "../verify/verifier"; +import { stageAll, stagedFiles, stagedDiffStat } from "../util/git"; +import { ensureCheckpoint, rollback, acceptCommit } from "./checkpoint"; +import { nowIso } from "../events/types"; +import { log, color } from "../util/logger"; +import type { RunContext, IterationResult, IterationFailure } from "./types"; + +/** + * Execute a single coding iteration for one feature: + * checkpoint → coder → stage → mechanical gates → independent verifier + * → accept (commit + transition) OR revert (fail-closed). + * + * On any failure the working tree is hard-reverted to the checkpoint; feature + * bookkeeping (attempts/blocked) is left to the loop. On accept, the feature is + * transitioned and committed atomically here (code + features.json in one commit). + */ +export async function runIteration( + ctx: RunContext, + feature: Feature, + attempt: number, + baseline: BaselineSnapshot, + previousFailure: IterationFailure | undefined, +): Promise { + const { cwd, config, store, eventLog, stateStore, state } = ctx; + const iteration = state.iteration; + const devPort = config.devServer.port; + + // 1. Checkpoint (clean, known-good commit to revert to). + const checkpointSha = await ensureCheckpoint(cwd, `iter ${iteration} pre ${feature.id}`); + state.checkpointSha = checkpointSha; + eventLog.append({ type: "checkpoint", ts: nowIso(), iteration, sha: checkpointSha }); + + const featuresHashBefore = store.snapshotHash(); + + // 2. Render + invoke the coder. + const context: CodingPromptContext = { + projectName: ctx.projectName, + projectDescription: ctx.projectDescription, + devPort, + specDir: config.specDir, + feature, + iteration, + attempt, + previousFailure: previousFailure + ? { + gates: previousFailure.gates, + verifierConcerns: previousFailure.verifierConcerns, + detail: previousFailure.detail, + } + : undefined, + recentProgress: buildRecentProgress(ctx), + }; + const prompt = renderPrompt("coding", context, { cwd, specDir: config.specDir }); + + log.step(`Iteration ${iteration} · ${color.bold(feature.id)} (attempt ${attempt}) — ${feature.description}`); + + const coderRes = await ctx.coder.adapter.invoke({ + prompt, + cwd, + role: "coder", + model: ctx.coder.role.model, + permissionTier: ctx.coder.role.permissionTier, + timeoutMs: ctx.agentTimeoutMs, + onOutput: ctx.stream ? (c) => log.raw(c) : undefined, + }); + stateStore.addUsage(state, "coder", coderRes.usage, coderRes.durationMs); + + const claim = parseRalphResult(coderRes.rawOutput); + const claimedOutcome = claim.ok ? claim.value.outcome : undefined; + const summary = claim.ok ? claim.value.summary : "(no result block)"; + + eventLog.append({ + type: "agent_result", + ts: nowIso(), + iteration, + role: "coder", + featureId: feature.id, + claimedOutcome, + exitCode: coderRes.exitCode, + timedOut: coderRes.timedOut, + durationMs: coderRes.durationMs, + usage: coderRes.usage, + }); + + // 3. Stage and measure the change. + await stageAll(cwd); + const changedFiles = await stagedFiles(cwd); + const diffStat = await stagedDiffStat(cwd); + const featuresHashAfter = store.snapshotHash(); + + // Agent explicitly gave up on this feature. + if (claimedOutcome === "blocked") { + await rollback(cwd, checkpointSha); + const detail = `agent reported blocked: ${claim.ok ? claim.value.blockers.join("; ") || summary : summary}`; + return { outcome: "blocked", detail }; + } + + if (changedFiles.length === 0) { + return { + outcome: "no_change", + detail: "coder produced no file changes", + failure: { gates: [], verifierConcerns: [], detail: "no changes were made" }, + }; + } + + // 4. Mechanical gates. + const gateCtx: GateContext = { + cwd, + config, + featuresRelPath: ctx.featuresRelPath, + changedFiles, + diffStat, + featuresHashBefore, + featuresHashAfter, + baseline, + }; + const failedGates: string[] = []; + const gateFailureDetails: string[] = []; + for (const gate of ctx.gates) { + const result = await gate.run(gateCtx); + eventLog.append({ + type: "gate_result", + ts: nowIso(), + iteration, + gate: result.gate, + passed: result.passed, + newFailures: result.newFailures, + detail: result.detail, + }); + if (result.passed) { + log.dim(` gate ${result.gate}: ok`); + } else { + failedGates.push(result.gate); + gateFailureDetails.push(`${result.gate}: ${result.detail}`); + log.warn(` gate ${result.gate}: FAILED — ${result.detail}`); + } + } + + if (failedGates.length > 0) { + await rollback(cwd, checkpointSha); + eventLog.append({ type: "revert", ts: nowIso(), iteration, toSha: checkpointSha, reason: `gates failed: ${failedGates.join(", ")}` }); + return { + outcome: "gate_failed", + detail: `gates failed: ${failedGates.join(", ")}`, + failure: { gates: failedGates, verifierConcerns: [], detail: gateFailureDetails.join("\n") }, + }; + } + + // 5. Independent verifier (fail-closed). + let verification: VerificationRecord | null = null; + let acceptedStatus: "verified" | "passed" = "passed"; + + if (config.verify.enabled) { + const diffSummary = + `Changed files (${diffStat.files}, +${diffStat.insertions}/-${diffStat.deletions}):\n` + + changedFiles.map((f) => ` - ${f}`).join("\n"); + + log.step(` verifying ${feature.id} (${ctx.verifier.role.adapter}/${ctx.verifier.role.model ?? "default"})`); + const verdict = await runVerifier({ + adapter: ctx.verifier.adapter, + role: ctx.verifier.role, + cwd, + specDir: config.specDir, + projectName: ctx.projectName, + devPort, + feature, + diffSummary, + timeoutMs: ctx.agentTimeoutMs, + onOutput: ctx.stream ? (c) => log.raw(c) : undefined, + }); + stateStore.addUsage(state, "verifier", verdict.usage, verdict.durationMs); + eventLog.append({ + type: "verifier_result", + ts: nowIso(), + iteration, + featureId: feature.id, + verdict: verdict.verdict, + concerns: verdict.concerns, + durationMs: verdict.durationMs, + usage: verdict.usage, + }); + + if (verdict.verdict !== "pass") { + await rollback(cwd, checkpointSha); + eventLog.append({ type: "revert", ts: nowIso(), iteration, toSha: checkpointSha, reason: `verifier ${verdict.verdict}` }); + log.warn(` verifier ${verdict.verdict}: ${verdict.concerns.join("; ") || "(no concerns given)"}`); + return { + outcome: "verifier_failed", + detail: `verifier returned ${verdict.verdict}`, + failure: { gates: [], verifierConcerns: verdict.concerns, detail: verdict.concerns.join("\n") || `verdict ${verdict.verdict}` }, + }; + } + + verification = { + verdict: "pass", + verifier: { adapter: ctx.verifier.role.adapter, model: ctx.verifier.role.model }, + at: nowIso(), + stepResults: verdict.steps, + concerns: verdict.concerns, + }; + acceptedStatus = "verified"; + } + + // 6. Accept: transition + atomic commit (code + features.json). + store.transition(feature.id, acceptedStatus, { verification }); + const commitSha = await acceptCommit(cwd, `ralph(${feature.id}): ${acceptedStatus} — ${truncate(summary, 72)}`); + eventLog.append({ type: "feature_transition", ts: nowIso(), featureId: feature.id, from: feature.status, to: acceptedStatus, reason: `accepted at ${commitSha.slice(0, 8)}` }); + log.success(` ${feature.id} ${acceptedStatus} · committed ${commitSha.slice(0, 8)}`); + + return { outcome: acceptedStatus, detail: summary }; +} + +function truncate(s: string, n: number): string { + const clean = s.replace(/\s+/g, " ").trim(); + return clean.length > n ? clean.slice(0, n - 1) + "…" : clean; +} + +function buildRecentProgress(ctx: RunContext): string | undefined { + const events = ctx.eventLog.read(); + const transitions = events + .filter((e) => e.type === "feature_transition") + .slice(-6) + .map((e) => { + const t = e as { featureId?: string; to?: string }; + return `- ${t.featureId} → ${t.to}`; + }); + return transitions.length ? transitions.join("\n") : undefined; +} diff --git a/packages/ralph/src/run/loop.ts b/packages/ralph/src/run/loop.ts new file mode 100644 index 0000000..6877b20 --- /dev/null +++ b/packages/ralph/src/run/loop.ts @@ -0,0 +1,302 @@ +import { captureBaseline } from "../gates"; +import { commitAll, rollbackTo } from "../util/git"; +import { run as runProc } from "../util/proc"; +import { checkBudget, checkStall } from "../budget/tracker"; +import { nowIso } from "../events/types"; +import { log, color } from "../util/logger"; +import { runIteration } from "./iteration"; +import { runReplan } from "../replan/replanner"; +import { runGarden } from "../garden/gardener"; +import type { RunContext, IterationFailure } from "./types"; +import type { FeatureStatus } from "../features/schema"; +import type { BaselineSnapshot } from "../gates/types"; + +export interface RunOptions { + /** CLI override for the iteration cap (falls back to config.budgets.maxIterations). */ + maxIterations?: number; +} + +export interface RunSummary { + reason: string; + iterations: number; + verified: number; + passed: number; + blocked: number; + total: number; + totalCostUsd: number; + durationMs: number; +} + +/** + * Drive the autonomous loop: select the next DAG-eligible feature, run one + * guarded iteration, apply bookkeeping, and stop on completion, budget, or + * stall. Feature selection, retries and blocking live here; per-iteration + * code lifecycle lives in runIteration. + */ +export async function runLoop(ctx: RunContext, opts: RunOptions = {}): Promise { + const { cwd, config, store, eventLog, stateStore, state, notifier } = ctx; + const unlockOn = config.verify.unlockOn; + const effectiveMax = opts.maxIterations ?? config.budgets.maxIterations; + const startedAt = Date.now(); + + eventLog.append({ + type: "run_start", + ts: nowIso(), + runId: state.runId, + featureCount: store.counts().total, + roles: { + coder: { adapter: ctx.coder.role.adapter, model: ctx.coder.role.model }, + verifier: { adapter: ctx.verifier.role.adapter, model: ctx.verifier.role.model }, + }, + budgets: config.budgets, + }); + + log.step("Capturing baseline (existing test/type failures are tolerated; only NEW ones block)…"); + let baseline = await captureBaseline(config, cwd); + + const failureMemory = new Map(); + let haltReason = ""; + + while (true) { + const budget = checkBudget(state, config, effectiveMax, Date.now() - startedAt); + budget.events.forEach((e) => eventLog.append(e)); + if (budget.halt) { + haltReason = budget.reason ?? "budget reached"; + await notifier.notify("budget", haltReason); + break; + } + + const feature = store.nextEligible(unlockOn); + if (!feature) { + const c = store.counts(); + const done = c.verified + c.passed >= c.total; + haltReason = done + ? "all features complete" + : "no eligible features remain (blocked or dependency-stuck)"; + break; + } + + state.iteration += 1; + stateStore.save(state); + eventLog.append({ + type: "iteration_start", + ts: nowIso(), + iteration: state.iteration, + featureId: feature.id, + featureDescription: feature.description, + attempt: feature.attempts + 1, + }); + + let result; + try { + result = await runIteration(ctx, feature, feature.attempts + 1, baseline, failureMemory.get(feature.id)); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + log.error(`iteration ${state.iteration} threw: ${detail}`); + if (state.checkpointSha) { + try { + await rollbackTo(cwd, state.checkpointSha); + } catch { + /* best-effort */ + } + } + result = { + outcome: "error" as const, + detail, + failure: { gates: [], verifierConcerns: [], detail }, + }; + } + + if (result.outcome === "verified" || result.outcome === "passed") { + failureMemory.delete(feature.id); + state.lastProgressIteration = state.iteration; + syncCounts(ctx); + stateStore.save(state); + baseline = await captureBaseline(config, cwd); // new known-good baseline + await notifier.notify("milestone", `${feature.id} ${result.outcome}: ${result.detail}`); + } else if (result.outcome === "blocked") { + store.transition(feature.id, "blocked", { reason: result.detail }); + await commitFeatures(cwd, `ralph(${feature.id}): blocked`); + failureMemory.delete(feature.id); + syncCounts(ctx); + stateStore.save(state); + await notifier.notify("blocked", `${feature.id} blocked: ${result.detail}`); + } else { + // Retriable failure: gate_failed / verifier_failed / no_change / error. + const attemptsNow = feature.attempts + 1; + if (attemptsNow > config.retries.maxAttempts) { + store.transition(feature.id, "blocked", { + reason: `exhausted ${attemptsNow} attempts — ${result.detail}`, + incrementAttempts: true, + }); + await commitFeatures(cwd, `ralph(${feature.id}): blocked after ${attemptsNow} attempts`); + failureMemory.delete(feature.id); + syncCounts(ctx); + stateStore.save(state); + await notifier.notify("blocked", `${feature.id} blocked after ${attemptsNow} attempts`); + } else { + store.transition(feature.id, "pending", { incrementAttempts: true }); + await commitFeatures(cwd, `ralph(${feature.id}): attempt ${attemptsNow} failed`); + failureMemory.set( + feature.id, + result.failure ?? { gates: [], verifierConcerns: [], detail: result.detail }, + ); + log.warn(` ${feature.id} attempt ${attemptsNow} failed — will retry`); + } + } + + // Periodic self-improvement: strong-model replan of the feature DAG. + if (config.replan.everyIterations && state.iteration % config.replan.everyIterations === 0) { + await maybeReplan(ctx); + } + + // Periodic entropy cleanup (gardening); refresh baseline if it committed. + if (config.garden.everyIterations && state.iteration % config.garden.everyIterations === 0) { + const garden = await maybeGarden(ctx, baseline); + if (garden) baseline = await captureBaseline(config, cwd); + } + + const stall = checkStall(state, config); + if (stall.event) eventLog.append(stall.event); + if (stall.stalled) { + await notifier.notify("stall", `no progress for ${state.iteration - state.lastProgressIteration} iterations`); + } + if (stall.halt) { + haltReason = `stalled: no progress for ${state.iteration - state.lastProgressIteration} iterations`; + break; + } + } + + syncCounts(ctx); + state.done = true; + state.haltReason = haltReason; + stateStore.save(state); + + const durationMs = Date.now() - startedAt; + const c = store.counts(); + eventLog.append({ + type: "run_end", + ts: nowIso(), + reason: haltReason, + verified: c.verified, + passed: c.passed, + blocked: c.blocked, + total: c.total, + durationMs, + totalCostUsd: state.totalCostUsd, + }); + + const complete = c.verified + c.passed >= c.total; + await notifier.notify(complete ? "complete" : "halt", `${haltReason} (${c.verified + c.passed}/${c.total} done)`); + + log.info(""); + log.info( + `${color.bold("Run finished:")} ${haltReason}. ` + + `${color.green(String(c.verified))} verified, ${c.passed} passed, ${color.yellow(String(c.blocked))} blocked of ${c.total} ` + + `(${state.iteration} iterations, $${state.totalCostUsd.toFixed(4)}).`, + ); + + return { + reason: haltReason, + iterations: state.iteration, + verified: c.verified, + passed: c.passed, + blocked: c.blocked, + total: c.total, + totalCostUsd: state.totalCostUsd, + durationMs, + }; +} + +function syncCounts(ctx: RunContext): void { + const c = ctx.store.counts() as Record & { total: number }; + ctx.state.features = { + verified: c.verified, + passed: c.passed, + blocked: c.blocked, + total: c.total, + }; +} + +async function commitFeatures(cwd: string, message: string): Promise { + // The harness updated features.json (attempts/blocked); commit it so the tree + // stays clean for the next checkpoint. No-op-safe if nothing changed. + try { + await commitAll(cwd, message); + } catch { + /* nothing staged / already clean */ + } +} + +async function maybeReplan(ctx: RunContext): Promise { + const { cwd, config, store, eventLog, stateStore, state, notifier } = ctx; + if (!(await ctx.replanner.adapter.isAvailable())) { + log.dim(" replan skipped (replanner adapter unavailable)"); + return; + } + try { + const gitLog = await buildGitLog(cwd); + const recentEvents = buildRecentEvents(ctx); + log.step(`Replanning (${ctx.replanner.role.adapter}/${ctx.replanner.role.model ?? "default"})…`); + const rp = await runReplan({ + adapter: ctx.replanner.adapter, + role: ctx.replanner.role, + cwd, + specDir: config.specDir, + store, + gitLog, + recentEvents, + timeoutMs: ctx.agentTimeoutMs, + onOutput: ctx.stream ? (c) => log.raw(c) : undefined, + }); + stateStore.addUsage(state, "replanner", rp.usage, rp.durationMs); + eventLog.append({ type: "replan", ts: nowIso(), iteration: state.iteration, operations: rp.applied, summary: rp.summary }); + if (rp.applied.length) { + await commitFeatures(cwd, `ralph(replan): ${rp.applied.length} change(s)`); + syncCounts(ctx); + stateStore.save(state); + log.success(` replan applied: ${rp.applied.join(", ")}`); + await notifier.notify("replan", `replan applied ${rp.applied.length} change(s): ${rp.summary ?? ""}`); + } else { + log.dim(` replan: no changes${rp.summary ? " — " + rp.summary : ""}`); + } + } catch (e) { + log.warn(` replan failed: ${(e as Error).message}`); + } +} + +async function maybeGarden(ctx: RunContext, baseline: BaselineSnapshot): Promise { + if (!(await ctx.gardener.adapter.isAvailable())) { + log.dim(" gardening skipped (gardener adapter unavailable)"); + return false; + } + try { + const res = await runGarden(ctx, baseline); + return res.committed; + } catch (e) { + log.warn(` gardening failed: ${(e as Error).message}`); + return false; + } +} + +async function buildGitLog(cwd: string): Promise { + try { + const r = await runProc("git", ["log", "--oneline", "-30"], { cwd }); + return r.stdout.trim(); + } catch { + return ""; + } +} + +function buildRecentEvents(ctx: RunContext): string { + const events = ctx.eventLog.read().slice(-20); + return events + .map((e) => { + const ev = e as unknown as Record; + const bits = [ev.type, ev.featureId, ev.gate, ev.verdict, ev.outcome, ev.reason] + .filter((x) => x !== undefined && x !== null) + .join(" "); + return `- ${bits}`; + }) + .join("\n"); +} diff --git a/packages/ralph/src/run/state.test.ts b/packages/ralph/src/run/state.test.ts new file mode 100644 index 0000000..1675575 --- /dev/null +++ b/packages/ralph/src/run/state.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { RunStateStore } from "./state"; + +describe("RunStateStore", () => { + let cwd: string; + + beforeEach(() => { + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ralph-state-")); + }); + + afterEach(() => { + fs.rmSync(cwd, { recursive: true, force: true }); + }); + + it("returns null when no state exists", () => { + expect(new RunStateStore(cwd).load()).toBeNull(); + }); + + it("init then load roundtrips", () => { + const store = new RunStateStore(cwd); + const created = store.init("run-1", 5); + + expect(created.runId).toBe("run-1"); + expect(created.iteration).toBe(0); + expect(created.perRole).toEqual({}); + expect(created.features).toEqual({ verified: 0, passed: 0, blocked: 0, total: 5 }); + expect(created.lastProgressIteration).toBe(0); + expect(created.done).toBe(false); + + const loaded = store.load(); + expect(loaded).toEqual(created); + }); + + it("addUsage accumulates across two calls and updates totals", () => { + const store = new RunStateStore(cwd); + const state = store.init("run-1", 3); + + store.addUsage(state, "coder", { inputTokens: 100, outputTokens: 50, costUsd: 0.1 }, 1000); + store.addUsage(state, "coder", { inputTokens: 20, outputTokens: 5, costUsd: 0.02 }, 500); + + expect(state.perRole.coder).toEqual({ + invocations: 2, + inputTokens: 120, + outputTokens: 55, + costUsd: expect.closeTo(0.12, 5), + durationMs: 1500, + }); + expect(state.totalInputTokens).toBe(120); + expect(state.totalOutputTokens).toBe(55); + expect(state.totalCostUsd).toBeCloseTo(0.12, 5); + + // Persisted state reflects the accumulation. + const loaded = store.load()!; + expect(loaded.perRole.coder.invocations).toBe(2); + expect(loaded.totalInputTokens).toBe(120); + }); + + it("addUsage treats undefined usage fields as zero", () => { + const store = new RunStateStore(cwd); + const state = store.init("run-1", 1); + + store.addUsage(state, "verifier", undefined, 250); + store.addUsage(state, "verifier", { costUsd: 0.05 }, 250); + + expect(state.perRole.verifier).toEqual({ + invocations: 2, + inputTokens: 0, + outputTokens: 0, + costUsd: 0.05, + durationMs: 500, + }); + expect(state.totalCostUsd).toBeCloseTo(0.05, 5); + }); + + it("save/load preserves fields including optionals", () => { + const store = new RunStateStore(cwd); + const state = store.init("run-1", 2); + state.iteration = 4; + state.features = { verified: 1, passed: 1, blocked: 0, total: 2 }; + state.lastProgressIteration = 3; + state.checkpointSha = "abc123"; + state.baselineFailureCounts = { "npx tsc": 2 }; + state.done = true; + state.haltReason = "budget"; + store.save(state); + + const loaded = store.load()!; + expect(loaded.iteration).toBe(4); + expect(loaded.features).toEqual({ verified: 1, passed: 1, blocked: 0, total: 2 }); + expect(loaded.lastProgressIteration).toBe(3); + expect(loaded.checkpointSha).toBe("abc123"); + expect(loaded.baselineFailureCounts).toEqual({ "npx tsc": 2 }); + expect(loaded.done).toBe(true); + expect(loaded.haltReason).toBe("budget"); + expect(loaded.updatedAt).toBeTruthy(); + }); +}); diff --git a/packages/ralph/src/run/state.ts b/packages/ralph/src/run/state.ts new file mode 100644 index 0000000..5f0b09b --- /dev/null +++ b/packages/ralph/src/run/state.ts @@ -0,0 +1,112 @@ +import fs from "node:fs"; +import type { AgentUsage } from "../adapters/types"; +import { nowIso } from "../events/types"; +import { ensureRalphDir, runStatePath } from "../util/paths"; + +/** Accumulated usage for a single role across the run. */ +export interface RoleUsage { + invocations: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + durationMs: number; +} + +/** + * Durable run-level telemetry persisted to .ralph/run-state.json. The loop + * mutates this via RunStateStore and drives budget/stall decisions from it. + */ +export interface RunState { + runId: string; + startedAt: string; + updatedAt: string; + iteration: number; + perRole: Record; + totalCostUsd: number; + totalInputTokens: number; + totalOutputTokens: number; + features: { verified: number; passed: number; blocked: number; total: number }; + lastProgressIteration: number; // last iteration that produced a verified/passed feature + checkpointSha?: string; + baselineFailureCounts?: Record; // carried baseline for command gates + done: boolean; + haltReason?: string; +} + +export class RunStateStore { + constructor(private readonly cwd: string) {} + + /** Load persisted state; null when missing or corrupt. */ + load(): RunState | null { + const file = runStatePath(this.cwd); + if (!fs.existsSync(file)) return null; + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as RunState; + } catch { + return null; + } + } + + /** Create, persist and return a fresh run state. */ + init(runId: string, totalFeatures: number): RunState { + const now = nowIso(); + const state: RunState = { + runId, + startedAt: now, + updatedAt: now, + iteration: 0, + perRole: {}, + totalCostUsd: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + features: { verified: 0, passed: 0, blocked: 0, total: totalFeatures }, + lastProgressIteration: 0, + done: false, + }; + this.save(state); + return state; + } + + /** Persist state as pretty JSON, stamping updatedAt. */ + save(state: RunState): void { + state.updatedAt = nowIso(); + ensureRalphDir(this.cwd); + fs.writeFileSync(runStatePath(this.cwd), JSON.stringify(state, null, 2)); + } + + /** + * Fold one agent invocation's usage into per-role and run totals, then save. + * Undefined usage fields count as zero. + */ + addUsage( + state: RunState, + role: string, + usage: AgentUsage | undefined, + durationMs: number + ): void { + const inputTokens = usage?.inputTokens ?? 0; + const outputTokens = usage?.outputTokens ?? 0; + const costUsd = usage?.costUsd ?? 0; + + const existing = state.perRole[role]; + const role_ = existing ?? { + invocations: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: 0, + durationMs: 0, + }; + role_.invocations += 1; + role_.inputTokens += inputTokens; + role_.outputTokens += outputTokens; + role_.costUsd += costUsd; + role_.durationMs += durationMs; + state.perRole[role] = role_; + + state.totalCostUsd += costUsd; + state.totalInputTokens += inputTokens; + state.totalOutputTokens += outputTokens; + + this.save(state); + } +} diff --git a/packages/ralph/src/run/types.ts b/packages/ralph/src/run/types.ts new file mode 100644 index 0000000..01ba388 --- /dev/null +++ b/packages/ralph/src/run/types.ts @@ -0,0 +1,67 @@ +import type { RalphConfig, ResolvedRole } from "../config/schema"; +import type { RunnerAdapter } from "../adapters/types"; +import type { Gate } from "../gates/types"; +import type { BaselineSnapshot } from "../gates/types"; +import { FeatureStore } from "../features/store"; +import { DevServerManager } from "../devserver/manager"; +import { EventLog } from "../events/log"; +import { RunStateStore, type RunState } from "./state"; +import { NotificationHub } from "../notify"; + +/** Everything an iteration/loop needs, assembled once by the CLI `run` command. */ +export interface RunContext { + cwd: string; + config: RalphConfig; + projectName: string; + projectDescription?: string; + /** features.json path relative to cwd (for the integrity gate + prompts). */ + featuresRelPath: string; + + store: FeatureStore; + devServer: DevServerManager; + eventLog: EventLog; + stateStore: RunStateStore; + state: RunState; + gates: Gate[]; + notifier: NotificationHub; + + coder: { adapter: RunnerAdapter; role: ResolvedRole }; + verifier: { adapter: RunnerAdapter; role: ResolvedRole }; + replanner: { adapter: RunnerAdapter; role: ResolvedRole }; + gardener: { adapter: RunnerAdapter; role: ResolvedRole }; + + /** Stream agent output to the console. */ + stream: boolean; + /** Per-agent invocation timeout. */ + agentTimeoutMs: number; +} + +export type IterationOutcome = + | "verified" + | "passed" + | "blocked" + | "gate_failed" + | "verifier_failed" + | "no_change" + | "error"; + +export interface IterationFailure { + gates: string[]; + verifierConcerns: string[]; + detail: string; +} + +export interface IterationResult { + outcome: IterationOutcome; + detail: string; + /** Present on retriable failures; fed into the next attempt's prompt. */ + failure?: IterationFailure; +} + +export interface FeatureFailureMemory { + get(id: string): IterationFailure | undefined; + set(id: string, f: IterationFailure): void; + delete(id: string): void; +} + +export type { BaselineSnapshot }; diff --git a/packages/ralph/src/util/git.ts b/packages/ralph/src/util/git.ts new file mode 100644 index 0000000..8fb6fda --- /dev/null +++ b/packages/ralph/src/util/git.ts @@ -0,0 +1,77 @@ +import { run } from "./proc"; + +/** + * Thin git helpers over the proc runner. Shared by the checkpoint logic and the + * diff/integrity gates so there is one source of truth for git invocations. + * All functions take an absolute repo cwd. + */ + +async function git(args: string[], cwd: string) { + return run("git", args, { cwd }); +} + +export async function isRepo(cwd: string): Promise { + const r = await git(["rev-parse", "--is-inside-work-tree"], cwd); + return r.code === 0 && r.stdout.trim() === "true"; +} + +export async function hasCommits(cwd: string): Promise { + const r = await git(["rev-parse", "--verify", "HEAD"], cwd); + return r.code === 0; +} + +export async function currentSha(cwd: string): Promise { + const r = await git(["rev-parse", "HEAD"], cwd); + return r.stdout.trim(); +} + +export async function isClean(cwd: string): Promise { + const r = await git(["status", "--porcelain"], cwd); + return r.stdout.trim() === ""; +} + +export async function stageAll(cwd: string): Promise { + await git(["add", "-A"], cwd); +} + +/** Stage everything and commit (skipping hooks). Returns the new commit SHA. */ +export async function commitAll(cwd: string, message: string): Promise { + await git(["add", "-A"], cwd); + await git(["commit", "--no-verify", "-m", message], cwd); + return currentSha(cwd); +} + +/** Hard reset + remove untracked files/dirs — full rollback to a checkpoint. */ +export async function rollbackTo(cwd: string, sha: string): Promise { + await git(["reset", "--hard", sha], cwd); + await git(["clean", "-fd"], cwd); +} + +/** Staged file paths (relative). Call after stageAll to include new files. */ +export async function stagedFiles(cwd: string): Promise { + const r = await git(["diff", "--cached", "--name-only"], cwd); + return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean); +} + +export interface DiffStat { + files: number; + insertions: number; + deletions: number; +} + +/** Parse `git diff --cached --shortstat` into numbers (staged vs HEAD). */ +export async function stagedDiffStat(cwd: string): Promise { + const r = await git(["diff", "--cached", "--shortstat"], cwd); + return parseShortstat(r.stdout); +} + +export function parseShortstat(text: string): DiffStat { + const files = /(\d+) files? changed/.exec(text)?.[1]; + const ins = /(\d+) insertions?\(\+\)/.exec(text)?.[1]; + const del = /(\d+) deletions?\(-\)/.exec(text)?.[1]; + return { + files: files ? Number(files) : 0, + insertions: ins ? Number(ins) : 0, + deletions: del ? Number(del) : 0, + }; +} diff --git a/packages/ralph/src/util/logger.ts b/packages/ralph/src/util/logger.ts new file mode 100644 index 0000000..8141b37 --- /dev/null +++ b/packages/ralph/src/util/logger.ts @@ -0,0 +1,36 @@ +/** + * Tiny zero-dependency console logger with ANSI colors. Honors NO_COLOR and + * non-TTY output. Kept dependency-free on purpose (the old package shipped an + * unused chalk dep). + */ + +const useColor = + !process.env.NO_COLOR && + process.env.TERM !== "dumb" && + (process.stdout.isTTY ?? false); + +function paint(code: string, s: string): string { + return useColor ? `\x1b[${code}m${s}\x1b[0m` : s; +} + +export const color = { + dim: (s: string) => paint("2", s), + bold: (s: string) => paint("1", s), + red: (s: string) => paint("31", s), + green: (s: string) => paint("32", s), + yellow: (s: string) => paint("33", s), + blue: (s: string) => paint("34", s), + cyan: (s: string) => paint("36", s), + magenta: (s: string) => paint("35", s), +}; + +export const log = { + info: (msg: string) => console.log(msg), + step: (msg: string) => console.log(color.cyan("▸ ") + msg), + success: (msg: string) => console.log(color.green("✓ ") + msg), + warn: (msg: string) => console.warn(color.yellow("! ") + msg), + error: (msg: string) => console.error(color.red("✗ ") + msg), + dim: (msg: string) => console.log(color.dim(msg)), + /** Raw passthrough (e.g. streaming agent output). */ + raw: (msg: string) => process.stdout.write(msg), +}; diff --git a/packages/ralph/src/util/paths.ts b/packages/ralph/src/util/paths.ts new file mode 100644 index 0000000..e93151e --- /dev/null +++ b/packages/ralph/src/util/paths.ts @@ -0,0 +1,46 @@ +import path from "node:path"; +import fs from "node:fs"; + +/** Per-project control directory holding all harness runtime state. */ +export const RALPH_DIR = ".ralph"; + +export function ralphDir(cwd: string): string { + return path.join(cwd, RALPH_DIR); +} + +export function ensureRalphDir(cwd: string): string { + const dir = ralphDir(cwd); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +export function progressJsonlPath(cwd: string): string { + return path.join(ralphDir(cwd), "progress.jsonl"); +} + +export function runStatePath(cwd: string): string { + return path.join(ralphDir(cwd), "run-state.json"); +} + +export function devServerStatePath(cwd: string): string { + return path.join(ralphDir(cwd), "dev-server.json"); +} + +export function devServerLogPath(cwd: string): string { + return path.join(ralphDir(cwd), "dev-server.log"); +} + +export function legacyDir(cwd: string): string { + return path.join(ralphDir(cwd), "legacy"); +} + +export const CONFIG_FILENAME = "ralph.config.json"; + +export function configPath(cwd: string): string { + return path.join(cwd, CONFIG_FILENAME); +} + +/** Resolve the features.json path for a given spec directory. */ +export function featuresPath(cwd: string, specDir: string): string { + return path.join(cwd, specDir, "features.json"); +} diff --git a/packages/ralph/src/util/proc.ts b/packages/ralph/src/util/proc.ts new file mode 100644 index 0000000..115d5f4 --- /dev/null +++ b/packages/ralph/src/util/proc.ts @@ -0,0 +1,220 @@ +import fs from "node:fs"; +import { spawn as nodeSpawn } from "node:child_process"; +import crossSpawn from "cross-spawn"; +import treeKill from "tree-kill"; + +// Spawner selection matters on Windows: +// - argv spawns (no shell) use cross-spawn, which resolves `.cmd`/`.ps1` shims +// (npm-installed CLIs like claude/codex/npm) and quotes args correctly. +// - shell spawns use Node's native spawn: cross-spawn's shell:true path has a +// known Windows bug where an exit code of 1 is misreported as ENOENT +// (isWin && status === 1 && !parsed.file), which would break gate commands +// such as `npm test` that legitimately exit 1. +function pickSpawn(shell: boolean) { + return shell ? nodeSpawn : crossSpawn; +} + +/** + * Cross-platform process runner built on cross-spawn (which resolves `.cmd` + * shims and handles argument quoting correctly on Windows). This is the single + * choke point for spawning child processes — adapters, gates, the dev-server + * manager and git checkpointing all go through here, so we deliberately avoid + * ESM-only deps like execa and keep the package CommonJS. + */ + +export interface RunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + /** Text piped to the child's stdin (used to pass large prompts to agent CLIs). */ + input?: string; + /** Kill the child (and its tree) after this many ms. 0/undefined = no timeout. */ + timeoutMs?: number; + /** Max bytes to retain per stream before truncating (default 20 MiB). */ + maxBuffer?: number; + /** Called with each stdout chunk as it arrives (for live streaming). */ + onStdout?: (chunk: string) => void; + /** Called with each stderr chunk as it arrives. */ + onStderr?: (chunk: string) => void; +} + +export interface RunResult { + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + /** stdout + stderr interleaved is not tracked; this is stdout then stderr. */ + combined: string; + timedOut: boolean; + durationMs: number; +} + +const DEFAULT_MAX_BUFFER = 20 * 1024 * 1024; + +/** + * Run a command with an explicit argv array (no shell). Preferred for invoking + * known binaries (claude, codex, git) — safe against injection and arg-length + * quirks. Pass big inputs via `opts.input` (stdin), not argv. + */ +export function run(command: string, args: string[] = [], opts: RunOptions = {}): Promise { + return spawnInternal(command, args, opts, false); +} + +/** + * Run a full command line through the platform shell. Needed for user-configured + * commands like "npm run dev" or "npx tsc --noEmit" that may rely on shell + * features. Do not pass untrusted input here. + */ +export function runShell(commandLine: string, opts: RunOptions = {}): Promise { + return spawnInternal(commandLine, [], opts, true); +} + +function spawnInternal( + command: string, + args: string[], + opts: RunOptions, + shell: boolean, +): Promise { + const start = Date.now(); + const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER; + + return new Promise((resolve, reject) => { + const child = pickSpawn(shell)(command, args, { + cwd: opts.cwd, + env: opts.env ?? process.env, + shell, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + let timedOut = false; + let settled = false; + + const timer = + opts.timeoutMs && opts.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + if (child.pid) treeKill(child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + }, opts.timeoutMs) + : null; + + child.stdout?.on("data", (d: Buffer) => { + const s = d.toString(); + opts.onStdout?.(s); + if (!stdoutTruncated) { + stdout += s; + if (stdout.length > maxBuffer) { + stdout = stdout.slice(0, maxBuffer) + "\n…[stdout truncated]"; + stdoutTruncated = true; + } + } + }); + + child.stderr?.on("data", (d: Buffer) => { + const s = d.toString(); + opts.onStderr?.(s); + if (!stderrTruncated) { + stderr += s; + if (stderr.length > maxBuffer) { + stderr = stderr.slice(0, maxBuffer) + "\n…[stderr truncated]"; + stderrTruncated = true; + } + } + }); + + child.on("error", (err) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + reject(err); + }); + + child.on("close", (code, signal) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve({ + code, + signal, + stdout, + stderr, + combined: stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""), + timedOut, + durationMs: Date.now() - start, + }); + }); + + if (opts.input !== undefined) { + child.stdin?.on("error", () => { + /* ignore EPIPE if the child exits before consuming stdin */ + }); + child.stdin?.end(opts.input); + } else { + child.stdin?.end(); + } + }); +} + +/** + * Best-effort check that a command exists on PATH. Uses `where` on Windows and + * `command -v` on POSIX. Returns false on any failure. + */ +export async function commandExists(command: string): Promise { + try { + const finder = process.platform === "win32" ? "where" : "command"; + const args = process.platform === "win32" ? [command] : ["-v", command]; + const res = + process.platform === "win32" + ? await run(finder, args) + : await runShell(`command -v ${command}`); + return res.code === 0; + } catch { + return false; + } +} + +/** Kill a process tree by pid (cross-platform). Resolves once done. */ +export function killTree(pid: number, signal: string = "SIGTERM"): Promise { + return new Promise((resolve) => { + treeKill(pid, signal, () => resolve()); + }); +} + +export interface DetachedOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + /** File path to which stdout+stderr are appended. */ + logFile: string; +} + +/** + * Spawn a long-running background process (e.g. a dev server) through the shell, + * detached, with output redirected to a log file. Returns the child pid. Use + * killTree(pid) to stop it. Unlike run(), this does not wait for exit. + */ +export function spawnDetached(commandLine: string, opts: DetachedOptions): number { + const out = fs.openSync(opts.logFile, "a"); + try { + const child = nodeSpawn(commandLine, [], { + cwd: opts.cwd, + env: opts.env ?? process.env, + shell: true, + windowsHide: true, + // New process group on POSIX so we can signal the whole tree; on Windows + // tree-kill walks the child tree via wmic/taskkill instead. + detached: process.platform !== "win32", + stdio: ["ignore", out, out], + }); + child.unref(); + return child.pid ?? -1; + } finally { + // Close the parent's copy of the log fd — the child has its own inherited + // handle. On Windows an unclosed parent handle keeps the log file locked + // for the life of the harness (breaking cleanup / deletion of .ralph). + fs.closeSync(out); + } +} diff --git a/packages/ralph/src/verify/verifier.ts b/packages/ralph/src/verify/verifier.ts new file mode 100644 index 0000000..b972030 --- /dev/null +++ b/packages/ralph/src/verify/verifier.ts @@ -0,0 +1,75 @@ +import type { RunnerAdapter, AgentUsage } from "../adapters/types"; +import type { ResolvedRole } from "../config/schema"; +import type { Feature } from "../features/schema"; +import { renderPrompt, type VerifierPromptContext } from "../prompts/render"; +import { parseRalphVerdict, type RalphVerdict } from "../prompts/blocks"; + +/** + * Independent, fail-closed verification. The verifier runs in a FRESH context + * (no implementer history), read-only, ideally on a different/cheaper model. + * Unparseable or ambiguous output is treated as "inconclusive" → the loop + * rejects the change (no agent grades its own work). + */ + +export interface VerifierOptions { + adapter: RunnerAdapter; + role: ResolvedRole; + cwd: string; + specDir: string; + projectName: string; + devPort: number; + feature: Feature; + diffSummary: string; + timeoutMs: number; + onOutput?: (chunk: string) => void; +} + +export interface VerifierOutcome { + verdict: "pass" | "fail" | "inconclusive"; + concerns: string[]; + steps: RalphVerdict["steps"]; + usage?: AgentUsage; + durationMs: number; + raw: string; +} + +export async function runVerifier(opts: VerifierOptions): Promise { + const context: VerifierPromptContext = { + projectName: opts.projectName, + devPort: opts.devPort, + specDir: opts.specDir, + feature: opts.feature, + diffSummary: opts.diffSummary, + }; + const prompt = renderPrompt("verifier", context, { cwd: opts.cwd, specDir: opts.specDir }); + + const res = await opts.adapter.invoke({ + prompt, + cwd: opts.cwd, + role: "verifier", + model: opts.role.model, + permissionTier: "readonly", + timeoutMs: opts.timeoutMs, + onOutput: opts.onOutput, + }); + + const parsed = parseRalphVerdict(res.rawOutput); + if (!parsed.ok) { + return { + verdict: "inconclusive", + concerns: [`verifier output could not be parsed (${parsed.error})`], + steps: [], + usage: res.usage, + durationMs: res.durationMs, + raw: res.rawOutput, + }; + } + return { + verdict: parsed.value.verdict, + concerns: parsed.value.concerns, + steps: parsed.value.steps, + usage: res.usage, + durationMs: res.durationMs, + raw: res.rawOutput, + }; +} diff --git a/packages/ralph/tsconfig.json b/packages/ralph/tsconfig.json new file mode 100644 index 0000000..c5a2ab6 --- /dev/null +++ b/packages/ralph/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 21e0fb7..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,985 +0,0 @@ -#!/usr/bin/env node - -import { Command } from "commander"; -import inquirer from "inquirer"; -import fs from "fs-extra"; -import path from "path"; -import { execSync, spawnSync } from "child_process"; - -const TEMPLATE_DIR = path.join(__dirname, "..", "template"); -const RALPH_MARKER = "RALPH LOOP"; -const RALPH_GITIGNORE_ENTRIES = [ - ".dev-server.pid", - ".dev-server.log", - "specs/phase1/screenshots/", -]; - -interface TemplateVars { - projectName: string; - projectSlug: string; - projectDescription: string; - createdAt: string; - devPort: string; -} - -interface ProjectDetection { - packageManager: "npm" | "pnpm" | "yarn"; - framework: string; - installCommand: string; - devCommand: string; - testCommand: string; - port: string; - packageName?: string; - packageDescription?: string; -} - -interface CliOptions { - yes: boolean; - git?: boolean; - install?: boolean; - adopt?: boolean; - init?: boolean; - generateSpecs?: boolean; - codex?: boolean; -} - -type ConflictAction = "skip" | "overwrite" | "merge"; - -function toKebabCase(str: string): string { - return str - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -function toTitleCase(str: string): string { - return str - .replace(/[-_]/g, " ") - .replace(/\b\w/g, (c: string) => c.toUpperCase()); -} - -function deriveProjectPort(seed: string): string { - let hash = 0; - for (const char of seed) { - hash = (hash * 31 + char.charCodeAt(0)) >>> 0; - } - return String(3000 + (hash % 1000)); -} - -function replaceTemplateVars(content: string, vars: TemplateVars): string { - return content.replace(/\{\{(\w+)\}\}/g, (_, key) => { - return (vars as unknown as Record)[key] ?? `{{${key}}}`; - }); -} - -function templateDestPath(entry: string): string { - const relativePath = path.relative(TEMPLATE_DIR, entry); - let destPath = relativePath; - const basename = path.basename(destPath); - if (basename.startsWith("_")) { - destPath = path.join(path.dirname(destPath), "." + basename.slice(1)); - } - if (destPath.endsWith(".hbs")) { - destPath = destPath.slice(0, -4); - } - return destPath; -} - -async function renderTemplateEntry( - entry: string, - vars: TemplateVars -): Promise { - const isTemplate = entry.endsWith(".hbs") || path.extname(entry) === ".sh"; - if (!isTemplate) { - return fs.readFile(entry); - } - const content = await fs.readFile(entry, "utf-8"); - return replaceTemplateVars(content, vars); -} - -async function scaffold(targetDir: string, vars: TemplateVars): Promise { - await fs.ensureDir(targetDir); - - const entries = await walkDir(TEMPLATE_DIR); - - for (const entry of entries) { - const destPath = path.join(targetDir, templateDestPath(entry)); - const rendered = await renderTemplateEntry(entry, vars); - await fs.ensureDir(path.dirname(destPath)); - await fs.writeFile(destPath, rendered); - } - - await chmodShellScripts(targetDir); -} - -async function walkDir(dir: string): Promise { - const results: string[] = []; - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...(await walkDir(fullPath))); - } else { - results.push(fullPath); - } - } - return results; -} - -async function chmodShellScripts(targetDir: string): Promise { - const shFiles = (await walkDir(targetDir)).filter((f) => f.endsWith(".sh")); - for (const sh of shFiles) { - try { - fs.chmodSync(sh, 0o755); - } catch { - // chmod may not work on Windows, safe to ignore. - } - } -} - -async function readPackageJson(targetDir: string): Promise | null> { - const packageJsonPath = path.join(targetDir, "package.json"); - if (!(await fs.pathExists(packageJsonPath))) { - return null; - } - try { - return await fs.readJson(packageJsonPath); - } catch { - return null; - } -} - -async function readEnvPort(targetDir: string): Promise { - for (const fileName of [".env.local", ".env"]) { - const envPath = path.join(targetDir, fileName); - if (!(await fs.pathExists(envPath))) { - continue; - } - const content = await fs.readFile(envPath, "utf-8"); - const match = content.match(/^(?:DEV_PORT|PORT)=(\d+)\s*$/m); - if (match) { - return match[1]; - } - } - return undefined; -} - -async function detectProject(targetDir: string): Promise { - const pkg = await readPackageJson(targetDir); - const deps = { - ...(pkg?.dependencies ?? {}), - ...(pkg?.devDependencies ?? {}), - }; - const scripts = pkg?.scripts ?? {}; - - let packageManager: ProjectDetection["packageManager"] = "npm"; - if (await fs.pathExists(path.join(targetDir, "pnpm-lock.yaml"))) { - packageManager = "pnpm"; - } else if (await fs.pathExists(path.join(targetDir, "yarn.lock"))) { - packageManager = "yarn"; - } - - let framework = "generic Node"; - if (deps.next) { - framework = "Next.js"; - } else if (deps.vite || deps["@vitejs/plugin-react"]) { - framework = "Vite"; - } else if (deps["@remix-run/dev"] || deps["@remix-run/react"]) { - framework = "Remix"; - } else if (deps.astro) { - framework = "Astro"; - } - - const projectSeed = pkg?.name ?? path.basename(targetDir); - const envPort = await readEnvPort(targetDir); - - return { - packageManager, - framework, - installCommand: - packageManager === "npm" - ? "npm install" - : packageManager === "pnpm" - ? "pnpm install" - : "yarn install", - devCommand: scriptCommand(packageManager, scripts.dev ? "dev" : scripts.start ? "start" : "dev"), - testCommand: scripts.test ? scriptCommand(packageManager, "test") : "", - port: envPort ?? deriveProjectPort(projectSeed), - packageName: pkg?.name, - packageDescription: pkg?.description, - }; -} - -function scriptCommand(packageManager: ProjectDetection["packageManager"], script: string): string { - if (packageManager === "npm") { - return `npm run ${script}`; - } - if (packageManager === "pnpm") { - return `pnpm ${script}`; - } - return `yarn ${script}`; -} - -async function buildVars( - targetDir: string, - options: CliOptions, - detection?: ProjectDetection -): Promise { - const dirName = path.basename(targetDir); - let projectName = detection?.packageName ?? dirName; - let projectDescription = - detection?.packageDescription ?? "An AI-powered application"; - - if (!options.yes && !detection) { - const answers = await inquirer.prompt([ - { - type: "input", - name: "projectName", - message: "Project name:", - default: toTitleCase(dirName), - }, - { - type: "input", - name: "projectDescription", - message: "One-line description:", - default: projectDescription, - }, - ]); - projectName = answers.projectName; - projectDescription = answers.projectDescription; - } else if (!options.yes && detection) { - const answers = await inquirer.prompt([ - { - type: "input", - name: "projectName", - message: "Project name:", - default: projectName, - }, - { - type: "input", - name: "projectDescription", - message: "One-line description:", - default: projectDescription, - }, - ]); - projectName = answers.projectName; - projectDescription = answers.projectDescription; - } - - return { - projectName, - projectSlug: toKebabCase(projectName), - projectDescription, - createdAt: new Date().toISOString().split("T")[0], - devPort: detection?.port ?? deriveProjectPort(projectName || dirName), - }; -} - -function adoptFileSet(relativePath: string): boolean { - const normalized = relativePath.replace(/\\/g, "/"); - if (normalized === "README.md" || normalized === ".env.example") { - return false; - } - if (normalized === ".gitignore") { - return false; - } - return true; -} - -async function adopt( - targetDir: string, - vars: TemplateVars, - detection: ProjectDetection, - options: CliOptions -): Promise { - const entries = await walkDir(TEMPLATE_DIR); - - for (const entry of entries) { - const relativePath = templateDestPath(entry); - if (!adoptFileSet(relativePath)) { - continue; - } - - const destPath = path.join(targetDir, relativePath); - let rendered: Buffer | string; - if (relativePath === "init.sh") { - rendered = renderAdoptInit(vars, detection); - } else if (relativePath === path.join("scripts", "dev-up.sh")) { - rendered = renderAdoptDevUp(detection); - } else if (relativePath === path.join("scripts", "dev-down.sh")) { - rendered = renderAdoptDevDown(detection); - } else if ( - relativePath === path.join("specs", "phase1", "prompts", "init_prompt.md") - ) { - rendered = renderAdoptInitializerPrompt(vars); - } else { - rendered = await renderTemplateEntry(entry, vars); - } - - await writeAdoptFile(destPath, relativePath, rendered, options); - } - - await appendGitignoreEntries(path.join(targetDir, ".gitignore")); - await ensureDevPortEnv(path.join(targetDir, ".env.local"), detection.port); - await chmodShellScripts(targetDir); -} - -async function writeAdoptFile( - destPath: string, - relativePath: string, - content: Buffer | string, - options: CliOptions -): Promise { - if (!(await fs.pathExists(destPath))) { - await fs.ensureDir(path.dirname(destPath)); - await fs.writeFile(destPath, content); - console.log(`Added ${relativePath}`); - return; - } - - if (relativePath === ".mcp.json") { - const merged = await mergeMcpJson(destPath, content.toString()); - if (merged) { - console.log("Merged .mcp.json"); - } else { - console.log("Skipped .mcp.json (existing JSON could not be merged)"); - } - return; - } - - if (options.yes) { - console.log(`Skipped ${relativePath} (already exists)`); - return; - } - - const choices = markdownMergeSupported(relativePath) - ? ["skip", "overwrite", "merge"] - : ["skip", "overwrite"]; - const answer = await inquirer.prompt<{ action: ConflictAction }>([ - { - type: "list", - name: "action", - message: `${relativePath} already exists. What should happen?`, - choices, - default: "skip", - }, - ]); - - if (answer.action === "skip") { - console.log(`Skipped ${relativePath}`); - return; - } - - if (answer.action === "merge") { - await appendMarkdownSection(destPath, content.toString()); - console.log(`Merged ${relativePath}`); - return; - } - - await fs.writeFile(destPath, content); - console.log(`Overwrote ${relativePath}`); -} - -function markdownMergeSupported(relativePath: string): boolean { - return relativePath === "CLAUDE.md" || relativePath === "AGENTS.md"; -} - -async function appendMarkdownSection(destPath: string, content: string): Promise { - const existing = await fs.readFile(destPath, "utf-8"); - if (existing.includes(`BEGIN ${RALPH_MARKER}`)) { - return; - } - const section = [ - "", - ``, - content.trim(), - ``, - "", - ].join("\n"); - await fs.writeFile(destPath, `${existing.trimEnd()}\n${section}`, "utf-8"); -} - -async function mergeMcpJson(destPath: string, incomingContent: string): Promise { - try { - const existing = await fs.readJson(destPath); - const incoming = JSON.parse(incomingContent); - existing.mcpServers = { - ...(existing.mcpServers ?? {}), - ...(incoming.mcpServers ?? {}), - }; - await fs.writeJson(destPath, existing, { spaces: 2 }); - return true; - } catch { - return false; - } -} - -async function appendGitignoreEntries(gitignorePath: string): Promise { - let existing = ""; - if (await fs.pathExists(gitignorePath)) { - existing = await fs.readFile(gitignorePath, "utf-8"); - } - const lines = new Set(existing.split(/\r?\n/).map((line) => line.trim())); - const missing = RALPH_GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry)); - if (missing.length === 0) { - return; - } - const block = ["", "# Ralph Loop", ...missing, ""].join("\n"); - await fs.writeFile(gitignorePath, `${existing.trimEnd()}${block}`, "utf-8"); - console.log("Updated .gitignore with Ralph Loop runtime files"); -} - -async function ensureDevPortEnv(envPath: string, port: string): Promise { - let existing = ""; - if (await fs.pathExists(envPath)) { - existing = await fs.readFile(envPath, "utf-8"); - } - if (/^(?:DEV_PORT|PORT)=\d+\s*$/m.test(existing)) { - return; - } - const line = `DEV_PORT=${port}`; - await fs.writeFile(envPath, `${existing.trimEnd()}\n${line}\n`, "utf-8"); - console.log(`Updated .env.local with ${line}`); -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function renderAdoptInit(vars: TemplateVars, detection: ProjectDetection): string { - const testLine = detection.testCommand - ? `echo " To run tests: ${detection.testCommand}"` - : `echo " To run tests: no test script detected"`; - return `#!/usr/bin/env bash -# ${vars.projectName} -- Development Environment Setup -# This script is idempotent -- safe to re-run at any time. - -set -euo pipefail - -echo "========================================" -echo " ${vars.projectName} -- Dev Environment Setup" -echo "========================================" -echo "" - -check_tool() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "ERROR: $1 is not installed. Please install it before continuing." - exit 1 - fi -} - -check_tool node -check_tool git -check_tool ${detection.packageManager} - -NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) -if [ "$NODE_VERSION" -lt 18 ]; then - echo "ERROR: Node.js >= 18 required. Current: $(node -v)" - exit 1 -fi - -if [ -f "package.json" ]; then - echo "Installing dependencies with ${detection.packageManager}..." - ${detection.installCommand} -else - echo "No package.json found; skipping dependency install." -fi - -if [ -f "prisma/schema.prisma" ]; then - echo "Generating Prisma client..." - npx prisma generate || true -fi - -if [ -f ".env.example" ] && [ ! -f ".env.local" ]; then - echo "Creating .env.local from .env.example..." - cp .env.example .env.local -fi - -echo "" -echo "========================================" -echo " Setup Summary" -echo "========================================" -echo " Framework: ${detection.framework}" -echo " Package mgr: ${detection.packageManager}" -echo " Dev port: ${detection.port}" -echo " Dev command: ${detection.devCommand}" -${testLine} -echo " Dev server: ./scripts/dev-up.sh" -echo " Cleanup: ./scripts/dev-cleanup.sh" -echo "========================================" -`; -} - -function renderAdoptDevUp(detection: ProjectDetection): string { - return `#!/usr/bin/env bash -# Start the detected dev server in the background. -# Idempotent: stops this project's stale server first, then waits until ready. - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PID_FILE=".dev-server.pid" -LOG_FILE=".dev-server.log" -REGISTRY_DIR="\${RALPH_HOME:-$HOME/.ralph}" -REGISTRY_FILE="$REGISTRY_DIR/servers.json" -READY_TIMEOUT="\${READY_TIMEOUT:-180}" -DEV_COMMAND=${shellQuote(detection.devCommand)} - -load_env_file() { - local file="$1" - if [ ! -f "$file" ]; then return 0; fi - set -a - # shellcheck disable=SC1090 - source "$file" - set +a -} - -load_env_file ".env" -load_env_file ".env.local" - -DEV_PORT="\${DEV_PORT:-\${PORT:-${detection.port}}}" -PORT="$DEV_PORT" - -kill_pid() { - local pid="$1" - if [ -z "$pid" ]; then return 0; fi - if ! kill -0 "$pid" 2>/dev/null; then return 0; fi - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "$pid" 2>/dev/null || return 0 - sleep 0.5 - done - kill -9 "$pid" 2>/dev/null || true -} - -registry_update() { - local action="$1" - local pid="\${2:-}" - mkdir -p "$REGISTRY_DIR" - node - "$REGISTRY_FILE" "$action" "$ROOT_DIR" "$PORT" "$pid" <<'NODE' -const fs = require("fs"); -const [registryFile, action, project, port, pid] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); - if (!Array.isArray(registry)) registry = []; -} catch { - registry = []; -} -registry = registry.filter((entry) => entry && entry.project !== project); -registry = registry.filter((entry) => { - if (!entry.pid) return false; - try { - process.kill(Number(entry.pid), 0); - return true; - } catch { - return false; - } -}); -if (action === "register") { - registry.push({ - project, - port: Number(port), - pid: Number(pid), - started: new Date().toISOString(), - }); -} -fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2) + "\\n"); -NODE -} - -registry_pid_for_port() { - node - "$REGISTRY_FILE" "$ROOT_DIR" "$PORT" <<'NODE' -const fs = require("fs"); -const [registryFile, project, port] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); -} catch {} -const entry = Array.isArray(registry) - ? registry.find((item) => String(item.port) === String(port) && item.project !== project) - : undefined; -if (entry && entry.pid) process.stdout.write(String(entry.pid)); -NODE -} - -port_in_use() { - if command -v fuser >/dev/null 2>&1 && fuser -s "\${PORT}/tcp" 2>/dev/null; then - return 0 - fi - if command -v lsof >/dev/null 2>&1 && [ -n "$(lsof -ti:"$PORT" 2>/dev/null || true)" ]; then - return 0 - fi - return 1 -} - -clear_own_port_processes() { - local registry_pid - registry_pid="$(registry_pid_for_port || true)" - if [ -n "$registry_pid" ] && kill -0 "$registry_pid" 2>/dev/null; then - echo "ERROR: port $PORT is registered to another Ralph project (pid $registry_pid)." >&2 - echo "Run that project's ./scripts/dev-down.sh or choose another DEV_PORT in .env.local." >&2 - exit 1 - fi -} - -if [ -f "$PID_FILE" ]; then - OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - kill_pid "$OLD_PID" - rm -f "$PID_FILE" -fi -registry_update unregister - -if port_in_use; then - clear_own_port_processes - echo "ERROR: port $PORT is already in use by an unregistered process." >&2 - echo "Stop that process or set DEV_PORT to another value in .env.local." >&2 - exit 1 -fi - -: > "$LOG_FILE" -PORT="$PORT" DEV_PORT="$DEV_PORT" bash -lc "$DEV_COMMAND" >> "$LOG_FILE" 2>&1 & -NEW_PID=$! -disown "$NEW_PID" 2>/dev/null || true -echo "$NEW_PID" > "$PID_FILE" -registry_update register "$NEW_PID" -echo "Started dev server (pid $NEW_PID) on port $PORT, logging to $LOG_FILE" - -DEADLINE=$(( $(date +%s) + READY_TIMEOUT )) -while :; do - if ! kill -0 "$NEW_PID" 2>/dev/null; then - echo "ERROR: dev server process exited before becoming ready." >&2 - tail -n 40 "$LOG_FILE" >&2 || true - rm -f "$PID_FILE" - registry_update unregister - exit 1 - fi - if grep -qE '(Ready in|started server on|Local:[[:space:]]+http|localhost:|http://)' "$LOG_FILE" 2>/dev/null; then - echo "Dev server ready on http://localhost:\${PORT}/ (per log)" - exit 0 - fi - if curl -fsS --connect-timeout 3 --max-time 5 -o /dev/null "http://localhost:\${PORT}/" 2>/dev/null; then - echo "Dev server ready on http://localhost:\${PORT}/" - exit 0 - fi - if [ "$(date +%s)" -ge "$DEADLINE" ]; then - echo "ERROR: dev server did not become ready within \${READY_TIMEOUT}s." >&2 - tail -n 40 "$LOG_FILE" >&2 || true - exit 1 - fi - sleep 0.5 -done -`; -} - -function renderAdoptDevDown(detection: ProjectDetection): string { - return `#!/usr/bin/env bash -# Stop the detected dev server. -# Idempotent: safe to run when nothing is up. - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PID_FILE=".dev-server.pid" -REGISTRY_DIR="\${RALPH_HOME:-$HOME/.ralph}" -REGISTRY_FILE="$REGISTRY_DIR/servers.json" - -load_env_file() { - local file="$1" - if [ ! -f "$file" ]; then return 0; fi - set -a - # shellcheck disable=SC1090 - source "$file" - set +a -} - -load_env_file ".env" -load_env_file ".env.local" - -DEV_PORT="\${DEV_PORT:-\${PORT:-${detection.port}}}" -PORT="$DEV_PORT" - -kill_pid() { - local pid="$1" - if [ -z "$pid" ]; then return 0; fi - if ! kill -0 "$pid" 2>/dev/null; then return 0; fi - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "$pid" 2>/dev/null || return 0 - sleep 0.5 - done - kill -9 "$pid" 2>/dev/null || true -} - -registry_update() { - mkdir -p "$REGISTRY_DIR" - node - "$REGISTRY_FILE" "$ROOT_DIR" <<'NODE' -const fs = require("fs"); -const [registryFile, project] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); - if (!Array.isArray(registry)) registry = []; -} catch { - registry = []; -} -registry = registry.filter((entry) => entry && entry.project !== project); -registry = registry.filter((entry) => { - if (!entry.pid) return false; - try { - process.kill(Number(entry.pid), 0); - return true; - } catch { - return false; - } -}); -fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2) + "\\n"); -NODE -} - -if [ -f "$PID_FILE" ]; then - PID="$(cat "$PID_FILE" 2>/dev/null || true)" - if [ -n "$PID" ]; then - kill_pid "$PID" - echo "Stopped dev server (pid $PID)" - fi - rm -f "$PID_FILE" -fi - -registry_update -`; -} - -function renderAdoptInitializerPrompt(vars: TemplateVars): string { - return `# ${vars.projectName} -- Adopted Project Initialization Prompt - -This project already existed before Ralph Loop was adopted. Do not scaffold a new app, run create-next-app, replace package configuration, or rewrite existing source files. - -Use this prompt only to prepare Ralph planning files for the existing codebase. - -## Deliverables - -1. Read the current project structure, documentation, package scripts, source files, routes, APIs, tests, and configuration. -2. Update \`specs/phase1/PRD.md\` so it describes the current product and known gaps. -3. Update \`specs/phase1/app_spec.txt\` so it documents the actual architecture, stack, data flow, commands, routes, UI, and environment variables. -4. Update \`specs/phase1/feature_list.json\` so already-working existing features have \`"passes": true\`, and future Ralph work has \`"passes": false\`. -5. Append an initial adoption entry to \`progress.txt\` summarizing what was discovered. - -## Rules - -- Do not modify application source code. -- Do not change \`package.json\`, lockfiles, framework config, or CI config. -- Do not install dependencies unless the user has explicitly prepared the environment for that. -- Do not mark a feature as passing unless it can be verified from existing code, tests, or manual inspection. -- Prefer this project's existing conventions over generic Ralph greenfield defaults. -`; -} - -async function runSpecGeneration( - targetDir: string, - options: CliOptions -): Promise { - const promptPath = path.join( - targetDir, - "specs", - "phase1", - "prompts", - "adopt_spec_prompt.md" - ); - if (!(await fs.pathExists(promptPath))) { - throw new Error("adopt_spec_prompt.md was not installed."); - } - const prompt = await fs.readFile(promptPath, "utf-8"); - const runner = options.codex ? "codex" : "claude"; - const probe = spawnSync(runner, ["--version"], { stdio: "pipe" }); - if (probe.error) { - throw new Error( - `Cannot run --generate-specs because '${runner}' is not installed or not on PATH. Adoption completed; install ${runner} and rerun with --generate-specs.` - ); - } - - console.log(`Generating specs with ${runner}...`); - const result = options.codex - ? spawnSync("codex", ["exec", "--yolo", prompt], { - cwd: targetDir, - stdio: "inherit", - shell: process.platform === "win32", - }) - : spawnSync( - "claude", - [ - "-p", - prompt, - "--allowedTools", - "Read,Write,Edit,Glob,Grep,Bash", - ], - { - cwd: targetDir, - stdio: "inherit", - shell: process.platform === "win32", - } - ); - - if (result.status !== 0) { - throw new Error(`${runner} failed while generating specs.`); - } -} - -async function runGreenfield( - projectDir: string | undefined, - options: CliOptions -): Promise { - if (!projectDir && !options.yes) { - const { dir } = await inquirer.prompt([ - { - type: "input", - name: "dir", - message: "Project directory:", - default: "my-ralph-project", - }, - ]); - projectDir = dir; - } else if (!projectDir) { - projectDir = "my-ralph-project"; - } - - const resolvedProjectDir = projectDir ?? "my-ralph-project"; - const targetDir = path.resolve(resolvedProjectDir); - if (await fs.pathExists(targetDir)) { - const contents = await fs.readdir(targetDir); - if (contents.length > 0) { - console.error( - `Error: Directory "${resolvedProjectDir}" already exists and is not empty. Use --adopt to add Ralph Loop to an existing project.` - ); - process.exit(1); - } - } - - const vars = await buildVars(targetDir, options); - - console.log(""); - console.log(`Creating ${vars.projectName} in ${targetDir}...`); - console.log(""); - - await scaffold(targetDir, vars); - - if (options.git !== false) { - try { - execSync("git init", { cwd: targetDir, stdio: "pipe" }); - console.log("Initialized git repository."); - } catch { - console.log("Warning: git init failed. You can do this manually."); - } - } - - printGreenfieldNextSteps(resolvedProjectDir); -} - -async function runAdopt( - projectDir: string | undefined, - options: CliOptions -): Promise { - const targetDir = path.resolve(projectDir ?? "."); - if (!(await fs.pathExists(targetDir))) { - console.error(`Error: Directory "${targetDir}" does not exist.`); - process.exit(1); - } - - const detection = await detectProject(targetDir); - const vars = await buildVars(targetDir, options, detection); - - console.log(""); - console.log(`Adopting Ralph Loop into ${targetDir}...`); - console.log(`Detected: ${detection.framework}, ${detection.packageManager}`); - console.log(""); - - await adopt(targetDir, vars, detection, options); - - if (options.generateSpecs) { - await runSpecGeneration(targetDir, options); - } - - printAdoptNextSteps(options); -} - -function printGreenfieldNextSteps(projectDir: string): void { - console.log(""); - console.log("Done! Your Ralph loop project is ready."); - console.log(""); - console.log("Next steps:"); - console.log(""); - console.log(` cd ${projectDir}`); - console.log(""); - console.log(" 1. Fill in your specs:"); - console.log(" - specs/phase1/PRD.md (product requirements)"); - console.log(" - specs/phase1/app_spec.txt (technical spec)"); - console.log(" - specs/phase1/feature_list.json (feature catalog)"); - console.log(""); - console.log(" 2. Run the initializer (once):"); - console.log(' claude -p "$(cat specs/phase1/prompts/init_prompt.md)" \\'); - console.log(' --allowedTools "Read,Write,Edit,Glob,Grep,Bash"'); - console.log(""); - console.log(" 3. Run the Ralph loop:"); - console.log(" ./ralph.sh --claude 20"); - console.log(""); -} - -function printAdoptNextSteps(options: CliOptions): void { - console.log(""); - console.log("Done! Ralph Loop adoption is ready."); - console.log(""); - console.log("Next steps:"); - console.log(""); - if (!options.generateSpecs) { - console.log(" 1. Review or generate adopted specs:"); - console.log(' claude -p "$(cat specs/phase1/prompts/adopt_spec_prompt.md)" \\'); - console.log(' --allowedTools "Read,Write,Edit,Glob,Grep,Bash"'); - console.log(""); - console.log(" Or rerun:"); - console.log(" npx create-ralph-loop --adopt --generate-specs"); - console.log(""); - } - console.log(" 2. Review adopted scripts:"); - console.log(" ./init.sh"); - console.log(" ./scripts/dev-up.sh"); - console.log(""); - console.log(" 3. Run the Ralph loop:"); - console.log(" ./ralph.sh --claude 20"); - console.log(""); -} - -async function main(): Promise { - const program = new Command(); - - program - .name("create-ralph-loop") - .description( - "Scaffold or adopt a Ralph agentic automation loop for AI-driven iterative development" - ) - .argument("[project-directory]", "Directory to create or adopt") - .option("-y, --yes", "Use defaults for all prompts", false) - .option("--no-git", "Skip git init in greenfield mode") - .option("--no-install", "Reserved for compatibility; generated scripts still install dependencies") - .option("--adopt", "Adopt Ralph Loop into an existing project") - .option("--init", "Alias for --adopt") - .option("--generate-specs", "Generate adopted specs using an installed agent CLI") - .option("--codex", "Use Codex instead of Claude for --generate-specs") - .action(async (projectDir: string | undefined, options: CliOptions) => { - if (options.adopt || options.init) { - await runAdopt(projectDir, options); - } else { - await runGreenfield(projectDir, options); - } - }); - - await program.parseAsync(process.argv); -} - -main().catch((err) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); -}); diff --git a/template/AGENTS.md b/template/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/template/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/template/README.md.hbs b/template/README.md.hbs deleted file mode 100644 index dd1e475..0000000 --- a/template/README.md.hbs +++ /dev/null @@ -1,114 +0,0 @@ -# {{projectName}} - -{{projectDescription}} - -Built with the [Ralph Loop](https://github.com/weststack/create-ralph-loop) — an agentic automation harness for AI-driven iterative development. - -## Quick Start - -### 1. Write your specs - -Before running anything, fill in these files: - -| File | Purpose | -|---|---| -| `specs/phase1/PRD.md` | Product requirements — what you're building and why | -| `specs/phase1/app_spec.txt` | Technical specification — data models, API routes, business logic, UI layout | -| `specs/phase1/feature_list.json` | Feature catalog — every feature with verification steps | -| `specs/phase1/prompts/init_prompt.md` | Scaffolding instructions — review and customize for your project | - -### 2. Run the initializer (once) - -This scaffolds your project from your specs: - -```bash -claude -p "$(cat specs/phase1/prompts/init_prompt.md)" \ - --allowedTools "Read,Write,Edit,Glob,Grep,Bash" -``` - -### 3. Run the Ralph loop - -This iterates through your feature list, implementing one feature per session: - -```bash -./ralph.sh --claude 20 -``` - -Use `--codex` instead of `--claude` to use OpenAI Codex as the AI backend. - -## How It Works - -Ralph runs an automated loop of AI coding sessions. Each iteration: - -1. **Orients** — reads `progress.txt` and `feature_list.json` to understand current state -2. **Checks regressions** — verifies 1-2 existing features still work -3. **Picks a feature** — selects the highest-priority unfinished feature -4. **Implements** — writes code following `app_spec.txt` -5. **Tests** — runs unit tests, integration tests, and Playwright verification -6. **Updates tracking** — marks the feature as passing, appends to `progress.txt` -7. **Commits** — makes a clean git commit -8. **Exits early** if all features pass (via `COMPLETE` sentinel) - -## Directory Structure - -``` -├── ralph.sh # Main loop driver -├── init.sh # Environment setup (idempotent, run at start of each session) -├── scripts/ -│ ├── dev-up.sh # Start dev server in background -│ ├── dev-down.sh # Stop dev server -│ └── dev-cleanup.sh # Clean stale global Ralph server registry entries -├── specs/phase1/ -│ ├── PRD.md # Product requirements document -│ ├── app_spec.txt # Technical specification -│ ├── feature_list.json # Feature catalog with pass/fail status -│ └── prompts/ -│ ├── init_prompt.md # One-time scaffolding instructions -│ └── coding_prompt.md # 10-step workflow (run each iteration) -├── progress.txt # Session log (appended by the agent) -├── CLAUDE.md # Claude Code project instructions -├── AGENTS.md # Agent behavior rules -└── .mcp.json # Playwright MCP server for browser testing -``` - -## Configuration - -### Environment Variables - -Copy `.env.example` to `.env.local` and fill in your values: - -```bash -cp .env.example .env.local -``` - -`DEV_PORT` controls the local dev server port. The scaffold assigns a deterministic port for this project so multiple Ralph projects can run on the same machine without all defaulting to `3000`. - -### Dev Server Registry - -`scripts/dev-up.sh` registers the active dev server in `~/.ralph/servers.json`, and `scripts/dev-down.sh` removes this project's registry entry. To remove stale registry entries: - -```bash -./scripts/dev-cleanup.sh -``` - -To stop all live registered Ralph dev servers: - -```bash -./scripts/dev-cleanup.sh --kill-live -``` - -### MCP (Model Context Protocol) - -`.mcp.json` configures the Playwright MCP server for browser-based testing. The coding agent uses this to take screenshots and verify UI features. - -### CLAUDE.md - -Add project-specific instructions for Claude Code in `CLAUDE.md`. The agent reads this file at the start of every session. - -## Requirements - -- **Node.js** >= 18 (v24 LTS recommended) -- **npm** -- **git** -- **Claude CLI** (`claude`) or **Codex CLI** (`codex`) -- **Bash shell** (Git Bash or WSL on Windows) diff --git a/template/init.sh b/template/init.sh deleted file mode 100644 index 5e8dde7..0000000 --- a/template/init.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env bash -# {{projectName}} -- Development Environment Setup -# This script is idempotent -- safe to re-run at any time. -# Run it at the start of every agent session to ensure deps are current. - -set -euo pipefail - -echo "========================================" -echo " {{projectName}} -- Dev Environment Setup" -echo "========================================" -echo "" - -# ------------------------------------------ -# 1. Check required tools -# ------------------------------------------ -echo "Checking required tools..." - -check_tool() { - if ! command -v "$1" &> /dev/null; then - echo "ERROR: $1 is not installed. Please install it before continuing." - exit 1 - fi -} - -check_tool node -check_tool npm -check_tool git - -# Check Node.js version (need >= 18) -NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1) -if [ "$NODE_VERSION" -lt 18 ]; then - echo "ERROR: Node.js >= 18 required. Current: $(node -v)" - exit 1 -fi - -echo " node: $(node -v)" -echo " npm: $(npm -v)" -echo " git: $(git --version)" -echo "" - -# ------------------------------------------ -# 2. Install dependencies (root workspace) -# ------------------------------------------ -echo "Installing dependencies..." - -if [ -f "package.json" ]; then - npm install -else - echo "WARNING: No package.json found at project root." - echo " If this is the initializer session, you need to scaffold the project first." -fi - -echo "" - -# ------------------------------------------ -# 3. Install sub-package dependencies -# ------------------------------------------ -if [ -d "functions" ] && [ -f "functions/package.json" ]; then - echo "Installing function dependencies..." - (cd functions && npm install) - echo "" -fi - -# ------------------------------------------ -# 4. Generate Prisma client (if applicable) -# ------------------------------------------ -if [ -f "prisma/schema.prisma" ]; then - echo "Generating Prisma client..." - npx prisma generate - echo "" -else - echo "NOTE: prisma/schema.prisma not found yet. Skipping Prisma generate." - echo "" -fi - -# ------------------------------------------ -# 5. Set up environment file -# ------------------------------------------ -if [ -f ".env.example" ] && [ ! -f ".env.local" ]; then - echo "Creating .env.local from .env.example..." - cp .env.example .env.local - echo " IMPORTANT: Edit .env.local with your actual credentials." - echo "" -elif [ -f ".env.local" ]; then - echo ".env.local already exists -- skipping copy." - if ! grep -qE '^(DEV_PORT|PORT)=' .env.local; then - echo "DEV_PORT={{devPort}}" >> .env.local - echo "Added DEV_PORT={{devPort}} to .env.local." - fi - echo "" -fi - -# ------------------------------------------ -# 6. Run database migrations (if applicable) -# ------------------------------------------ -if [ -f "prisma/schema.prisma" ] && [ -f ".env.local" ]; then - echo "Pushing database schema..." - npx prisma db push --skip-generate 2>/dev/null || { - echo "NOTE: prisma db push failed. This is expected if DATABASE_URL is not configured." - } - echo "" -fi - -# ------------------------------------------ -# 7. Status summary -# ------------------------------------------ -echo "========================================" -echo " Setup Summary" -echo "========================================" -echo "" -echo " Node.js: $(node -v)" -echo " npm: $(npm -v)" - -if [ -f "package.json" ]; then - echo " Root deps: installed" -else - echo " Root deps: NOT FOUND (need package.json)" -fi - -if [ -f "prisma/schema.prisma" ]; then - echo " Prisma: schema found" -else - echo " Prisma: no schema yet" -fi - -if [ -f ".env.local" ]; then - echo " Environment: .env.local exists" - DEV_PORT_VALUE="$(grep -E '^(DEV_PORT|PORT)=' .env.local | tail -n 1 | cut -d= -f2- || true)" - echo " Dev port: ${DEV_PORT_VALUE:-{{devPort}}}" -else - echo " Environment: NO .env.local (create from .env.example)" - echo " Dev port: {{devPort}}" -fi - -echo "" -echo " To start dev server: ./scripts/dev-up.sh" -echo " To clean stale dev servers: ./scripts/dev-cleanup.sh" -echo " To run tests: npm test" -echo " To check types: npx tsc --noEmit" -echo "" -echo "========================================" -echo " Ready!" -echo "========================================" diff --git a/template/ralph.sh b/template/ralph.sh deleted file mode 100644 index 7d68531..0000000 --- a/template/ralph.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -# Usage: ralph.sh [--claude|--codex] -# Default runner: claude - -RUNNER="claude" -if [[ "$1" == "--codex" ]]; then - RUNNER="codex" - shift -elif [[ "$1" == "--claude" ]]; then - RUNNER="claude" - shift -fi - -if [ -z "$1" ]; then - echo "Usage: $0 [--claude|--codex] " - exit 1 -fi - -PROMPT="$(cat ./specs/phase1/prompts/coding_prompt.md)" - -# --- Server lifecycle: start once, clean up on exit --- -echo "=== Initializing environment and starting dev server ===" -./scripts/dev-down.sh -./init.sh -./scripts/dev-up.sh || { echo "FATAL: dev server failed to start"; exit 1; } - -trap './scripts/dev-down.sh' EXIT - -echo "=== Running $1 iteration(s) with $RUNNER ===" - -for ((i=1; i<=$1; i++)); do - echo "Iteration $i" - echo "--------------------------------" - - if [ "$RUNNER" = "claude" ]; then - result=$(claude -p "$PROMPT" --allowedTools "Read,Write,Edit,Glob,Grep,Bash,mcp__playwright" --output-format text 2>&1) || true - else - result=$(codex exec --yolo -o /dev/stdout "$PROMPT" 2>&1) || true - fi - - echo "$result" - - if [[ "$result" == *"COMPLETE"* ]]; then - echo "All tasks complete after $i iterations." - exit 0 - fi - - echo "" - echo "--- End of iteration $i ---" - echo "" -done - -echo "Reached max iterations ($1)" -exit 1 diff --git a/template/scripts/dev-cleanup.sh b/template/scripts/dev-cleanup.sh deleted file mode 100644 index 03b549c..0000000 --- a/template/scripts/dev-cleanup.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Clean stale Ralph dev-server registry entries and optionally stop live servers. - -set -euo pipefail - -REGISTRY_DIR="${RALPH_HOME:-$HOME/.ralph}" -REGISTRY_FILE="$REGISTRY_DIR/servers.json" -KILL_LIVE="${1:-}" - -mkdir -p "$REGISTRY_DIR" - -node - "$REGISTRY_FILE" "$KILL_LIVE" <<'NODE' -const fs = require("fs"); -const [registryFile, killLive] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); - if (!Array.isArray(registry)) registry = []; -} catch { - registry = []; -} - -const survivors = []; -for (const entry of registry) { - if (!entry || !entry.pid) continue; - const pid = Number(entry.pid); - let alive = false; - try { - process.kill(pid, 0); - alive = true; - } catch {} - - if (alive && killLive === "--kill-live") { - try { - process.kill(pid, "SIGTERM"); - console.log(`Stopped ${entry.project} on port ${entry.port} (pid ${pid})`); - } catch {} - continue; - } - - if (alive) { - survivors.push(entry); - } else { - console.log(`Removed stale registry entry for ${entry.project} on port ${entry.port}`); - } -} - -fs.writeFileSync(registryFile, JSON.stringify(survivors, null, 2) + "\n"); -NODE diff --git a/template/scripts/dev-down.sh b/template/scripts/dev-down.sh deleted file mode 100644 index f01e630..0000000 --- a/template/scripts/dev-down.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Stop this project's dev server. -# Idempotent: safe to run when nothing is up. - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PID_FILE=".dev-server.pid" -REGISTRY_DIR="${RALPH_HOME:-$HOME/.ralph}" -REGISTRY_FILE="$REGISTRY_DIR/servers.json" - -load_env_file() { - local file="$1" - if [ ! -f "$file" ]; then return 0; fi - set -a - # shellcheck disable=SC1090 - source "$file" - set +a -} - -load_env_file ".env" -load_env_file ".env.local" - -DEV_PORT="${DEV_PORT:-${PORT:-{{devPort}}}}" -PORT="$DEV_PORT" - -kill_pid() { - local pid="$1" - if [ -z "$pid" ]; then return 0; fi - if ! kill -0 "$pid" 2>/dev/null; then return 0; fi - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "$pid" 2>/dev/null || return 0 - sleep 0.5 - done - kill -9 "$pid" 2>/dev/null || true -} - -registry_update() { - mkdir -p "$REGISTRY_DIR" - node - "$REGISTRY_FILE" "$ROOT_DIR" <<'NODE' -const fs = require("fs"); -const [registryFile, project] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); - if (!Array.isArray(registry)) registry = []; -} catch { - registry = []; -} -registry = registry.filter((entry) => entry && entry.project !== project); -registry = registry.filter((entry) => { - if (!entry.pid) return false; - try { - process.kill(Number(entry.pid), 0); - return true; - } catch { - return false; - } -}); -fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2) + "\n"); -NODE -} - -if [ -f "$PID_FILE" ]; then - PID="$(cat "$PID_FILE" 2>/dev/null || true)" - if [ -n "$PID" ]; then - kill_pid "$PID" - echo "Stopped dev server (pid $PID)" - fi - rm -f "$PID_FILE" -fi - -registry_update -exit 0 diff --git a/template/scripts/dev-up.sh b/template/scripts/dev-up.sh deleted file mode 100644 index 11dad15..0000000 --- a/template/scripts/dev-up.sh +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -# Start the dev server in the background. -# Idempotent: stops this project's stale server first, then waits until ready. - -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PID_FILE=".dev-server.pid" -LOG_FILE=".dev-server.log" -REGISTRY_DIR="${RALPH_HOME:-$HOME/.ralph}" -REGISTRY_FILE="$REGISTRY_DIR/servers.json" -READY_TIMEOUT="${READY_TIMEOUT:-180}" - -load_env_file() { - local file="$1" - if [ ! -f "$file" ]; then return 0; fi - set -a - # shellcheck disable=SC1090 - source "$file" - set +a -} - -load_env_file ".env" -load_env_file ".env.local" - -DEV_PORT="${DEV_PORT:-${PORT:-{{devPort}}}}" -PORT="$DEV_PORT" -DEV_COMMAND="${DEV_COMMAND:-npm run dev}" - -kill_pid() { - local pid="$1" - if [ -z "$pid" ]; then return 0; fi - if ! kill -0 "$pid" 2>/dev/null; then return 0; fi - kill "$pid" 2>/dev/null || true - for _ in $(seq 1 10); do - kill -0 "$pid" 2>/dev/null || return 0 - sleep 0.5 - done - kill -9 "$pid" 2>/dev/null || true -} - -registry_update() { - local action="$1" - local pid="${2:-}" - mkdir -p "$REGISTRY_DIR" - node - "$REGISTRY_FILE" "$action" "$ROOT_DIR" "$PORT" "$pid" <<'NODE' -const fs = require("fs"); -const [registryFile, action, project, port, pid] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); - if (!Array.isArray(registry)) registry = []; -} catch { - registry = []; -} -registry = registry.filter((entry) => entry && entry.project !== project); -registry = registry.filter((entry) => { - if (!entry.pid) return false; - try { - process.kill(Number(entry.pid), 0); - return true; - } catch { - return false; - } -}); -if (action === "register") { - registry.push({ - project, - port: Number(port), - pid: Number(pid), - started: new Date().toISOString(), - }); -} -fs.writeFileSync(registryFile, JSON.stringify(registry, null, 2) + "\n"); -NODE -} - -registry_pid_for_port() { - node - "$REGISTRY_FILE" "$ROOT_DIR" "$PORT" <<'NODE' -const fs = require("fs"); -const [registryFile, project, port] = process.argv.slice(2); -let registry = []; -try { - registry = JSON.parse(fs.readFileSync(registryFile, "utf8")); -} catch {} -const entry = Array.isArray(registry) - ? registry.find((item) => String(item.port) === String(port) && item.project !== project) - : undefined; -if (entry && entry.pid) process.stdout.write(String(entry.pid)); -NODE -} - -port_in_use() { - if command -v fuser >/dev/null 2>&1 && fuser -s "${PORT}/tcp" 2>/dev/null; then - return 0 - fi - if command -v lsof >/dev/null 2>&1 && [ -n "$(lsof -ti:"$PORT" 2>/dev/null || true)" ]; then - return 0 - fi - return 1 -} - -clear_own_port_processes() { - local registry_pid - registry_pid="$(registry_pid_for_port || true)" - if [ -n "$registry_pid" ] && kill -0 "$registry_pid" 2>/dev/null; then - echo "ERROR: port $PORT is registered to another Ralph project (pid $registry_pid)." >&2 - echo "Run that project's ./scripts/dev-down.sh or choose another DEV_PORT in .env.local." >&2 - exit 1 - fi -} - -# Stop this project's previous server, if any. -if [ -f "$PID_FILE" ]; then - OLD_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - kill_pid "$OLD_PID" - rm -f "$PID_FILE" -fi -registry_update unregister - -if port_in_use; then - clear_own_port_processes - echo "ERROR: port $PORT is already in use by an unregistered process." >&2 - echo "Stop that process or set DEV_PORT to another value in .env.local." >&2 - exit 1 -fi - -: > "$LOG_FILE" -PORT="$PORT" DEV_PORT="$DEV_PORT" bash -lc "$DEV_COMMAND" >> "$LOG_FILE" 2>&1 & -NEW_PID=$! -disown "$NEW_PID" 2>/dev/null || true -echo "$NEW_PID" > "$PID_FILE" -registry_update register "$NEW_PID" -echo "Started dev server (pid $NEW_PID) on port $PORT, logging to $LOG_FILE" - -DEADLINE=$(( $(date +%s) + READY_TIMEOUT )) -while :; do - if ! kill -0 "$NEW_PID" 2>/dev/null; then - echo "ERROR: dev server process exited before becoming ready." >&2 - echo "---- last 40 log lines ----" >&2 - tail -n 40 "$LOG_FILE" >&2 || true - rm -f "$PID_FILE" - registry_update unregister - exit 1 - fi - if grep -qE '(Ready in|started server on|Local:[[:space:]]+http|localhost:|http://)' "$LOG_FILE" 2>/dev/null; then - echo "Dev server ready on http://localhost:${PORT}/ (per log)" - exit 0 - fi - if curl -fsS --connect-timeout 3 --max-time 5 -o /dev/null "http://localhost:${PORT}/" 2>/dev/null \ - || curl -sS --connect-timeout 3 --max-time 5 -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -qE '^[23][0-9][0-9]$'; then - echo "Dev server ready on http://localhost:${PORT}/" - exit 0 - fi - if [ "$(date +%s)" -ge "$DEADLINE" ]; then - echo "ERROR: dev server did not become ready within ${READY_TIMEOUT}s." >&2 - echo "---- last 40 log lines ----" >&2 - tail -n 40 "$LOG_FILE" >&2 || true - exit 1 - fi - sleep 0.5 -done diff --git a/template/specs/phase1/feature_list.json.hbs b/template/specs/phase1/feature_list.json.hbs deleted file mode 100644 index 9d3365b..0000000 --- a/template/specs/phase1/feature_list.json.hbs +++ /dev/null @@ -1,66 +0,0 @@ -{ - "version": 1, - "generated_by": "create-ralph-loop", - "generated_at": "{{createdAt}}", - "total_features": 5, - "features": [ - { - "id": "INFRA-001", - "priority": 1, - "category": "infrastructure", - "description": "Project scaffolded with required dependencies installed and dev server running", - "steps": [ - "Verify package.json exists with all required dependencies", - "Run 'npx tsc --noEmit' and confirm zero errors", - "Navigate to http://localhost:{{devPort}} and verify the page loads" - ], - "passes": false - }, - { - "id": "INFRA-002", - "priority": 2, - "category": "infrastructure", - "description": "Database schema defined and migrations applied successfully", - "steps": [ - "Verify database schema file exists with all models", - "Verify database can be queried without errors", - "Verify database client singleton is exported from src/lib/db.ts" - ], - "passes": false - }, - { - "id": "UI-001", - "priority": 3, - "category": "ui", - "description": "Application shell with navigation layout and placeholder pages", - "steps": [ - "Navigate to http://localhost:{{devPort}} and verify main layout renders", - "Verify navigation links are present and functional", - "Verify all placeholder pages load without errors" - ], - "passes": false - }, - { - "id": "FEAT-001", - "priority": 4, - "category": "feature", - "description": "TODO: Replace with your first feature description", - "steps": [ - "TODO: Define verification step 1", - "TODO: Define verification step 2" - ], - "passes": false - }, - { - "id": "FEAT-002", - "priority": 5, - "category": "feature", - "description": "TODO: Replace with your second feature description", - "steps": [ - "TODO: Define verification step 1", - "TODO: Define verification step 2" - ], - "passes": false - } - ] -} diff --git a/template/specs/phase1/prompts/adopt_spec_prompt.md.hbs b/template/specs/phase1/prompts/adopt_spec_prompt.md.hbs deleted file mode 100644 index 82a023e..0000000 --- a/template/specs/phase1/prompts/adopt_spec_prompt.md.hbs +++ /dev/null @@ -1,42 +0,0 @@ -# {{projectName}} -- Adoption Spec Generation Prompt - -You are adopting Ralph Loop into an existing codebase for **{{projectName}}**, {{projectDescription}}. - -Your job is to inspect the current application and generate Ralph-compatible planning files that describe what already exists and what remains to improve. Do not scaffold a new app. Do not replace the existing framework, source layout, package manager, or project configuration. - -## Deliverables - -Write these files: - -- `specs/phase1/PRD.md` -- `specs/phase1/app_spec.txt` -- `specs/phase1/feature_list.json` - -## Process - -1. Inspect the repository structure, `package.json`, framework config, source directories, routes, components, APIs, tests, and existing documentation. -2. Generate `PRD.md` as a reverse-engineered product requirements document for the app as it exists today. -3. Generate `app_spec.txt` as a technical reference for coding agents. Include the actual detected stack, important modules, data models or storage assumptions, routes, business logic, UI layout, environment variables, and testing commands. -4. Generate `feature_list.json` using this shape: - -```json -{ - "version": 1, - "generated_by": "create-ralph-loop --adopt --generate-specs", - "generated_at": "{{createdAt}}", - "total_features": 0, - "features": [] -} -``` - -For features already implemented in the existing app, set `"passes": true`. For missing, broken, or follow-up work that Ralph should implement later, set `"passes": false`. - -## Rules - -- Do not modify application source code. -- Do not run destructive commands. -- Do not install dependencies unless required to inspect the project and the user has already configured the environment. -- Do not change `package.json`, lockfiles, framework config, or existing tests. -- Keep each feature independently testable with 2-5 concrete verification steps. -- Prefer the project's existing scripts and conventions over generic defaults. -- If something cannot be determined from the codebase, document it as an open question in the generated specs instead of inventing certainty. diff --git a/template/specs/phase1/prompts/coding_prompt.md.hbs b/template/specs/phase1/prompts/coding_prompt.md.hbs deleted file mode 100644 index 9ef6069..0000000 --- a/template/specs/phase1/prompts/coding_prompt.md.hbs +++ /dev/null @@ -1,204 +0,0 @@ -# {{projectName}} -- Coding Session - -You are continuing development on {{projectName}}, {{projectDescription}}. Each session you implement ONE FEATURE ONLY, test it, and leave the codebase in a clean, merge-ready state. - -Follow these 10 steps in order. Do not skip any step. Do not do them out of order. - ---- - -## Step 1: Orientation - -Get your bearings. Run these commands to understand where you are and what has happened: - -```bash -pwd -git log --oneline -20 -``` - -Then read these files: - -- `progress.txt` -- session-by-session progress log -- `specs/phase1/feature_list.json` -- the complete feature list with pass/fail status -- `specs/phase1/app_spec.txt` -- the application specification (for reference) - -Take note of: which features pass, which don't, what the last session accomplished, and what it recommended as next priorities. - -## Step 2: Server Check - -The dev server should already be running (started by the loop driver). Verify it: - -```bash -set -a -[ -f .env ] && . ./.env -[ -f .env.local ] && . ./.env.local -set +a -DEV_PORT="${DEV_PORT:-${PORT:-{{devPort}}}}" -curl -sS --connect-timeout 5 -o /dev/null -w "%{http_code}" "http://localhost:${DEV_PORT}/" -``` - -If you get a 200, proceed. If the server is not responding: - -```bash -./scripts/dev-up.sh -``` - -Do NOT run `init.sh` or `npm install` unless you encounter a missing-dependency error during implementation. Do NOT run `npm run dev` directly -- it blocks the session. - -## Step 3: Regression Check - -Before starting new work, verify that 1-2 features already marked as `passes: true` still work correctly. Pick features relevant to the area you're about to modify. - -If you find a regression: - -1. Fix it immediately -2. Commit the fix with a descriptive message -3. Note it in your progress entry - -Do NOT proceed to new work if existing features are broken. - -## Step 4: Feature Selection - -Open `specs/phase1/feature_list.json` and find the highest-priority feature where `passes` is `false`. This is the feature you will implement this session. - -Rules: - -- IMPORTANT: Work on ONLY ONE FEATURE per session! -- Respect priority ordering -- lower priority numbers first -- If a feature depends on another that hasn't passed yet, implement the dependency first -- If you finish early and the feature is verified, you may start the next one - -Announce which feature you are working on before writing any code. - -## Step 5: Implementation - -Write the code needed to make this feature work. Reference `specs/phase1/app_spec.txt` for specifications on models, API routes, scoring rules, etc. - -Guidelines: - -- Keep changes focused on the selected feature -- Reuse existing utilities in `src/lib/` -- Follow existing code patterns and conventions -- Do not refactor unrelated code -- Do not add features beyond what the feature description specifies - -## Step 6: Testing - -Test your implementation thoroughly: - -IMPORTANT: Chrome is installed and working. Do not skip Playwright testing. -Do not reference previous sessions. The browser MCP tools are -available and functional. Use them when needed for verification. - -- **Unit tests**: If the feature involves pure logic (scoring, validation, transforms), write or update tests in `__tests__/unit/` -- **Integration tests**: If the feature involves API routes, write or update tests in `__tests__/integration/` -- **Manual verification**: Follow the feature's `steps` exactly. If steps involve UI, use Playwright MCP to: - 1. Navigate to the local server URL to verify each step - 2. Take a screenshot, and save each screenshot as `specs/phase1/screenshots/[task-name].png` - -Run the test suite: - -```bash -npm test # Unit + integration tests -``` - -Do not mark a feature as passing unless ALL verification steps succeed. - -## Step 7: Update Feature List - -**ONLY after successful verification**, update the feature in `specs/phase1/feature_list.json`: - -```json -"passes": true -``` - -**IT IS UNACCEPTABLE TO:** - -- Remove any feature from the list -- Edit the description or steps of any feature -- Reorder features -- Mark a feature as `passes: true` without actually verifying it works - -The `passes` field is the ONLY thing you may change, and ONLY from `false` to `true`. - -If a feature does not pass verification, leave it as `false` and note the issue in your progress entry. Do not mark partial implementations as passing. - -## Step 8: Progress Notes - -Append a new session entry to `progress.txt`: - -``` -## Session N -- [Feature Category] -Date: [today's date] - -### Feature Worked On -- [Feature ID]: [Description] -- Status: PASSED / FAILED / PARTIAL - -### What Was Done -- [Specific changes made] -- [Files created or modified] - -### Testing -- [How the feature was verified] -- [Test results] - -### Blockers / Issues -- [Any problems encountered] -- [Workarounds applied] - -### Next Priorities -- [Recommended next feature(s) based on dependency order] -- [Any setup or prep needed for next session] -``` - -## Step 9: Git Commit - -AFTER EACH TASK IS COMPLETE: Commit your work with a descriptive message: - -```bash -git add -git commit -m "feat: - -- Implements feature : -- -- " -``` - -Rules: - -- Stage specific files, not `git add -A` -- Write descriptive commit messages that explain the "why" -- If you need to revert bad changes, use `git checkout -- ` or `git revert` -- Make the commit BEFORE updating the progress file (so git log is accurate) - -## Step 10: Session Cleanup - -Before ending your session, ensure a clean state: - -```bash -npx tsc --noEmit # TypeScript compiles cleanly -git status # Working tree is clean (everything committed) -``` - -Do NOT stop the dev server -- the loop driver manages its lifecycle. - -The clean-state principle: leave the codebase in a state where any developer (or agent) could begin work on the next feature without cleaning up your mess first. Code should be merge-ready quality -- no console.logs left in, no commented-out experiments, no half-finished work. - -If tsc fails: - -1. Fix it -2. Commit the fix -3. Update your progress notes -4. Re-run the check - ---- - -## IMPORTANT REMINDERS - -- ONLY ONE FEATURE per session. Do not try to implement multiple features at once. -- Test BEFORE marking a feature as passing. Premature victory is the most common failure mode. -- If something is broken from a previous session, fix it FIRST. -- Use `git revert` or `git checkout` to undo bad changes rather than trying to manually fix a mess. -- Do NOT modify this prompt file or the initializer prompt. -- Read `specs/phase1/app_spec.txt` when you need details about models, routes, scoring rules, etc. -- When in doubt, commit your work and note the uncertainty in your progress file. A partial, clean commit is better than a broken working tree. diff --git a/template/specs/phase1/prompts/init_prompt.md.hbs b/template/specs/phase1/prompts/init_prompt.md.hbs deleted file mode 100644 index 401e5c7..0000000 --- a/template/specs/phase1/prompts/init_prompt.md.hbs +++ /dev/null @@ -1,197 +0,0 @@ -# {{projectName}} — Initialization Prompt - -You are the initializer agent for **{{projectName}}**, {{projectDescription}}. Your job is to generate the technical specs from the PRD, then scaffold the project from zero to a state where coding agents can begin implementing features one at a time. - -You do NOT implement features. You build the foundation that coding agents will build on. - ---- - -## What You Are Building - -Read the full PRD at `specs/phase1/PRD.md` for requirements context. This is YOUR primary source of truth. - ---- - -## Your Deliverables (in order) - -Complete each step fully before moving to the next. Do not skip steps. - -### Step 1: Generate the Application Specification - -Read `specs/phase1/PRD.md` thoroughly. Then read the template at `specs/phase1/app_spec.txt`. - -**Replace every TODO** in `app_spec.txt` with real, specific content derived from the PRD. Fill in every section: - -1. **Tech Stack** — Keep the defaults (Next.js, shadcn/ui, Prisma + SQLite, Jest + Playwright, Claude API) unless the PRD requires something different. List every additional dependency the project needs. -2. **Project Structure** — Design the directory layout based on the features in the PRD. Include all route files, components, and library modules. -3. **Data Models** — Write complete Prisma models for every entity described in the PRD. Include all fields, types, relations, and constraints. -4. **API Routes** — Define every endpoint: method, path, request body, response shape, and status codes. Derive these from the PRD's functional requirements. -5. **Business Logic** — Describe algorithms, validation rules, processing pipelines, or scoring logic. Extract these from the PRD's functional requirements and user stories. -6. **UI Layout** — Define the navigation structure, page layout, and key components for each page in the PRD. -7. **Environment Variables** — List all required env vars with example values. - -Write the completed spec back to `specs/phase1/app_spec.txt`. - -### Step 2: Generate the Feature List - -Read the completed `specs/phase1/app_spec.txt` and `specs/phase1/PRD.md`. Then read the template at `specs/phase1/feature_list.json`. - -**Replace the template** with a complete feature list derived from the PRD and app spec. Follow these rules: - -- Keep `INFRA-001` (project scaffold) and `INFRA-002` (database) as the first two features — they validate the scaffolding you will do in later steps. -- Keep `UI-001` (application shell) as the third feature. -- Add features for every functional requirement in the PRD. Each feature should be a single, testable unit of work that a coding agent can implement in one session. -- Assign IDs using category prefixes: `INFRA-` for infrastructure, `UI-` for UI-only work, `API-` for API routes, `FEAT-` for full-stack features. -- Set priorities so dependencies come first (e.g., API routes before the UI that calls them). -- Write 2-5 concrete verification steps for each feature. Each step should be something a coding agent can actually check (curl an endpoint, click a button, query the database). -- Set all `passes` fields to `false`. -- Update `total_features` to match the actual count. - -Write the completed feature list back to `specs/phase1/feature_list.json`. - -### Step 3: Initialize the Next.js Project - -```bash -npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm -``` - -If `package.json` already exists, skip this step. - -After scaffolding: - -- Verify `tsconfig.json` has `"strict": true` -- Install additional dependencies per `specs/phase1/app_spec.txt` (Section 1: Tech Stack) -- Initialize shadcn/ui: - ```bash - npx shadcn@latest init - ``` - Then install UI components as specified in the app spec. - -### Step 4: Set Up Database (if applicable) - -If the app spec defines data models: - -```bash -npx prisma init --datasource-provider sqlite -``` - -Write the Prisma schema to `prisma/schema.prisma` exactly as specified in the app spec (Data Models section). - -```bash -npx prisma generate -npx prisma db push -``` - -Create the Prisma client singleton at `src/lib/db.ts`. - -### Step 5: Create Environment Configuration - -Create `.env.example` with all required variables from the app spec. -Copy it to `.env.local` if `.env.local` does not exist. - -### Step 6: Create Shared Types - -Create `src/types/index.ts` with TypeScript types that mirror the data models and add domain-specific enums as defined in the app spec. - -### Step 7: Create the Application Shell - -Build the layout and navigation structure as defined in the app spec (UI Layout section): - -1. **Root layout** (`src/app/layout.tsx`): Import global styles, set up the app shell -2. **Navigation components**: As specified in the app spec -3. **Placeholder pages**: Create route files for all pages with "coming soon" placeholders - -### Step 8: Create Stub API Routes - -Create all API route files with basic structure but minimal implementation. Each should return a placeholder response so the route exists and responds. Reference the app spec (API Routes section) for the full list. - -At minimum, implement the health endpoint fully: -- `src/app/api/health/route.ts` — return `{ status: "ok", timestamp }` - -### Step 9: Create Directory Structure for Future Work - -Create empty directories and placeholder files for the coding agents to fill in. Reference the app spec (Project Structure section) for the full layout. - -Each placeholder file should contain a comment explaining its purpose and an empty exported function signature, so coding agents know what to implement. - -### Step 10: Configure Jest - -Create `jest.config.js` at the project root with ts-jest preset and path aliases matching `tsconfig.json`. - -Add to `package.json` scripts: - -```json -"test": "jest", -"test:watch": "jest --watch" -``` - -### Step 11: Verify Everything Works - -Run these checks and fix any issues before finishing: - -```bash -npx tsc --noEmit # TypeScript compiles cleanly -npm test # Jest runs (0 tests is OK, no errors) -./scripts/dev-up.sh # Dev server starts on DEV_PORT -DEV_PORT="${DEV_PORT:-${PORT:-{{devPort}}}}" -curl "http://localhost:${DEV_PORT}" # Page loads -curl "http://localhost:${DEV_PORT}/api/health" # Health endpoint responds -``` - -Stop the dev server after verification with `./scripts/dev-down.sh`. - -### Step 12: Initial Git Commit - -```bash -git add -A -git commit -m "chore: scaffold {{projectName}} project - -- Initial project setup from specs -- Application shell with navigation -- Stub API routes -- Directory structure for all modules -- Jest configuration" -``` - -### Step 13: Write Initial Progress Entry - -Update `progress.txt` with: - -``` -## Session 0 — Project Initialization -Date: [today's date] - -### What Was Done -- Scaffolded project from specs -- Set up database (if applicable) -- Built application shell -- Created stub API routes -- Set up directory structure for all modules -- Configured Jest -- Initial git commit - -### Project State -- All features in feature_list.json are marked as `passes: false` -- Application shell loads at localhost:{{devPort}} -- Health endpoint responds at /api/health -- No business logic implemented yet - -### Next Priorities -- INFRA-001: Verify scaffolding passes -- INFRA-002: Database verification -- Then proceed through features by priority order -``` - ---- - -## Rules - -- Do NOT implement any business logic. Coding agents handle that. -- DO generate `specs/phase1/app_spec.txt` and `specs/phase1/feature_list.json` in Steps 1-2 — these are your deliverables. -- After Step 2, do NOT modify `specs/phase1/feature_list.json` or `specs/phase1/app_spec.txt` — they become the contract for coding agents. -- Do NOT modify `specs/phase1/prompts/coding_prompt.md` — it is the coding workflow. -- Do NOT mark any features as passing. You are setting up the scaffold, not implementing features. -- DO leave the project in a state where `npx tsc --noEmit` passes with zero errors. -- DO leave the project in a state where `npm run dev` starts the dev server successfully. -- DO make one clean initial git commit with all scaffolding. -- If you encounter a decision not covered by the app spec, make a reasonable choice and document it in a comment. Do not block on open questions. diff --git a/template/specs/phase1/prompts/prd_prompt.md.hbs b/template/specs/phase1/prompts/prd_prompt.md.hbs deleted file mode 100644 index 0a2a2cf..0000000 --- a/template/specs/phase1/prompts/prd_prompt.md.hbs +++ /dev/null @@ -1,55 +0,0 @@ -# {{projectName}} — PRD Generation Prompt - -You are a product analyst. Your job is to take a raw product idea and produce a complete Product Requirements Document for **{{projectName}}**. - ---- - -## The Idea - -{{projectDescription}} - -> **Instructions to the user:** Before running this prompt, edit the project description above (or replace it with a more detailed description of your idea). The more detail you provide, the better the PRD will be. Include things like: -> - What the app does and who it's for -> - Key features and workflows -> - Any technical constraints or preferences -> - What "done" looks like for an MVP - ---- - -## Your Task - -Read the PRD template at `specs/phase1/PRD.md`. It contains section headers with TODO placeholders. - -**Replace every TODO** with real, specific content based on the idea above. Write the completed PRD back to `specs/phase1/PRD.md`. - -### Section Guidelines - -1. **Executive Summary** — 2-3 sentences. What is this product, who uses it, and what does the MVP include? - -2. **Problem Statement** — What pain point does this solve? Who experiences it? What's the cost of not solving it? - -3. **Goals and Non-Goals** — 3-5 concrete MVP goals. Non-goals should explicitly fence off v2+ scope so coding agents don't over-build. - -4. **User Stories** — 5-10 user stories in "As a [role], I want to [action] so that [benefit]" format. Cover the core workflows. - -5. **Functional Requirements** — Be specific: - - **Core Features:** List every feature the MVP needs, with enough detail that a developer could estimate the work. - - **Data Requirements:** What data does the system store, process, or display? What are the key entities? - - **API Requirements:** What endpoints are needed? What do they accept and return? - - **UI Requirements:** What pages exist? What can the user do on each page? - -6. **Non-Functional Requirements** — For an MVP, keep it practical: basic performance expectations, security basics (auth if needed), and a note on scalability scope (e.g., "single-user demo" or "multi-tenant"). - -7. **Tech Stack Guidance** — Reference `specs/phase1/app_spec.txt` for the detailed spec. Note any specific libraries, APIs, or services the idea requires beyond the default stack (Next.js, Prisma, shadcn/ui). - -8. **Open Questions** — List 2-5 genuine decisions that could go either way. These help coding agents know where they have latitude. - ---- - -## Rules - -- Write for a coding agent audience — be precise and unambiguous. -- Scope to MVP. If a feature is nice-to-have, put it in Non-Goals. -- Do NOT modify any file other than `specs/phase1/PRD.md`. -- Do NOT start implementing code. This prompt only produces the PRD. -- Do NOT modify any other files in the specs directory. diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..868f8f6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["packages/**/*.test.ts", "e2e/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + testTimeout: 30000, + hookTimeout: 30000, + }, +});