Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,46 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versions follow [semantic versioning](https://semver.org/) (minor for new
functionality, patch for fixes and internal changes).

## [1.5.0] - 2026-09-03

### Added

- Change requests are now planned against the application as it actually is.
Before asking the model for a plan, hifumi.dev reads the project's workspace
— the gems installed beyond the Rails defaults, the database tables and
their columns, the routes file, the list of source files under `app/`, and
the project's own `docs/` — and hands that to the planner alongside the
request. Plans name the real files, columns, routes and colours instead of
guessing: "let people delete an entry" no longer invents an authorization
step for an app that has no users, and a styling tweak names the template's
actual hex value rather than a CSS variable the app does not have. The first
build of a project is unaffected; there is no application to read at that
point. The snapshot travels with every change request's planning call — a
few kilobytes for a fresh app, up to ~35 KB for the largest existing one — so
plans cost a little more on a bring-your-own-key account.
- Three maintainer scripts for plans, none of which persists anything.
`bin/inspect-plan-application-modification <project_id> "<intent>"` dry-runs
the change planner and prints the workspace snapshot and the plan it
produces; `--blind` shows what the planner would have done without the
snapshot. `bin/inspect-plan-application-creation` dry-runs the first-build
planner and works again — the RubyLLM 2.0 upgrade had broken it.
`bin/inspect-plans <project_id>` reads back every plan already stored for a
project without calling a model.

### Changed

- Both planner prompts stopped asserting things that were never true of a
generated app: that Devise is installed (it is not — sign-in is planned with
Rails' own `has_secure_password` and sessions) and that hifumi.dev's own
design tokens such as `--accent` exist in the generated app. Plans that used
to hedge ("if it uses a CSS class…", "assuming a User model exists") are told
not to, now that they can see the answer.
- The documentation agent that runs after every build step is now asked to
keep each of the four `docs/` files under 8 000 characters, condensing stale
sections instead of appending to them. The planner reads each file up to
that length and cuts it there, per file — an oversized file loses its tail,
never a sibling file.

## [1.4.0] - 2026-08-24

### Changed
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Hosted at **[hifumi.dev](https://hifumi.dev)** · Source: this repo.
Deferred observations from Phase 2 (revisit later, not blockers):
- refused-tool-call pill UX (Step 6) — the `🌀 Build started` pill flashing when the LLM ignores the state rule. Nothing rescues it any more: the tool-side guard was deleted in `13e9c1c`, leaving the prompt rule and the `BadRequestError` banner (see the RubyLLM idempotency bullet below).
- deferred-request handling after `✅ Generation finished.` — see `docs/09-ideas/02-deferred-request-handling.md`.
- Step 7 wall-time margin (Step 7) — real run consumed ~900s vs the spike's 496s; the integration test's `WALL_TIME_BUDGET = 900` sits right at the edge. Bump the budget or investigate W2-phase slowdown (looks heavy on the docs-update agent) before relying on this in CI.
- Step 7 wall-time margin (Step 7) — real runs: 496s (spike), ~900s (May, before the per-revision hardening), 550s (2026-09-03, the run that re-validated the repaired E2E test). `WALL_TIME_BUDGET` is 1200 so a slow day is not a red; the budget assertion runs before the generated app's own suite. W2-phase (docs-update agent) is still the heaviest step if the number climbs again.

## Documentation structure

Expand Down Expand Up @@ -57,4 +57,5 @@ Additional sources from the Phase 1 spike:
- **LLM model selection**: never hardcode a model id at a call site — `lib/llm/stages.rb` (`LLM::Stages`, note the `LLM` acronym inflection) is the registry of the six LLM stages (chat, plan_creation, plan_modification, template, code, docs), their labels, factory defaults, and the curated `AVAILABLE_MODELS` list (full OpenRouter ids only, never `sonnet`/`haiku` aliases). Profiles store per-user defaults (`default_<stage>_model`), projects snapshot their own selection (`<stage>_model`) at creation; selectors live in the build tab (`model_selections/_pane`), the new-project form, and the account integrations pane. A new stage = registry entry + migration on both tables + threading at the call site. A new **model** = confirm the OpenRouter slug supports `structured_outputs` (plan/template stages) and `tools` (chat) → populate the RubyLLM registry in **every** environment → only then add the id to `AVAILABLE_MODELS`; that order is load-bearing, because RubyLLM owns its registry: it resolves against the `ruby_llm_models` table (its store) and falls back to the gem's bundled `models.json` only when that table is *empty*, so an id absent from both raises `ModelNotFoundError` on the four RubyLLM-backed stages. Check with `bin/verify-model-registry [candidate-id]`, fix with `RubyLLM.models.refresh!` plus a process restart (the registry is memoized per process); the refresh only ever adds or updates rows — per-provider fetch failures are logged and skipped, never allowed to empty the store. Full procedure in `docs/05-runbooks/03-llm-model-registry.md`. Selection applies on the OpenRouter path only — `roast_model_env` keeps claudesubscription runs on the operator's ENV/alias defaults, and an explicit `HIFUMI_DEV_MODEL`/`HIFUMI_DEV_DOCS_MODEL` always wins.
- **Agent sandbox (tenant isolation)**: the codegen agent runs `claude` with `skip_permissions!` on user-controlled prompts — treat it as untrusted code execution, so in production it must NOT run in the shared generator container. `ExecuteInstructionJob#execute_revision` wraps the roast invocation via `Roast::Sandbox.wrap` (`lib/roast/sandbox.rb`, plain builder, returns the `docker run` argv) into a throwaway `--rm` container that mounts ONLY this project's workspace (no `workspace_root`, no `/var/run/docker.sock`), runs entirely as the unprivileged `generator` user (`--user`, `--cap-drop=ALL`, zero cap-adds — uniform uid avoids the capless-root/mixed-ownership deadlock of issue #24; the workspace is re-relaxed `a+rwX` before every sandboxed run), and forwards env (incl. `OPENROUTER_API_KEY` and the per-project `HIFUMI_DEV_*` model selection) **by name** so secrets never hit argv. Gated by `sandboxed?` (production OR `FORCE_AGENT_SANDBOX=1`); dev stays direct (single-tenant, Claude-subscription transport, no Docker). Image = the generator's own, from `HIFUMI_AGENT_IMAGE` (set in `deploy.yml`). Not runtime-verifiable on macOS — see the verification checklist + residuals (generator-side socket, egress, bundle vendoring) in `docs/09-ideas/05-followups.md`.
- **Preview infrastructure**: `lib/preview/preview_manager.rb` (plain Ruby, not a Roast workflow) drives Docker. `lib/preview/Dockerfile{,.base}` are owned by this repo — never read from generated apps. `lib/preview/skeleton/` is the canonical fresh-Rails-app baseline copied into every workspace; regenerate with `bin/preview-regen-skeleton` when bumping Rails. Rebuild the base image with `bin/preview-rebuild-base` after Gemfile changes. The `preview-internal` Docker network is created without `--internal` on Docker Desktop (host port mapping wouldn't work otherwise) — Phase 4 reintroduces strict egress isolation on a Linux production host. In remote mode `PreviewManager#run_container` passes `PREVIEW_HOST=<id>.preview.<domain>` to the container; the skeleton-overlay's `preview_iframe.rb` initializer appends it to `Rails.application.config.hosts` so Rails 8's dev HostAuthorization doesn't 403 the kamal-proxy request.
- **Modification planner context**: `PlanApplicationModification::AdHocLLM` plans against a snapshot of the real workspace, built by `AppState.build(workspace:)` in `lib/app_state.rb` — gems beyond the default Gemfile, database tables (or the `db/migrate/` listing while `db/schema.rb` is not written yet), `config/routes.rb` verbatim, every `.rb/.erb/.js/.css` under `app/` minus `app/assets/builds/`, and the four `docs/` files. `ModifyApplication#execute` assembles it (inside its `rescue StandardError`, so an unreadable file degrades to a chat-safe error hash) and passes `context: { app_state: }`; the planner renders it after `Intent:`. The docs cap is **per file** (`AppState::DOC_FILE_CAP`, 8 000 chars), never on the total — a body cap evicts whichever file is last, and that is `frontend.md`, the only record of the app's palette. The W2.6 docs-writer prompt in `lib/roast/revision_workflow.rb` hardcodes the same 8 000 (it runs as a Roast subprocess, outside the autoloader) — change both together. The **creation** planner stays blind by ordering, not choice: `CreateApplication` persists the plan before `ExecuteInstructionJob` runs `rails new`, so no workspace exists yet and its system prompt is its only lever. Both planner prompts say only what IS there ("default Rails 8", Tailwind, Hotwire, `has_secure_password` for sign-in) — no gem-absence claims, no Propshaft/Importmap/Solid enumeration, no `--accent`-style token names. Verify any planner change with `bin/inspect-plan-application-modification <project_id> "<intent>" [--blind]` and `bin/inspect-plan-application-creation "<intent>"`: both dry-run the planner and persist nothing, and `--blind` is the same-session A/B against planning from the intent alone.
- **RubyLLM tools must be idempotent within a user turn**. RubyLLM's tool loop will sometimes call the same tool twice in adjacent assistant messages before either result lands, producing the order `assistant(use_X) → assistant(use_Y) → user(result_X) → user(result_Y)` — illegal for Anthropic, and the chat permanently rejects every subsequent message. **There is no tool-side guard any more** — the tool that carried one was deleted in `13e9c1c`. What stands today is a prompt rule (*"Call each tool AT MOST ONCE per user turn"*, `app/prompts/generator_agent/instructions.txt.erb:19`) plus the `RubyLLM::BadRequestError` banner in `ChatRespondJob::FRIENDLY_ERRORS` that turns an already-corrupt chat into *"This conversation can't be continued by the model. Please start a new project."* So a new tool needs its own in-band guard: return an error hash rather than raising, so the second `tool_use` still gets a `tool_result` and history stays valid. Diagnose corrupt chats with `bin/inspect-chat <project_id>` (works on prod via `kamal app exec`).
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,18 @@ kamal app exec --primary "bin/rails runner bin/inspect-chat 15" # against pr

Dumps a project's chat messages and `tool_calls` rows in order, then runs a structural pairing analysis (every `tool_result` must follow its matching assistant `tool_use`). Use when the chat fails with `RubyLLM::BadRequestError` ("unexpected `tool_use_id`...") — the dump tells you whether a tool was called twice in one turn or a tool_result is orphaned.

### Inspect stored and dry-run plans — `bin/inspect-plans`, `bin/inspect-plan-application-*`

```sh
bin/inspect-plans 42 # every plan stored for project 42 (DB only, no model call)
bin/inspect-plan-application-modification 42 "let people delete an entry" # dry-run the change planner with the workspace snapshot
bin/inspect-plan-application-modification 42 "..." --blind # same intent without the snapshot — the A/B
bin/inspect-plan-application-creation "a flower shop with inventory" # dry-run the first-build planner
kamal app exec --primary "bin/inspect-plans 42" # any of them against prod
```

`bin/inspect-plans` reads the database only. The two `inspect-plan-application-*` scripts call the planner LLM with the project owner's (or your profile's) OpenRouter key and persist nothing — run them before shipping any planner-prompt or `AppState` change; the "Modification planner context" convention in `CLAUDE.md` says what to look for.

### Run the test suite

```sh
Expand Down
6 changes: 3 additions & 3 deletions app/jobs/execute_instruction_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,9 @@ def init_docs_baseline(workspace)
docs_dir = File.join(workspace, "docs")
FileUtils.mkdir_p(docs_dir)
{
"architecture.md" => "# Architecture\n\n(empty — will be filled in by the first revision)\n",
"conventions.md" => "# Conventions\n\n(empty — will be filled in by the first revision)\n",
"domain.md" => "# Domain\n\n(empty — will be filled in by the first revision)\n",
"architecture.md" => "# Architecture\n\n#{AppState::PLACEHOLDER}\n",
"conventions.md" => "# Conventions\n\n#{AppState::PLACEHOLDER}\n",
"domain.md" => "# Domain\n\n#{AppState::PLACEHOLDER}\n",
"revision_notes.md" => "# Revision notes\n\n"
}.each { |name, content| File.write(File.join(docs_dir, name), content) }

Expand Down
2 changes: 1 addition & 1 deletion app/prompts/plan_application_creation_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ You are a Rails application planner. Given a user's plain-language intent, emit
Rules for the plan:
- 3 to 6 revisions.
- Each revision is one atomic, testable change ("add Product model with name/price", not "set up the shop").
- Assume the workspace is an already-initialized Rails 8 app with Tailwind + Hotwire + Devise gems available. Do NOT include `rails new` or gem installation steps.
- The workspace is a default Rails 8 app with Tailwind and Hotwire, on the default Gemfile. Do NOT include `rails new`. If the app needs sign-in, plan a revision that adds it — `has_secure_password` plus sessions is the Rails-native default.
- Prefer Rails Way: scaffolds, concerns, validations over custom abstractions.
- Mount the primary user-facing feature at the root path (`root to: "...#index"`) unless the user explicitly asked for a different landing page. The first revision that introduces that feature must set the root route.
- When the plan introduces more than one user-facing feature, include a revision that adds a top or side navigation menu in `app/views/layouts/application.html.erb` linking to each feature — unless the user explicitly asked for a different navigation pattern (e.g. single-page, dashboard-only).
Expand Down
7 changes: 5 additions & 2 deletions app/prompts/plan_application_modification_system.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
You are a Rails application planner. The application already exists in the workspace — Rails 8 is installed, gems are bundled, and previous revisions have shaped the schema, routes, views, and Tailwind theme.
You are a Rails application planner. The application already exists. The user turn carries a "Current application state" section describing it: installed gems, database tables, routes, the list of files under `app/`, and the project's own `docs/`. Read it before planning and ground every file path, table, route and colour in what it actually says.

Your job: given a user's plain-language change request, emit a short plan of one or more atomic revisions matching the required JSON schema.

Expand All @@ -8,7 +8,10 @@ Rules for the plan:
- DO NOT change the root route unless the user explicitly asks for it.
- DO NOT re-introduce models, controllers, or views that already exist. Reference existing files by path; describe modifications rather than scaffolds.
- DO NOT add a navigation menu unless the user explicitly asks for one. Modify the existing navigation only when relevant.
- Assume Tailwind, Hotwire, Devise, and the previously picked template's design tokens are already wired. Reference existing CSS variables (e.g. `--accent`, `--paper-100`) when applicable rather than introducing new ones.
- The app is a default Rails 8 installation — everything Rails ships with is there and needs no setup. On top of that, Tailwind and Hotwire (Turbo + Stimulus) ARE installed: use them, and reach for Turbo Frames / Turbo Streams / Stimulus rather than hand-written fetch or a JS framework.
- Plan only with what the "Gems" section names plus what a default Rails 8 install ships. For sign-in, the Rails-native route is `has_secure_password` plus sessions.
- Style with the literal Tailwind classes and hex values the app already uses. `docs/frontend.md` is the palette source; the file list tells you which views exist, not what is in them. Do NOT introduce CSS variables the app does not define.
- Do NOT hedge. The application state is given to you. Never write "if it uses X…", "whichever matches your design", "assuming Y exists", or "verify that…". Name the actual file, the actual class, the actual colour.
- Never reference "Claude", "Anthropic", or any LLM provider unless the user explicitly asks for that integration.
- Each revision's `prompt` is the full instruction passed to the implementer agent — concrete, file-level, verifiable. Mention specific files (e.g. "in `app/views/layouts/application.html.erb`, change …").
- Each revision's `summary` is a git-commit-style one-liner.
Expand Down
21 changes: 20 additions & 1 deletion app/services/plan_application_modification/ad_hoc_llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ module AdHocLLM

class InvalidResponse < StandardError; end

# Appended when there is no snapshot to give (a --blind probe, or a workspace
# gone between the tool being bound and the call). The system prompt tells
# the planner the state is given and forbids hedging; this is the one place
# that claim is false, so say so where the model reads it.
NO_STATE_NOTE = "No application state snapshot is available for this request. " \
"Say what you assume about existing files, tables and colours instead of asserting it."

def self.call(intent:, clarifications:, context:, openrouter_api_key:, model:)
user_prompt = build_user_prompt(intent, clarifications, context)
content = invoke_llm(system: SYSTEM_PROMPT, user: user_prompt, openrouter_api_key: openrouter_api_key, model: model)
Expand All @@ -17,12 +24,24 @@ def self.invoke_llm(system:, user:, openrouter_api_key:, model:)
chat.with_schema(PlanSchema).ask(user).parsed
end

def self.build_user_prompt(intent, clarifications, _context)
# The first deliberate divergence from the PlanApplicationCreation twin,
# which cannot have workspace context: the workspace does not exist when
# it runs (CreateApplication persists the plan before ExecuteInstructionJob
# runs `rails new`).
def self.build_user_prompt(intent, clarifications, context)
lines = [ "Intent: #{intent}" ]
if clarifications.present?
lines << "Clarifications:"
clarifications.each { |k, v| lines << " - #{k}: #{v}" }
end
# Last, matching RevisionPrompt.build, which leads with "## Task" and only
# then appends the stack inventory, the docs manifest and the workspace
# snapshot: request first, reference material after. Keeps the ask from
# being buried behind up to ~35 KB of listing (project_39, the largest). (There is no schema text in
# this turn to sit next to — with_schema ships as OpenRouter's
# `response_format` payload field.)
app_state = context.is_a?(Hash) ? context[:app_state] : nil
lines << "\n#{app_state.presence || NO_STATE_NOTE}"
lines.join("\n")
end

Expand Down
6 changes: 5 additions & 1 deletion app/tools/modify_application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ def execute(intent:, clarifications: {})
result = PlanApplicationModification.call(
intent: intent,
clarifications: clarifications,
context: { project_id: @project.id },
# Built here rather than in the planner so AdHocLLM stays a pure function
# of its arguments, and so the file reads sit inside the rescue below —
# ENOENT/EACCES on a workspace file degrades to a chat-safe error hash
# instead of orphaning the tool_use.
context: { app_state: AppState.build(workspace: @project.workspace_path) },
openrouter_api_key: @project.user.profile.openrouter_api_key,
model: @project.plan_modification_model
)
Expand Down
Loading
Loading