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`.
-[](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,
+ },
+});