diff --git a/DOCS.md b/DOCS.md index 6d0752b..678c70e 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1118,12 +1118,15 @@ Every runner shares the same CLI contract: ``` node imp/fda_.mjs "" [--config imp/fia.config.yaml] - [--fda-id ] [--resume] [--agent ] [--debug] + [--fda-id ] [--resume] [--retry-unchanged] + [--llm ""]… [--agent ] [--debug] ``` The prompt may be inline text or a **file path** (a brief file is inlined). `--resume` requires `--fda-id` and may omit the prompt — it is reloaded from -the trace. `--agent` is only read by `fda_prompt`. `--debug` (or env +the trace. `--retry-unchanged` overrides the unchanged-tree guard on a bare +resume (§9.7). `--llm` runs THIS run on another LLM without touching the +roster (§9.3). `--agent` is only read by `fda_prompt`. `--debug` (or env `FIA_DEBUG`) prints full stack traces. A failed run always prints the exact resume command (`node imp/fda_.mjs --fda-id --resume`) — nothing is lost. Exit codes: 0 accepted · 1 any failure (including "phases green but @@ -1164,6 +1167,7 @@ node imp/fda_quick.mjs "Make the empty-state copy on /invoices friendlier" node imp/fda_qa.mjs "M1" node imp/fda_sdlc.mjs ai-docs/todos/briefs/task-12.md node imp/fda_plan_build_test.mjs --fda-id 3fa9c21b --resume # resume a failed run +node imp/fda_sdlc.mjs ai-docs/todos/briefs/task-12.md --llm "grok-4.6 high" # this run on another LLM ``` An engine exit without a parseable Report envelope fails fast with the last @@ -1268,6 +1272,22 @@ Engines (`coding_agent`): | `claude_code` | `claude` (override `CLAUDE_PATH`) | `sonnet`, `opus`, `haiku`, `fable`, or full names | Runs on the Claude Pro/Max plan. `effort` sets reasoning depth; system prompt via `--append-system-prompt` (preserves the cacheable prefix). | | `pi` | `pi` (override `PI_PATH`) | `openai-codex/gpt-5.6-sol`, `openrouter/…`, `xai/…`, `github-copilot/…` | Session continuity via a session FILE; `thinking` sets reasoning; per-agent `tools` and `harness_engineering` (Pi extensions). Subscription providers log in via `/login openai-codex` / `github-copilot`; API-key providers read their env var (`OPENROUTER_API_KEY`, `XAI_API_KEY`, …). | | `cursor` | `cursor-agent` (override `CURSOR_AGENT_PATH`) | picker ids like `sonnet-4.5`, `gpt-5`, `composer-1` | Cursor subscription; no token usage reported; system prompt is prepended to the first prompt. | +| `grok` | `grok` (override `GROK_PATH`; `~/.grok/bin/grok` is found even off PATH) | `grok-4.6`, `grok-4.5` (`grok models`) | Grok Build on the **xAI subscription** — `grok login` once (OAuth; the OIDC token in `~/.grok/auth.json` refreshes itself, and that file is what the FIA detects). Headless via `-p` + `--output-format streaming-messages-json` (the Claude stream-json dialect, so tool calls show live in the viewer/TUI); `--resume ` continues the session; role via `--rules` (append — never `--system-prompt-override`, the cacheable prefix stays); `effort` low\|medium\|high\|xhigh, clamped per model. `XAI_API_KEY` is stripped from the child env (with it the CLI bills per token). The first grok run trusts the project folder once (`~/.grok/trusted_folders.toml`) — untrusted, grok skips the project hooks in silence. | + +**One run on another LLM — `--llm`.** Every FDA accepts `--llm ""` +(repeatable): `[[,]=][ ]`. `--llm "grok-4.6 high"` +puts EVERY agent phase of that run on Grok 4.6 at high; `--llm "builder=opus +xhigh"` only the builder; `--llm "reviewer,scout=openai-codex/gpt-5.6-sol"` +keeps each agent's own level, clamped to the new engine's ladder. The roster +on disk is never written: the switch is printed at run start, traced as +`llm_override` (payload: agent, from, to, level_given, spec, source) and saved +in `imp/data/sessions//llm_override.json`, so `--resume` re-applies it +without the flag (a model that changed mid-run would break the engine session +and the cache); an explicit `--llm` on resume replaces it. An explicit level +applies to every phase of that agent for the run (the roster's +`phase_overrides` are dropped for it); declared `fallbacks` still apply if the +chosen engine is unavailable. Inside Pi: `/task 12 --llm "grok 4.6 high"`. +Same grammar as `imp llm set` (`modules/llm-target.mjs` is the single resolver). **Fallbacks** (up to 5 per agent) are walked in three stages. At **run start**, for *hard* unavailability (binary missing; Pi provider with no login and no API @@ -1416,8 +1436,18 @@ as disproportionate. notice at session start (SessionStart hook) and a PreToolUse hook blocks file edits and write-shaped bash commands (incl. `git commit`/`add`/…) aimed inside the repo — an external write mid-run would be attributed to - the phase agent and rolled back. Interactive Pi gets the same guard via the - `.pi/extensions/fda-lock.ts` extension, and Cursor via the + the phase agent and rolled back. **Grok Build reads the same + `.claude/settings.json`**, so both PreToolUse gates (this one and the + desktop guard) serve two dialects at once: the payload is read as + `tool_name|toolName` / `tool_input|toolInput`, grok's own tool ids count as + write/shell surfaces (`search_replace`, `run_terminal_command`), and a block + answers in both protocols in one object — exit 2 + the reason on stderr + (Claude Code) plus a stdout JSON carrying `decision: "deny"` and + `hookSpecificOutput` (grok honors the JSON regardless of the exit code). A + hook that speaks only one dialect is a **silent no-op** on the other engine, + which is why grok also needs the project folder trusted — granted + automatically before the first grok phase (§9.3). Interactive Pi gets the + same guard via the `.pi/extensions/fda-lock.ts` extension, and Cursor via the `.cursor/hooks.json` `beforeShellExecution` hook (`.cursor/hooks/fda-lock-cursor.mjs` — Cursor has no before-edit hook, so shell commands are the guarded surface; stray agent edits are still swept @@ -1520,9 +1550,14 @@ for the same reason. Four views, each a URL hash that survives reload: (component registry, `/ui-components` probe) and every `ai-docs/` document rendered, plus an `inbox · N open` badge. - **Agents** (`npm run agents`) — engine cards with install/login state and - fix hints per provider, the commands → phases → agents map, and the roster + fix hints per provider (Claude, Pi + its providers, Cursor and Grok Build — + the grok card shows the subscription login, its model list, whether the + project folder is trusted yet and a flag when `XAI_API_KEY` is set in the + environment), the commands → phases → agents map, and the roster editor: engine/model/reasoning/fallbacks per agent, curated model pickers - (live `cursor-agent --list-models` when Cursor is installed), a billing + (live `cursor-agent --list-models` when Cursor is installed, grok's own + `models_cache.json` when Grok Build is), reasoning pills that follow each + engine's ladder (grok stops at `xhigh`), a billing guard banner for `anthropic/…` models on the Pi engine, and a save that edits the YAML **preserving comments**, backs up first (`imp/data/backups/fia.config..yaml`) and answers **409** while a @@ -2524,8 +2559,8 @@ update_roster. | `/grill` | `[doc\|topic?]` | Stress-test the PRD one question at a time; decisions recorded and written back. | | `/prd` | `[focus?]` | Quick reviewer opinion on the PRD — never edits it. | | `/map` | `[notes?]` | Runs a conditional architecture checkpoint before planning; consequential decisions land in optional `architecture.md`, while simple plans skip it. Then PRD → `map.yaml` + screens-routes + issues/task-master + specs + registry seed + `/ui-components` + milestones; ends by opening the Plan page. | -| `/task` | `[number\|description?]` | ONE task: the task-sequencer writes the brief (enforcing the theme and env gates); exact `Mode: prototype` → `fda_prototype`, otherwise `fda_plan_build_test` (bigger/riskier normal work → `fda_sdlc`). On first failure: one automatic recovery (re-run / repair once); if that also fails: `npm run fda:phases -- `, resume with `--fda-id --resume`. | -| `/goal` | `[limit?] [--light]` | All unblocked tasks to done, one FDA per task (never batched), gates inside the loop, human-only steps handled MID-goal; `Mode: prototype` selects `fda_prototype` per brief, otherwise `--light` selects `fda_plan_build_test` and the default is `fda_sdlc`; on failure it recovers automatically while each failure names a NEW gap (the per-run recovery budget is capped in code by `verdict.mjs`), stopping only on a repeated violation, a terminal outcome or a spent budget — always with its recommended fix, which a plain "continue" from the engineer authorizes; every completed milestone automatically runs blocking `fda_qa`, drains docs with one `fda_document` and — when other milestones remain — ends the loop at the boundary with a 5-line handoff (one milestone per session: state is durable in `ai-docs/`, and a fresh session drops the orchestrator's accumulated context); ends with the app RUNNING + "How to test", then `/launch`. | +| `/task` | `[number\|description?] [--llm ""]` | ONE task: the task-sequencer writes the brief (enforcing the theme and env gates); exact `Mode: prototype` → `fda_prototype`, otherwise `fda_plan_build_test` (bigger/riskier normal work → `fda_sdlc`). On first failure: one automatic recovery (re-run / repair once); if that also fails: `npm run fda:phases -- `, resume with `--fda-id --resume`. `--llm "grok 4.6 high"` (or plain words — "on opus xhigh", "builder on codex") is passed through to the FDA as the run-scoped override (§9.3); the roster is never edited for it. | +| `/goal` | `[limit?] [--light] [--llm ""]` | All unblocked tasks to done, one FDA per task (never batched), gates inside the loop, human-only steps handled MID-goal; `Mode: prototype` selects `fda_prototype` per brief, otherwise `--light` selects `fda_plan_build_test` and the default is `fda_sdlc`; on failure it recovers automatically while each failure names a NEW gap (the per-run recovery budget is capped in code by `verdict.mjs`), stopping only on a repeated violation, a terminal outcome or a spent budget — always with its recommended fix, which a plain "continue" from the engineer authorizes; every completed milestone automatically runs blocking `fda_qa`, drains docs with one `fda_document` and — when other milestones remain — ends the loop at the boundary with a 5-line handoff (one milestone per session: state is durable in `ai-docs/`, and a fresh session drops the orchestrator's accumulated context); ends with the app RUNNING + "How to test", then `/launch`. | | `/feature` | `"request"` | Delta on an existing mapped system: size triage (module-sized routes UP to `/idea`), delta mini-grill, delta spec, DELTA tasks, approval before executing. Requires `map.yaml` (`/absorb` first otherwise). | | `/bug` | `"symptom"` | Classifies `direct` vs `rca`; ambiguous/risky defects get a versioned investigation and critical/sensitive/low-confidence RCAs require approval before claim. Then `fda_bug` enforces an assertion-failing reproduction before any fix. | | `/quick` | `"small change"` | Triage; SIMPLE runs `node imp/fda_quick.mjs` + the `Q-NNN` quick-log entry; COMPLEX routes to `/feature`/`/bug` naming the failed criterion. | @@ -2539,7 +2574,7 @@ update_roster. | `/design` | `images + scope` | Layout redesign from references — structure from the image, identity from OUR system. | | `/example` | `URL [notes] \| list` | Register an external reference on the shelf (license researched, `What NOT to take` mandatory). After adding one, cite its NOTES in `/grill ai-docs/PRD.md`, then run `/map` to reconcile approved PRD additions into open specs/tasks before implementation. | | `/agents` | — | Opens the viewer's Agents tab (`npm run agents -- --detach`) to edit engines/models/fallbacks; Pi is forbidden from editing `imp/fia.config.yaml` itself. | -| `/llm` | `["1 → fable"?]` | Numbered list of the FDA agents with the LLM each one runs on (phases included); the student answers by number or name and the switch is applied via `imp/scripts/fia-llm.mjs set` — the same comment-preserving, backed-up, run-locked write path as the Agents tab. Terminal twin: `imp llm` / `npm run llm`. | +| `/llm` | `["1 → fable"?]` | Numbered list of the FDA agents with the LLM each one runs on (phases included); the student answers by number or name and the switch is applied via `imp/scripts/fia-llm.mjs set` — the same comment-preserving, backed-up, run-locked write path as the Agents tab. Engines: `claude_code`, `pi` (Codex/any provider), `cursor`, `grok` (Grok Build — `grok-4.6`/`grok-4.5`). A model asked for "just this task" is NOT a roster change: that is `--llm` on the run (§9.3). Terminal twin: `imp llm` / `npm run llm`. | | `/defer` | `[n \| resume n]` | Postpone a task that cannot proceed right now (missing API keys, a paid account for later, a pending decision) — via `imp/scripts/task-defer.mjs`, never by hand: status → `deferred` in issue + index, its sealed holdout probes renamed `NN-*` → `_NN-*` (content untouched, reversible), deferral ledgered in `imp/data/deferrals.json` + noted in the inbox. `resume n` restores everything and sets `pending`. Refuses while an FDA run is live; the launch check warns about every open deferral. Terminal twin: `imp defer` / `npm run defer`. | | `/onboarding` | `[focus?] [--report-only]` | First command on an EXISTING system: chains `/absorb` → `/stack` → `/kit` in one guided pass (each stage's own prompt is the law; stages whose artifacts already exist can be kept and skipped), then hands over explaining the split — `/idea` for a MODULE-sized addition vs `/feature` for a one-sentence delta. The tour keeps a **resume rail** in the decision log (`open onboarding` → one stage note each → `close`): an interrupted session resumes from its last stage note instead of restarting. `--report-only` is the express path — the `/kit` stage presents its gap report and defers the design decisions to a later `/kit` run. | | `/absorb` | `[focus?]` | Brownfield onboarding (as-built PRD/map/conventions/registry, maintained wiki + digest stamp, and one canonical project skill in `.agents/skills/project/` with a Claude symlink; never `.pi` or `.cursor` copies). Divergent legacy copies in any engine stop for an explicit choice. | @@ -2826,7 +2861,7 @@ Four sections (five with `--gates`): | Section | What it checks | |---|---| -| **Engines (subscriptions)** | `claude` on PATH, the Codex login inside Pi (`~/.pi/agent/auth.json`), the Cursor CLI. **Informative, never an error** — which subscriptions to use is the professional's call. There is deliberately no `claude` login probe: no heuristic is reliable and `claude` walks the user through login on first run (same rationale as the preflight, §3.2). | +| **Engines (subscriptions)** | `claude` on PATH, the Codex login inside Pi (`~/.pi/agent/auth.json`), the Cursor CLI, and Grok Build (`grok` on PATH or `~/.grok/bin/grok`, plus the subscription login in `~/.grok/auth.json`). **Informative, never an error** — which subscriptions to use is the professional's call. There is deliberately no `claude` login probe: no heuristic is reliable and `claude` walks the user through login on first run (same rationale as the preflight, §3.2). | | **Core CLIs** | node (the >= 22.12 floor), git and npm as required; gh and vercel as optional. | | **Pi & imp** | Pi installed + version, the three exact-pinned extension packages, and the same update probe the launcher prints after a session — timeboxed at 4 s, so offline or a slow registry just drops those rows instead of holding the report. | | **Project** (only when the folder looks like an IAI project) | FIA runtime present (`imp/scripts` + `imp/fia.config.yaml`); `.mcp.json` hygiene; the harness stamp state; a summarized `--verify` audit (capped at 8 rows — the full report stays in `npx impactus --verify`). | @@ -3066,14 +3101,21 @@ imp rewind --run 3fa9c21b --yes # restore them (no reset, nothing commi ```bash npm run agents # opens the viewer's Agents tab (or /agents inside pi) -# pick engine (claude_code | pi | cursor), model, reasoning and a fallback chain -# per agent; Save preserves the YAML comments and backs the file up. Locked -# while an FDA runs; applies from the next run. +# pick engine (claude_code | pi | cursor | grok), model, reasoning and a fallback +# chain per agent; Save preserves the YAML comments and backs the file up. +# Locked while an FDA runs; applies from the next run. +imp llm # numbered list; `imp llm set builder grok-4.6 --effort high` +# ONE run only, roster untouched (saved with the run, so --resume keeps it): +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "grok-4.6 high" +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "builder=opus xhigh" --llm "reviewer=grok-4.5" ``` Or edit `imp/fia.config.yaml` by hand — remember the billing rule: Claude agents use `coding_agent: claude_code` (the plan); Claude INSIDE Pi bills per -token as extra usage. +token as extra usage. Grok Build (`coding_agent: grok`) is recognized +automatically once `grok login` has been done — never set `XAI_API_KEY` for +it (that is the per-token API route; the FIA strips the variable from grok +runs). ### 15.6 Activate a service key later @@ -3237,7 +3279,8 @@ Recognized by the FIA runtime inside a project: | `FIA_DEBUG` | Same as `--debug` on any FDA (full stack traces). | | `FIA_FDA_RUN` | Exported BY the runner into its child agents — makes the fda-lock hooks/extension silent for the run's own process tree. Never set it yourself. | | `ENGINEER_NAME` | Engineer identity stamped in the trace (fallback: `git config user.name` → `$USER`). | -| `PI_PATH` / `CLAUDE_PATH` / `CURSOR_AGENT_PATH` | Engine binary overrides (`pi` / `claude` / `cursor-agent`). | +| `PI_PATH` / `CLAUDE_PATH` / `CURSOR_AGENT_PATH` / `GROK_PATH` | Engine binary overrides (`pi` / `claude` / `cursor-agent` / `grok`). | +| `XAI_API_KEY` (as seen by the `grok` engine) | Never read by the grok engine — REMOVED from every grok child env, because the CLI would switch to per-token API billing. It remains the key of the Pi `xai/…` provider (row below). | | `PI_SESSIONS_DIR` | Override for the viewer's interactive-Pi session dir (`~/.pi/agent/sessions/`). | | `IAI_DECISION_LOG_NOW` | Fixed timestamp for decision-log/stack-research, the loop-health report and the `updated:` line `wiki-check.mjs --stamp` writes (tests). | | `OPENROUTER_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, `GEMINI_API_KEY`, `FIREWORKS_API_KEY`, `DEEPSEEK_API_KEY`, `CEREBRAS_API_KEY`, `MISTRAL_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | Pi API-key providers (per-token billing — the subscription providers `openai-codex`/`github-copilot` log in via `/login` instead). | diff --git a/README.md b/README.md index 6bb27e2..52c29de 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ npx impactus ``` One command stamps your project with the **agent harness** plus the **FIA**, -running entirely inside the AI subscriptions you already have (Claude Pro/Max -and ChatGPT Plus/Pro) — no API keys, no per-token billing. +running entirely inside the AI subscriptions you already have (Claude Pro/Max, +ChatGPT Plus/Pro and — when you have it — Grok Build on xAI) — no API keys, +no per-token billing. ## What the names mean @@ -123,7 +124,9 @@ imp stop # the stop button: halts FDA runs cleanly before imp handoff # continue the newest Pi conversation in `claude` # (Codex outage? your work keeps moving) imp llm # numbered list of the FDA agents + the LLM each runs on; - # switch by number or name (`imp llm set 1 fable`) + # switch by number or name (`imp llm set 1 fable`, + # `imp llm set builder grok-4.6 --effort high`); + # one run only: node imp/fda_*.mjs … --llm "grok-4.6 high" imp defer # postpone a task blocked on something external (API keys, # a decision): sealed probes quarantined reversibly; # `imp defer resume ` brings it back @@ -183,7 +186,7 @@ clear it. | `/grill [target]` | Stress-tests the PRD one question at a time, recording every decision. | | `/prd [focus]` | Quick reviewer opinion on the PRD. | | `/map` | Conditional architecture checkpoint → map + screens + tasks + milestones; opens the plan in the browser when done. Simple plans skip the checkpoint automatically. | -| `/task [n]` | Runs ONE task end to end via FDA. A brief explicitly marked `Mode: prototype` uses the guarded lint/typecheck-only prototype flow. | +| `/task [n] [--llm " [level]"]` | Runs ONE task end to end via FDA. A brief explicitly marked `Mode: prototype` uses the guarded lint/typecheck-only prototype flow. `--llm "grok 4.6 high"` (or `"builder=opus xhigh"`) runs THAT task on another LLM without touching the roster — same flag on `/goal`, `/quick`, `/bug`. | | `/goal [--light]` | Runs ALL tasks until done. `Mode: prototype` applies per brief; otherwise `--light` skips review+document phases. A completed milestone automatically runs blocking browser QA before the next one. | | `/feature "what you want"` | New feature in an existing system: delta interview → delta spec + new tasks. | | `/bug "the symptom"` | Records the defect, runs proportional RCA when ambiguity/risk requires it, proves a valid failing test first (RED), then fixes it. | @@ -197,7 +200,7 @@ clear it. | `/launch` | Go live — public beta and production, with readiness gates. | | `/qa [scope?]` | Browser QA at milestone/spec/task — Playwright e2e, responsive check, design audit, durable report. | | `/agents` | Visual roster editor: engine, model and fallbacks per FDA agent — with automatic mid-run relay when an engine dies. | -| `/llm ["1 → fable"?]` | Quick model switch: numbered list of the FDA agents with the LLM each one runs on — answer by number or name and it's applied (same safe write path as `/agents`; also `imp llm` in a terminal). | +| `/llm ["1 → fable"?]` | Quick model switch: numbered list of the FDA agents with the LLM each one runs on — answer by number or name and it's applied (same safe write path as `/agents`; also `imp llm` in a terminal). Engines: Claude Code, Codex/any Pi provider, Cursor and Grok Build (`grok-4.6`). | | `/defer [n \| resume n]` | Postpone a task that cannot proceed right now (missing API keys, a pending decision): status → deferred, sealed holdout probes quarantined reversibly; `resume` brings everything back (also `imp defer` in a terminal). The launch check warns about every open deferral. | | `/status` | Progress + latest runs. | | `/evolve --run ` / `--since ` | Evidence-backed retrospective of a finished FDA run or project-history window; writes local reports and never changes the system automatically. | @@ -256,7 +259,7 @@ npx impactus --update-runtime --dir . # 3. imp/ + .pi/ outdated? re-stamp from imp update # 4. update the CLI itself, Pi and the pinned extensions ``` -`imp doctor` checks your subscriptions (Claude/Codex/Cursor), the core CLIs, +`imp doctor` checks your subscriptions (Claude/Codex/Cursor/Grok Build), the core CLIs, Pi and — inside a project — the install itself, and every finding ends in the command that repairs it. `imp fix` only ever **restores what disappeared** (deleted harness/runtime/skill files, the `AGENTS.md` block) and never @@ -384,9 +387,10 @@ the agent harness (skills, commands, gates) and the FIA runtime: - Recommended, **not required** (the installer only warns and keeps going): **Claude Code** with a Claude **Pro/Max** subscription, and/or — for FIA's Codex roles — a **ChatGPT Plus/Pro** subscription (login at the end via - `/login openai-codex` in Pi). With neither, everything still installs; you - get the best results with one of these, and other providers/models can be - added later inside Pi with `/login`. + `/login openai-codex` in Pi). **Grok Build** (`grok login`, xAI + subscription) is picked up automatically as a third engine when present. + With none, everything still installs; you get the best results with one of + these, and other providers/models can be added later inside Pi with `/login`. Everything runs inside these subscriptions — no API keys, no per-token billing. diff --git a/bin/imp.js b/bin/imp.js index 00ccd8c..5a34b32 100755 --- a/bin/imp.js +++ b/bin/imp.js @@ -61,7 +61,7 @@ Usage: all impactus flags work — see \`imp init --help\`) imp update Update impactus, Pi and the Pi extension packages imp tui Terminal dashboard — tasks, specs and runs (same as npm run tui) - imp doctor Read-only checkup: subscriptions (Claude/Codex/Cursor), + imp doctor Read-only checkup: subscriptions (Claude/Codex/Cursor/Grok), CLIs, Pi and this project (--json for machine output). --gates additionally self-tests the FIA gates: injects deliberate defects against throwaway fixtures and @@ -78,9 +78,11 @@ Usage: (works while Codex is down; --list picks a session) imp llm List the FDA agents (phase owners) numbered, each with the LLM it runs on, and switch any by number or name: - \`imp llm set 1 fable\`. Interactive on a TTY; --json - for machines; same rules as /agents (comments kept, - backup first, locked while a run is live) + \`imp llm set 1 fable\`, \`imp llm set builder grok-4.6 + --effort high\`. Interactive on a TTY; --json for + machines; same rules as /agents (comments kept, backup + first, locked while a run is live). One run only: + node imp/fda_*.mjs "" --llm "grok-4.6 high" imp defer Postpone a task that cannot proceed now (missing API keys, a pending decision): \`imp defer \` quarantines its sealed holdout probes (rename only — reversible), diff --git a/fia-templates/fia.config.yaml b/fia-templates/fia.config.yaml index ca97d6e..50e8f92 100644 --- a/fia-templates/fia.config.yaml +++ b/fia-templates/fia.config.yaml @@ -27,13 +27,31 @@ # `cursor-agent login`. Models: `cursor-agent --list-models` # (e.g. sonnet-4.5, gpt-5, composer-1…). Cursor's "effort" # lives in the model id itself (e.g. sonnet-4.5-thinking). +# grok → Grok Build (`grok` CLI) — xAI subscription. Install: +# curl -fsSL https://grok.com/install.sh | bash, then +# `grok login` (OAuth; the token in ~/.grok/auth.json refreshes +# itself — that login is what the FIA detects). Models: +# grok-4.6 (default), grok-4.5 (`grok models` lists them). +# `effort`: low | medium | high | xhigh (no max/ultracode). +# NEVER set XAI_API_KEY for this engine — that turns the CLI +# into per-token API billing; the FIA strips it from grok runs. +# The first grok run trusts the project folder once +# (~/.grok/trusted_folders.toml) so the FIA hooks apply — +# untrusted, grok would skip them in silence. # # Effort/reasoning per engine: # claude_code → `effort` field (low…ultracode) +# grok → `effort` field (low|medium|high|xhigh) # pi → `thinking` field (minimal|low|medium|high) — on Codex this # is the model's reasoning effort # cursor → model-id variant (…-thinking) # +# ONE RUN on another LLM (this file untouched): every FDA takes +# --llm " []" e.g. --llm "grok-4.6 high" (every agent) +# --llm "[,]= []" e.g. --llm "builder=opus xhigh" +# The choice is printed, traced (llm_override) and saved with the run, so +# `--resume` keeps it. Inside Pi: `/task 12 --llm "grok 4.6 high"`. +# # WARNING: Claude INSIDE Pi bills as per-token "extra usage" — to stay on the # Pro/Max plan, always use coding_agent: claude_code. # @@ -61,7 +79,12 @@ # model: fable # effort: xhigh # -# - name: builder # Grok 4.5 straight on xAI +# - name: builder # Grok 4.6 on the xAI subscription (Grok Build) +# coding_agent: grok +# model: grok-4.6 +# effort: high +# +# - name: builder # Grok 4.5 through the xAI API key (per token) # coding_agent: pi # model: xai/grok-4.5 # diff --git a/fia-templates/modules/agent-grok.mjs b/fia-templates/modules/agent-grok.mjs new file mode 100644 index 0000000..86b1086 --- /dev/null +++ b/fia-templates/modules/agent-grok.mjs @@ -0,0 +1,277 @@ +/** + * Grok Build engine — runs the `grok` CLI headless on the user's xAI + * subscription (login once: `grok login`; the OIDC token in ~/.grok/auth.json + * refreshes on its own). + * + * Contract mirrors agent-claude: `--output-format streaming-messages-json` is + * NDJSON in the Claude Code stream-json dialect (system/init, assistant with + * content blocks + per-message usage, user/tool_result, result) — so the + * stream recorder shows grok's tool calls live in the viewer and the TUI, and + * `--resume ` continues the same session for correction rounds. + * + * Verified against grok 1.0.5 (Aug/2026), the facts this adapter relies on: + * - the prompt goes in `-p` (NOT a positional argument); + * - `--rules` APPENDS to grok's own system prompt (the tool guidance the + * model was tuned on stays; the FIA role rides on top — same contract as + * `--append-system-prompt` on claude/pi, and the cacheable prefix stays); + * - `--reasoning-effort` takes low|medium|high|xhigh (no `max`); + * - headless grok executes tools (reads AND writes) without asking; project + * hooks (.claude/settings.json) still apply, but ONLY in a trusted folder + * (session.ensure grants trust before the first grok phase); + * - death signatures: "You are not authenticated" (login), "unknown model + * id" (config error, non-zero exit). + * + * Billing guard: XAI_API_KEY is removed from the child environment. With it + * set, the CLI switches to API-key auth and bills every token outside the + * subscription — the one thing the FIA promises never to do. + */ +import { spawn } from 'node:child_process'; +import { mkdirSync, appendFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { armLimits } from './agent-limits.mjs'; +import { claudeUsageOf } from './agent-claude.mjs'; +import { GROK_EFFORTS, GROK_INSTALL_HINT, grokBinary, grokModels } from './engines.mjs'; + +/** + * The reasoning level grok accepts for a roster level: its own ladder passes + * through, Claude-only tiers clamp to the nearest grok tier (max/ultracode → + * xhigh), Pi's `minimal` → low. When `model` is known to grok's models cache + * the result is further clamped to the levels THAT model advertises (grok-4.5 + * has no xhigh, for instance) — an unknown level makes the CLI exit non-zero, + * which would read as a crash. Null when nothing usable was given (the CLI + * then applies the model's default). + */ +export function grokEffortOf(level, model = null, models = null) { + const v = String(level || '') + .trim() + .toLowerCase(); + if (!v) return null; + let effort = null; + if (GROK_EFFORTS.includes(v)) effort = v; + else if (v === 'minimal' || v === 'none') effort = 'low'; + else if (v === 'max' || v === 'ultracode') effort = 'xhigh'; + if (!effort || !model) return effort; + let ladder = null; + try { + ladder = (models || grokModels()).find((m) => m.id === model)?.efforts || null; + } catch { + ladder = null; + } + if (!ladder?.length || ladder.includes(effort)) return effort; + // Nearest advertised tier at or below the requested one, else the model's floor. + const idx = GROK_EFFORTS.indexOf(effort); + for (let i = idx; i >= 0; i--) if (ladder.includes(GROK_EFFORTS[i])) return GROK_EFFORTS[i]; + return GROK_EFFORTS.find((e) => ladder.includes(e)) || null; +} + +export function buildGrokArgs(request) { + // `-p` carries the prompt (a positional would open the interactive TUI). + // bypassPermissions = grok's always-approve: no permission prompt can ever + // hang a headless phase; deny rules and hooks still apply on top, and the + // FIA's own permission gate (rollback of undeclared writes) is the real + // guard either way. + const args = ['-p', request.prompt, '--output-format', 'streaming-messages-json', '--permission-mode', 'bypassPermissions']; + if (request.model) args.push('--model', request.model); + const effort = grokEffortOf(request.effort || request.thinking, request.model, request.models); + if (effort) args.push('--reasoning-effort', effort); + // Agent role (rendered system.md) via APPEND (`--rules`) — never + // --system-prompt-override: grok's default prefix (tools + context) is what + // the model was tuned on and what the cache leverages. Sent on EVERY + // invocation (including --resume): omitting it on a resume would change the + // system prompt mid-session — invalidating the cache and changing behavior. + if (request.systemPrompt) args.push('--rules', request.systemPrompt); + if (request.sessionId) args.push('--resume', request.sessionId); + return args; +} + +/** The child env: the run's env minus the per-token API key. */ +export function grokEnv(base = process.env, extra = {}) { + const env = { ...base, ...extra }; + delete env.XAI_API_KEY; + return env; +} + +/** Context window of `model` from grok's models cache (0 when unknown). */ +function contextWindowOf(model) { + try { + const entry = grokModels().find((m) => m.id === model); + return entry?.context_window || 0; + } catch { + return 0; + } +} + +/** + * Run Grok Build headless (subscription billing via the official CLI). + * `model` is a bare grok id (grok-4.6, grok-4.5 — `grok models` lists them); + * `effort`/`thinking` maps to --reasoning-effort (low…xhigh). + */ +export async function runGrok(request, { onEvent, onSpawn, onExit } = {}) { + const args = buildGrokArgs(request); + const bin = grokBinary(request.env ? { ...process.env, ...request.env } : process.env) || 'grok'; + + mkdirSync(dirname(request.rawOutputPath), { recursive: true }); + + return new Promise((resolve) => { + const child = spawn(bin, args, { + cwd: request.cwd, + env: grokEnv(process.env, request.env), + stdio: ['ignore', 'pipe', 'pipe'], + }); + onSpawn?.(child.pid); + const limiter = armLimits(child, request.limits); + + let text = ''; + let assistantText = ''; + let tokens = 0; + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + // Live context ≈ what the LAST model call processed (fresh input + the + // cached prefix it re-read). grok reports usage per assistant message, so + // this is exact per turn — not the invocation-accumulated sum. + let lastContext = 0; + let sawResultUsage = false; + let cost = 0; + let sessionId = request.sessionId || ''; + let apiKeySource = ''; + let buffer = ''; + let stdoutAll = ''; + let parsedLines = 0; + + const takeUsage = (usage, { cumulative }) => { + const u = claudeUsageOf(usage); + if (cumulative) { + // The result event carries the invocation total: replace the + // per-message sum with it (never add on top of it). + tokens = u.total; + input = u.input; + output = u.output; + cacheRead = u.cacheRead; + cacheWrite = u.cacheWrite; + sawResultUsage = true; + } else if (!sawResultUsage) { + tokens += u.total; + input += u.input; + output += u.output; + cacheRead += u.cacheRead; + cacheWrite += u.cacheWrite; + lastContext = u.input + u.cacheRead + u.cacheWrite; + } + limiter.noteTokens(tokens); + }; + + const consume = (event) => { + onEvent?.(event); + if (event.type === 'system' && event.subtype === 'init') { + if (event.session_id) sessionId = event.session_id; + if (event.apiKeySource) apiKeySource = String(event.apiKeySource); + return; + } + if (event.session_id) sessionId = event.session_id; + if (event.type === 'assistant' && event.message) { + for (const block of event.message.content || []) { + if (block.type === 'text') assistantText += block.text || ''; + } + if (event.message.usage) takeUsage(event.message.usage, { cumulative: false }); + return; + } + if (event.type === 'result') { + if (typeof event.result === 'string') text = event.result; + if (typeof event.total_cost_usd === 'number') cost = event.total_cost_usd; + if (event.usage) takeUsage(event.usage, { cumulative: true }); + } + }; + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdoutAll += chunk; + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + appendFileSync(request.rawOutputPath, line + '\n'); + try { + consume(JSON.parse(line)); + parsedLines += 1; + } catch { + /* a non-JSON line (pretty-printed object fragment, or plain text) — handled at close */ + } + } + }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (d) => appendFileSync(request.rawOutputPath, `[stderr] ${d}`)); + + const finish = (code) => { + // Whole-stdout fallback: `--output-format json` (or a future default) + // prints ONE pretty-printed object across many lines — parse it as a + // whole when line-wise parsing saw nothing. Plain text is the last resort. + if (!parsedLines) { + if (buffer.trim()) appendFileSync(request.rawOutputPath, buffer + '\n'); + try { + const whole = JSON.parse(stdoutAll); + if (whole && typeof whole === 'object') { + if (typeof whole.text === 'string') text = whole.text; + if (whole.sessionId) sessionId = whole.sessionId; + if (typeof whole.total_cost_usd === 'number') cost = whole.total_cost_usd; + if (whole.usage) { + takeUsage(whole.usage, { cumulative: true }); + lastContext = input + cacheRead + cacheWrite; + } + } + } catch { + text = text || stdoutAll; + } + } + if (apiKeySource && apiKeySource !== 'oauth') { + appendFileSync( + request.rawOutputPath, + `[stderr] FIA: grok reported apiKeySource=${apiKeySource} — per-token billing, outside the subscription\n`, + ); + } + onExit?.(child.pid); + resolve({ + text: text || assistantText, + returncode: code ?? 1, + session_id: sessionId, + tokens, + cost, + input_tokens: input, + output_tokens: output, + cache_read_tokens: cacheRead, + cache_write_tokens: cacheWrite, + context_tokens: lastContext, + context_window: contextWindowOf(request.model), + api_key_source: apiKeySource || null, + terminated: limiter.finish(), + }); + }; + + child.on('close', (code) => finish(code)); + + child.on('error', (err) => { + onExit?.(child.pid); + const hint = + err.code === 'ENOENT' + ? `Grok Build CLI not found (${bin}). Install it: ${GROK_INSTALL_HINT} — then log in: grok login` + : String(err.message || err); + resolve({ + text: `Error: ${hint}`, + returncode: 127, + session_id: sessionId, + tokens: 0, + cost: 0, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_tokens: 0, + context_window: 0, + terminated: limiter.finish(), + }); + }); + }); +} diff --git a/fia-templates/modules/agents.mjs b/fia-templates/modules/agents.mjs index ea3d395..5138bac 100644 --- a/fia-templates/modules/agents.mjs +++ b/fia-templates/modules/agents.mjs @@ -4,10 +4,11 @@ import { parse as parseYaml } from 'yaml'; import * as agentPi from './agent-pi.mjs'; import * as agentClaude from './agent-claude.mjs'; import * as agentCursor from './agent-cursor.mjs'; +import * as agentGrok from './agent-grok.mjs'; import * as prompts from './prompts.mjs'; import * as permissions from './permissions.mjs'; import * as continuation from './continuation.mjs'; -import { checkEngines, engineIssue } from './engines.mjs'; +import { ENGINE_NAMES, checkEngines, engineIssue } from './engines.mjs'; import { runChangedPaths } from './git-helper.mjs'; import { StopCondition } from './stop.mjs'; import { OUTCOMES } from './outcome.mjs'; @@ -40,6 +41,7 @@ export const engineAdapters = { claude_code: agentClaude.runClaude, cursor: agentCursor.runCursor, pi: agentPi.runPi, + grok: agentGrok.runGrok, }; export function loadConfig(path = 'imp/fia.config.yaml') { @@ -105,17 +107,12 @@ export function validate(cfg, required) { for (const name of required) { try { const agent = resolve(cfg, name); - if (!['pi', 'claude_code', 'cursor'].includes(agent.coding_agent)) { - problems.push(`agent ${name}: coding_agent ${agent.coding_agent} not supported`); + if (!ENGINE_NAMES.includes(agent.coding_agent)) { + problems.push(`agent ${name}: coding_agent ${agent.coding_agent} not supported (${ENGINE_NAMES.join('|')})`); } for (const [i, fb] of (Array.isArray(agent.fallbacks) ? agent.fallbacks : []).entries()) { - if ( - !fb || - !['pi', 'claude_code', 'cursor'].includes(fb.coding_agent) || - typeof fb.model !== 'string' || - !fb.model.trim() - ) { - problems.push(`agent ${name}: fallbacks[${i}] needs coding_agent (pi|claude_code|cursor) and a model`); + if (!fb || !ENGINE_NAMES.includes(fb.coding_agent) || typeof fb.model !== 'string' || !fb.model.trim()) { + problems.push(`agent ${name}: fallbacks[${i}] needs coding_agent (${ENGINE_NAMES.join('|')}) and a model`); } } if (agent.phase_overrides !== undefined) { @@ -219,6 +216,28 @@ async function send(run, phase, agent, promptText, systemText, sessionMeta) { ); } + if (agent.coding_agent === 'grok') { + return engineAdapters.grok( + { + prompt: promptText, + systemPrompt: systemText, + model: agent.model, + effort: agent.effort, + thinking: agent.thinking, + sessionId: sessionMeta.sessionId, + limits: sessionMeta.limits, + rawOutputPath, + cwd: run.repoRoot, + env: run.env, + }, + { + onEvent, + onSpawn: (pid) => run.tracer.processStart(run.fdaId, 'agent', agent.name, pid, `grok ${agent.name}`), + onExit: (pid) => run.tracer.processEnd(run.fdaId, pid), + }, + ); + } + return engineAdapters.pi( { prompt: promptText, @@ -285,13 +304,17 @@ function engineHint(text, agent) { const kind = continuation.classifyEngineFailure(text); if (kind === 'login') { const cmd = - { claude_code: '`claude`', pi: '`pi` and then /login', cursor: '`cursor-agent login`' }[agent.coding_agent] || - agent.coding_agent; + { claude_code: '`claude`', pi: '`pi` and then /login', cursor: '`cursor-agent login`', grok: '`grok login`' }[ + agent.coding_agent + ] || agent.coding_agent; return `This looks like a login problem. Open a terminal, run ${cmd}, sign in again, then re-run this FDA.`; } if (kind === 'limit') { return 'This looks like your subscription plan limit. Limits reset on their own — wait a while and re-run this FDA. No extra payment is needed.'; } + if (/unknown model id|unknown model|model .* not found|invalid model/i.test(String(text || ''))) { + return `The model id "${agent.model}" is not one this engine knows — retrying cannot help. Fix the roster (imp llm set ${agent.name} ) or the --llm value, then re-run this FDA.`; + } return `The ${agent.coding_agent} CLI exited with an error before answering. Re-run this FDA; if it persists, run the CLI by hand to see the problem.`; } diff --git a/fia-templates/modules/continuation.mjs b/fia-templates/modules/continuation.mjs index 2210833..30f7133 100644 --- a/fia-templates/modules/continuation.mjs +++ b/fia-templates/modules/continuation.mjs @@ -52,7 +52,8 @@ const MISSING_CAP = 20; // a verdict is a scope, not a backlog export function classifyEngineFailure(text) { const t = String(text || '').toLowerCase(); if ( - /log ?in|logged out|log out|credential|unauthorized|unauthenticated|authentication|auth error|401|token expired|expired token/.test( + // "not authenticated" is grok's exact logged-out wording. + /log ?in|logged out|log out|credential|unauthorized|unauthenticated|not authenticated|authentication|auth error|401|token expired|expired token/.test( t, ) ) { diff --git a/fia-templates/modules/engines.mjs b/fia-templates/modules/engines.mjs index 2a2596a..b607412 100644 --- a/fia-templates/modules/engines.mjs +++ b/fia-templates/modules/engines.mjs @@ -1,10 +1,11 @@ /** * Engine readiness — deterministic, local-only checks of which coding engines - * (claude_code, pi, cursor) can actually run on this machine, plus the + * (claude_code, pi, cursor, grok) can actually run on this machine, plus the * fallback resolution used at the start of every FDA run. * - * Hard signals only: a missing binary, or a Pi provider with neither an OAuth - * entry in ~/.pi/agent/auth.json nor its API-key env var. Soft signals (e.g. + * Hard signals only: a missing binary, a Pi provider with neither an OAuth + * entry in ~/.pi/agent/auth.json nor its API-key env var, or a Grok Build + * install with no subscription login in ~/.grok/auth.json. Soft signals (e.g. * "claude is installed but we cannot prove it is logged in") are reported for * display but never trigger a fallback — the engine itself is the authority. * @@ -17,13 +18,29 @@ * chain even though the local checks pass. Mid-run, agents.mjs relays down * the same chain on engine death (see modules/continuation.mjs). */ -import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join, delimiter } from 'node:path'; +import { dirname, join, delimiter, resolve } from 'node:path'; const PI_AUTH_PATH = join(homedir(), '.pi', 'agent', 'auth.json'); +// ── Grok Build (xAI subscription) ──────────────────────────────────────────── +// The `grok` CLI logs in through `grok login` (OAuth at auth.x.ai) and keeps an +// OIDC token in ~/.grok/auth.json that it refreshes on its own. That file is +// the subscription signal. XAI_API_KEY is deliberately NOT a signal: it flips +// the CLI into per-token API billing, outside the subscription (agent-grok.mjs +// strips it from the child env for the same reason). +const GROK_HOME = join(homedir(), '.grok'); +const GROK_AUTH_PATH = join(GROK_HOME, 'auth.json'); +const GROK_TRUST_PATH = join(GROK_HOME, 'trusted_folders.toml'); +const GROK_MODELS_CACHE = join(GROK_HOME, 'models_cache.json'); +export const GROK_INSTALL_HINT = 'curl -fsSL https://grok.com/install.sh | bash'; +/** Reasoning levels Grok Build accepts (no `max`/`ultracode` — those are Claude's). */ +export const GROK_EFFORTS = ['low', 'medium', 'high', 'xhigh']; +/** Shipped model list — only used when grok's own models cache is absent. */ +export const GROK_MODELS = ['grok-4.6', 'grok-4.5']; + /** Pi providers that authenticate through an env var instead of /login. */ export const PI_ENV_KEYS = { openrouter: 'OPENROUTER_API_KEY', @@ -41,8 +58,8 @@ export const PI_ENV_KEYS = { /** Providers whose login lives in ~/.pi/agent/auth.json (subscription OAuth). */ export const PI_OAUTH_PROVIDERS = ['openai-codex', 'github-copilot']; -export const ENGINE_NAMES = ['claude_code', 'pi', 'cursor']; -export const ENGINE_BINS = { claude_code: 'claude', pi: 'pi', cursor: 'cursor-agent' }; +export const ENGINE_NAMES = ['claude_code', 'pi', 'cursor', 'grok']; +export const ENGINE_BINS = { claude_code: 'claude', pi: 'pi', cursor: 'cursor-agent', grok: 'grok' }; /** Is `bin` reachable on PATH? (no shell involved; Windows gets .exe/.cmd) */ export function binOnPath(bin, env = process.env) { @@ -123,15 +140,185 @@ function agentBinIsCursor(env = process.env) { } } +// ── Grok Build helpers ─────────────────────────────────────────────────────── + +/** + * The grok binary to spawn: `GROK_PATH`, then PATH, then the installer's own + * ~/.grok/bin (the official install.sh puts it there and only appends to the + * shell rc — a run launched from an editor or a fresh shell may not see it on + * PATH yet). Null when nothing is installed. + */ +export function grokBinary(env = process.env) { + if (env.GROK_PATH) return env.GROK_PATH; + if (binOnPath(ENGINE_BINS.grok, env)) return ENGINE_BINS.grok; + const local = join(GROK_HOME, 'bin', process.platform === 'win32' ? 'grok.exe' : 'grok'); + return existsSync(local) ? local : null; +} + +export function readGrokAuth(path = GROK_AUTH_PATH) { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +/** + * Is there a Grok subscription login? auth.json holds one entry per OIDC + * issuer::client id, each with a bearer `key` and a `refresh_token`. The CLI + * refreshes the token itself, so an expired `expires_at` is NOT a logout — + * only an absent/empty entry is. + */ +export function grokLoggedIn(auth = readGrokAuth()) { + if (!auth || typeof auth !== 'object') return false; + return Object.values(auth).some( + (entry) => entry && typeof entry === 'object' && Boolean(entry.refresh_token || entry.key || entry.access_token), + ); +} + +/** + * The models the logged-in account can use, with the reasoning levels each one + * advertises — read from grok's own cache (refreshed by the CLI on every + * launch). The shipped list when the cache is absent: `/llm`, the Agents tab + * and `--llm` validation all read THIS, never a hardcoded copy. + */ +export function grokModels(path = GROK_MODELS_CACHE) { + try { + const cache = JSON.parse(readFileSync(path, 'utf8')); + const models = Object.values(cache?.models || {}) + .map((m) => m?.info) + .filter((info) => info && typeof info.id === 'string' && !info.hidden); + if (models.length) { + return models.map((info) => ({ + id: info.id, + name: info.name || info.id, + efforts: (info.reasoning_efforts || []).map((e) => e?.id || e?.value).filter(Boolean), + default_effort: info.reasoning_effort || null, + context_window: Number(info.context_window) || 0, + })); + } + } catch { + /* no cache, or unreadable — the shipped list below is still correct */ + } + return GROK_MODELS.map((id) => ({ id, name: id, efforts: [...GROK_EFFORTS], default_effort: 'high', context_window: 0 })); +} + +/** Is this a Grok Build model id (the bare `grok-` form the CLI takes)? */ +export function isGrokModelId(model) { + return /^grok-\d/i.test(String(model || '').trim()); +} + +/** + * The comparable forms of a path for the trust store: resolved AND realpath'd + * (when it exists — a not-yet-created subfolder still cascades from a trusted + * parent whose realpath differs, e.g. macOS /var → /private/var). No trailing + * slash; case-folded on Windows. + */ +function trustForms(path) { + const norm = (p) => { + const bare = p.replace(/[\\/]+$/, '') || p; + return process.platform === 'win32' ? bare.toLowerCase() : bare; + }; + const resolved = resolve(String(path || '')); + const forms = new Set([norm(resolved)]); + try { + forms.add(norm(realpathSync(resolved))); + } catch { + /* does not exist (yet) — the resolved form alone is compared */ + } + return forms; +} + +/** + * Is `root` (or an ancestor) trusted in grok's folder-trust store? Without + * trust, grok SKIPS the project's `.claude/settings.json` hooks — the + * desktop guard and the fda-lock gate — in silence. The store is a tiny TOML: + * [folders."/abs/path"] + * trusted = true + * and trust cascades to subdirectories, so any trusted ancestor counts. + */ +export function grokFolderTrusted(root, path = GROK_TRUST_PATH) { + let text; + try { + text = readFileSync(path, 'utf8'); + } catch { + return false; + } + const trusted = new Set(); + let current = null; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + const header = /^\[folders\."((?:[^"\\]|\\.)*)"\]$/.exec(line); + if (header) { + current = header[1].replace(/\\(["\\])/g, '$1'); + continue; + } + if (/^\[/.test(line)) { + current = null; + continue; + } + if (current && /^trusted\s*=\s*true\b/.test(line)) for (const form of trustForms(current)) trusted.add(form); + } + if (!trusted.size) return false; + let dir = resolve(String(root || '')); + for (;;) { + for (const form of trustForms(dir)) if (trusted.has(form)) return true; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** + * Grant grok's folder trust for `root` once, so the project hooks run: the + * CLI's own `--trust` flag is the only supported writer of the store (it holds + * a lock file), so this spawns one deliberately tiny headless turn — no tools, + * one turn, low effort. Returns { trusted, granted, error }. Never throws. + */ +export function ensureGrokTrust(root, { bin = grokBinary(), timeoutMs = 90000, env = process.env } = {}) { + if (grokFolderTrusted(root)) return { trusted: true, granted: false }; + if (!bin) return { trusted: false, granted: false, error: 'grok CLI not found' }; + const childEnv = { ...env }; + delete childEnv.XAI_API_KEY; + try { + const r = spawnSync( + bin, + [ + '--trust', + '-p', + 'Reply with the word ok', + '--output-format', + 'json', + '--max-turns', + '1', + '--reasoning-effort', + 'low', + '--disallowed-tools', + 'run_terminal_command,search_replace,read_file,list_dir,grep,web_search,web_fetch,Agent', + ], + { cwd: root, env: childEnv, encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const trusted = grokFolderTrusted(root); + if (trusted) return { trusted: true, granted: true }; + const tail = String(r.stderr || r.stdout || '').trim().split('\n').filter(Boolean).slice(-2).join(' | '); + return { trusted: false, granted: false, error: tail || `grok exited with ${r.status ?? 'signal'}` }; + } catch (err) { + return { trusted: false, granted: false, error: String(err?.message || err) }; + } +} + /** * Snapshot of every engine's state on this machine. * `logged: null` means "cannot tell locally" (the CLI itself is the authority). + * `grok.trusted` answers for `root` (the project the FDA runs in). */ -export function checkEngines(env = process.env) { +export function checkEngines(env = process.env, { root = process.cwd() } = {}) { const auth = readPiAuth() || {}; const providers = {}; const names = new Set([...PI_OAUTH_PROVIDERS, ...Object.keys(PI_ENV_KEYS), ...Object.keys(auth)]); for (const p of names) providers[p] = piProviderReady(p, auth, env); + const grokBin = grokBinary(env); return { claude_code: { installed: binOnPath(ENGINE_BINS.claude_code, env), logged: claudeLoggedHint() }, pi: { installed: binOnPath(ENGINE_BINS.pi, env), providers }, @@ -139,6 +326,15 @@ export function checkEngines(env = process.env) { // `agent` binary name — the latter only after `agent --version` proves it // is actually Cursor (the name is too generic to trust on its own). cursor: { installed: binOnPath(ENGINE_BINS.cursor, env) || agentBinIsCursor(env), logged: null }, + // Grok Build: the subscription login is a hard signal (auth.json is the + // documented store, refreshed by the CLI); folder trust is informative — + // session.ensure grants it automatically before the first grok phase. + grok: { + installed: Boolean(grokBin), + logged: grokBin ? grokLoggedIn() : false, + trusted: grokBin ? grokFolderTrusted(root) : false, + api_key_env: Boolean(env.XAI_API_KEY), + }, }; } @@ -164,6 +360,11 @@ export function engineIssue({ coding_agent, model }, engines) { } return null; } + if (coding_agent === 'grok') { + if (!engines.grok?.installed) return `grok CLI (Grok Build) not found (${GROK_INSTALL_HINT}, then \`grok login\`)`; + if (!engines.grok.logged) return 'grok is not logged in (run `grok login` — the xAI subscription; never set XAI_API_KEY, that bills per token)'; + return null; + } return `unknown coding_agent "${coding_agent}"`; } @@ -177,8 +378,9 @@ export function engineIssue({ coding_agent, model }, engines) { */ /** * Provider the EFFECTIVE engine bills per token through (API key), or null when - * it runs inside a subscription (claude_code, cursor, Pi OAuth providers). + * it runs inside a subscription (claude_code, cursor, grok, Pi OAuth providers). * Used to print a loud warning at run start — never to block anything. + * (grok is always subscription here: the adapter strips XAI_API_KEY.) */ export function apiKeyProvider(agent) { if (agent.coding_agent !== 'pi') return null; diff --git a/fia-templates/modules/fda-cli.mjs b/fia-templates/modules/fda-cli.mjs index 2d3ae83..db1fb83 100644 --- a/fia-templates/modules/fda-cli.mjs +++ b/fia-templates/modules/fda-cli.mjs @@ -1,10 +1,12 @@ import { parseArgs } from 'node:util'; import { basename } from 'node:path'; import Database from 'better-sqlite3'; +import { join } from 'node:path'; import { loadConfig, validate } from './agents.mjs'; import { ensure } from './session.mjs'; import { classifyFailure, outcomeIsSuccess, outcomeLabel } from './outcome.mjs'; import { resolvePrompt } from './utils.mjs'; +import { applyLlmOverrides, describeLlm, readLlmOverride, writeLlmOverride } from './llm-target.mjs'; export function parseFdaArgs(argv, { agentDefault } = {}) { const { values, positionals } = parseArgs({ @@ -15,17 +17,22 @@ export function parseFdaArgs(argv, { agentDefault } = {}) { resume: { type: 'boolean', default: false }, 'retry-unchanged': { type: 'boolean', default: false }, agent: { type: 'string', default: agentDefault }, + llm: { type: 'string', multiple: true }, debug: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, }); if (values.help) { - console.log('Usage: node imp/fda_*.mjs "" [--config imp/fia.config.yaml] [--fda-id id] [--resume] [--debug]'); + console.log('Usage: node imp/fda_*.mjs "" [--config imp/fia.config.yaml] [--fda-id id] [--resume] [--llm ""] [--debug]'); console.log(' --resume with --fda-id: skip phases that already succeeded in that run (reuses saved results).'); console.log(' The prompt may be omitted — the one saved with the original run is reused.'); console.log(' --retry-unchanged with --resume: override the unchanged-tree guard (a failed run'); console.log(' whose tree has not moved is otherwise refused — re-running cannot pass).'); + console.log(' --llm "" run THIS run on another LLM without touching imp/fia.config.yaml.'); + console.log(' = [agent[,agent]=][ ] — e.g. --llm "grok-4.6 high"'); + console.log(' (every agent), --llm "builder=opus xhigh", --llm "reviewer=openai-codex/gpt-5.6-sol".'); + console.log(' Repeatable; saved with the run so --resume keeps the same LLM.'); console.log(' --debug print the full technical stack trace when a run fails'); console.log(' Agent phases get 1 automatic correction round by default when a gate fails (retries: 1).'); process.exit(0); @@ -47,10 +54,49 @@ export function parseFdaArgs(argv, { agentDefault } = {}) { resume: values.resume, retryUnchanged: values['retry-unchanged'], agent: values.agent, + llm: (values.llm || []).map((s) => String(s).trim()).filter(Boolean), debug: values.debug, }; } +/** The run's session dir before a Run exists (same layout runner.mjs uses). */ +function sessionDirOf(cfg, fdaId) { + return join(cfg.defaults?.data_dir || 'imp/data', 'sessions', fdaId); +} + +/** + * Run-scoped LLM override — `--llm` on the command line, or the override + * saved with the run when this is a `--resume` without one (the engine + * session and its cache only survive when the model stays the same across + * attempts). MUTATES the loaded cfg (the run's copy — the YAML is never + * written) and prints every switch before the run starts. Returns the + * decisions (empty when nothing was overridden). + */ +export function applyRunLlm(cfg, args) { + let specs = args.llm || []; + let source = 'flag'; + if (!specs.length && args.resume && args.fdaId) { + const saved = readLlmOverride(sessionDirOf(cfg, args.fdaId)); + if (saved?.specs?.length) { + specs = saved.specs; + source = 'saved'; + } + } + if (!specs.length) return { specs, decisions: [], source }; + const decisions = applyLlmOverrides(cfg, specs); + console.log( + source === 'saved' + ? ` ⚙ LLM override re-applied from run ${args.fdaId} (this run only — the roster is untouched):` + : ' ⚙ LLM override for THIS run only (imp/fia.config.yaml is untouched):', + ); + for (const d of decisions) { + const fromLevel = d.from.level ? ` (${d.from.level})` : ''; + const toLevel = d.to.level ? ` · ${d.to.coding_agent === 'pi' ? 'thinking' : 'effort'} ${d.to.level}` : ''; + console.log(` ${d.agent}: ${d.from.coding_agent} · ${d.from.model}${fromLevel} → ${d.to.coding_agent} · ${d.to.model}${toLevel}`); + } + return { specs, decisions, source }; +} + export function phaseParams(name, kind, owner, description, extra = {}) { if (!description || description.trim().toLowerCase() === name.replace(/_/g, ' ')) { throw new Error(`phase ${name}: description is required and must explain intent`); @@ -134,12 +180,34 @@ export async function runFda(main, { agents = [], agentDefault } = {}) { process.exit(1); } } + // Run-scoped LLM override BEFORE validation and engine resolution: the + // overridden engine is what gets validated, fallback-resolved and traced. + const llm = applyRunLlm(cfg, args); const required = typeof agents === 'function' ? agents(args) : agents; if (required.length) validate(cfg, required); run = ensure(cfg, args.fdaId, { resume: args.resume, retryUnchanged: args.retryUnchanged }); // Surfaced on the run so gates deep in the phase flow (ui_verify) can // honor the same override without re-parsing argv. run.retryUnchanged = args.retryUnchanged; + if (llm.decisions.length) { + // Saved with the run (so --resume re-applies it) and traced per agent — + // the Agents tab and the per-LLM ledger already stamp the EFFECTIVE + // model on every agent_start/agent_end; this event records WHY. + writeLlmOverride(run.sessionDir, { specs: llm.specs, decisions: llm.decisions }); + for (const d of llm.decisions) { + run.tracer.event({ + fda_id: run.fdaId, + phase_id: '', + type: 'log', + name: 'llm_override', + payload: { agent: d.agent, from: d.from, to: d.to, level_given: d.level_given, spec: d.spec, source: llm.source }, + }); + } + run.llmOverride = llm.decisions; + run.console.note( + `LLM override active for ${llm.decisions.map((d) => `${d.agent} → ${describeLlm(cfg.agents.find((a) => a.name === d.agent))}`).join('; ')}`, + ); + } const prompt = resolvePrompt(args.prompt); const exitCode = await main({ run, cfg, prompt, args }); await announceRunEnd(run, cfg); diff --git a/fia-templates/modules/llm-target.mjs b/fia-templates/modules/llm-target.mjs new file mode 100644 index 0000000..117a60f --- /dev/null +++ b/fia-templates/modules/llm-target.mjs @@ -0,0 +1,256 @@ +/** + * LLM target resolution — the ONE place that turns what an engineer typed + * ("fable", "grok 4.6 high", "builder=openai-codex/gpt-5.6-sol", "cursor + * sonnet-4.5-thinking") into `{ coding_agent, model, level }`. + * + * Two consumers, one grammar: + * - the roster switcher (`imp llm set …` / `/llm`) — a DURABLE change, + * written to imp/fia.config.yaml through roster.mjs; + * - the run-scoped override (`node imp/fda_*.mjs … --llm ""`) — ONE + * run only: the roster is never touched, the choice is printed, traced, + * and saved with the run so `--resume` keeps the same LLM (a model that + * changed mid-run would break the engine session and the cache). + * + * Levels are normalized per engine (claude_code: effort ladder incl. + * max/ultracode; grok: low…xhigh; pi: minimal…high; cursor: none — the level + * lives in the model id), so "grok 4.6 max" clamps to xhigh instead of + * crashing the CLI with an unknown level. + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ENGINE_NAMES, GROK_EFFORTS, isGrokModelId, providerOfModel } from './engines.mjs'; + +export const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']; +export const PI_THINKING = ['minimal', 'low', 'medium', 'high']; +/** Every level word the grammar accepts, in ascending order. */ +export const LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']; + +export const CLAUDE_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable']); +/** Leading words that name an engine ("claude opus", "grok 4.6", "cursor gpt-5"). */ +export const ENGINE_WORDS = { + claude: 'claude_code', + claude_code: 'claude_code', + pi: 'pi', + codex: 'pi', + cursor: 'cursor', + grok: 'grok', + grok_build: 'grok', +}; + +/** The roster field that carries the reasoning level for this engine. */ +export function levelField(engine) { + if (engine === 'claude_code' || engine === 'grok') return 'effort'; + if (engine === 'pi') return 'thinking'; + return null; +} + +/** The ladder of levels this engine accepts. */ +export function engineLevels(engine) { + if (engine === 'claude_code') return CLAUDE_EFFORTS; + if (engine === 'grok') return GROK_EFFORTS; + if (engine === 'pi') return PI_THINKING; + return []; +} + +/** + * Clamp a level onto the engine's ladder: unknown tiers snap to the nearest + * one the engine has (grok has no max → xhigh; pi has no xhigh → high; claude + * has no minimal → low). Null for cursor, or when nothing was given. + */ +export function normalizeLevel(engine, level) { + const ladder = engineLevels(engine); + const v = String(level || '') + .trim() + .toLowerCase(); + if (!ladder.length || !v) return null; + if (ladder.includes(v)) return v; + const idx = LEVELS.indexOf(v); + if (idx === -1) return null; + // Nearest tier below on the shared scale, else the ladder's floor. + for (let i = idx; i >= 0; i--) if (ladder.includes(LEVELS[i])) return LEVELS[i]; + return ladder[0]; +} + +/** + * `{ coding_agent, model }` from what the engineer typed. Claude aliases and + * `claude-*` ids imply claude_code; `grok-` ids imply grok; + * `provider/id` implies pi; anything else needs an explicit engine (cursor + * ids look like bare words). Guards the two billing traps: Claude through Pi + * (extra usage) is refused with the fix, and a leading engine word ("cursor + * sonnet-4.5", "claude opus", "grok 4.6") is accepted. + */ +export function resolveTarget(raw, { engine } = {}) { + let model = String(raw ?? '').trim(); + if (engine !== undefined && !ENGINE_NAMES.includes(engine)) { + throw new Error(`--engine must be one of ${ENGINE_NAMES.join('|')}`); + } + const words = model.split(/\s+/); + if (!engine && words.length > 1 && ENGINE_WORDS[words[0].toLowerCase()]) { + engine = ENGINE_WORDS[words[0].toLowerCase()]; + model = words.slice(1).join(' '); + } + if (!model) throw new Error('missing model — e.g. fable, opus, grok-4.6, openai-codex/gpt-5.6-sol'); + // "grok 4.6" / "grok-4.6" / "grok 4.6" after the engine word → grok-4.6. + if (engine === 'grok' || /^grok[\s-]\d/i.test(model)) { + const version = model.replace(/^grok[\s-]*/i, '').trim(); + if (/^\d/.test(version)) model = `grok-${version.replace(/\s+/g, '')}`; + engine = engine || 'grok'; + } + let coding_agent = engine; + if (!coding_agent) { + if (CLAUDE_ALIASES.has(model.toLowerCase()) || /^claude-/i.test(model)) coding_agent = 'claude_code'; + else if (isGrokModelId(model)) coding_agent = 'grok'; + else if (model.includes('/')) coding_agent = 'pi'; + else { + throw new Error( + `cannot tell which engine runs "${model}" — name it: --engine ${ENGINE_NAMES.join('|')} ` + + '(pi models are provider/id like openai-codex/gpt-5.6-sol; grok ids are grok-4.6 / grok-4.5; cursor ids come from `cursor-agent --list-models`)', + ); + } + } + if (coding_agent === 'pi') { + if (!model.includes('/')) throw new Error(`pi models are provider/model-id (e.g. openai-codex/${model || 'gpt-5.6-sol'})`); + if (providerOfModel(model) === 'anthropic') { + throw new Error( + 'Claude INSIDE Pi bills per token as "extra usage" — use the claude_code engine instead ' + + '(e.g. `set opus`): the official `claude` CLI runs on the Claude Pro/Max plan.', + ); + } + if (providerOfModel(model) === 'xai') { + // Not refused (an engineer may hold an xAI API key on purpose) — but the + // subscription route exists and is what the installer promises. + // The run-start warning (api_key_billing_warning) says the rest. + } + } + if (coding_agent === 'grok' && !isGrokModelId(model)) { + throw new Error(`grok models are grok- ids (grok-4.6, grok-4.5 — \`grok models\` lists them), not "${model}"`); + } + return { coding_agent, model }; +} + +/** + * Parse ONE `--llm` spec. Grammar (whitespace, `@` and `:` all separate): + * [[,…]=][ ] + * so "grok-4.6@high", "grok 4.6 high", "builder=opus xhigh", + * "builder,reviewer=openai-codex/gpt-5.6-sol high" and "cursor sonnet-4.5" + * all parse. Returns { agents: string[]|null, coding_agent, model, level }. + */ +export function parseLlmSpec(spec) { + const raw = String(spec ?? '').trim(); + if (!raw) throw new Error('--llm needs a value, e.g. --llm "grok-4.6 high" or --llm "builder=opus xhigh"'); + let agents = null; + let rest = raw; + const eq = raw.indexOf('='); + if (eq !== -1) { + agents = raw + .slice(0, eq) + .split(',') + .map((a) => a.trim().toLowerCase()) + .filter(Boolean); + if (!agents.length || agents.some((a) => !/^[a-z0-9_-]+$/.test(a))) { + throw new Error(`--llm "${raw}": the part before "=" must be agent name(s), e.g. builder=grok-4.6`); + } + rest = raw.slice(eq + 1).trim(); + } + const tokens = rest.split(/[\s@:]+/).filter(Boolean); + let level = null; + if (tokens.length > 1 && LEVELS.includes(tokens[tokens.length - 1].toLowerCase())) { + level = tokens.pop().toLowerCase(); + } + const target = resolveTarget(tokens.join(' ')); + return { agents, coding_agent: target.coding_agent, model: target.model, level, raw }; +} + +/** Human form of an agent's LLM, e.g. `pi · openai-codex/gpt-5.6-sol · thinking medium`. */ +export function describeLlm(agent) { + const field = levelField(agent.coding_agent); + const level = field ? agent[field] : ''; + return [`${agent.coding_agent} · ${agent.model}`, level ? `${field} ${level}` : ''].filter(Boolean).join(' · '); +} + +/** + * Apply run-scoped overrides to the loaded config — MUTATES cfg.agents in + * place (the run's copy, never the YAML on disk) and returns one decision per + * changed agent: { agent, from, to, level_given }. A spec without agents + * applies to every agent a named spec did not already claim; later specs win. + * + * Levels: when the spec names one it applies to EVERY phase of that agent — + * the engineer asked for "high" for this run, so the roster's per-phase + * reasoning overrides (which only ever lower the level on repair rounds) are + * dropped for the agent. Without a level, the agent's own level is carried + * over, clamped to the new engine's ladder. Both `effort` and `thinking` are + * written so the choice survives an engine relay mid-run. + */ +export function applyLlmOverrides(cfg, specs) { + const agents = cfg?.agents || []; + const known = new Set(agents.map((a) => a.name)); + const parsed = (Array.isArray(specs) ? specs : [specs]).map(parseLlmSpec); + const plan = new Map(); // agent name → spec + for (const spec of parsed) { + if (spec.agents) { + for (const name of spec.agents) { + if (!known.has(name)) { + throw new Error(`--llm "${spec.raw}": no agent "${name}" in the roster — agents: ${[...known].join(', ')}`); + } + plan.set(name, spec); + } + } + } + const global = [...parsed].reverse().find((spec) => !spec.agents) || null; + if (global) for (const name of known) if (!plan.has(name)) plan.set(name, global); + + const decisions = []; + for (const agent of agents) { + const spec = plan.get(agent.name); + if (!spec) continue; + const from = { coding_agent: agent.coding_agent, model: agent.model, effort: agent.effort, thinking: agent.thinking }; + const currentLevel = agent.effort || agent.thinking || null; + const level = normalizeLevel(spec.coding_agent, spec.level || currentLevel); + agent.coding_agent = spec.coding_agent; + agent.model = spec.model; + if (level) { + agent.effort = level; + agent.thinking = level; + } else if (spec.coding_agent === 'cursor') { + delete agent.effort; + delete agent.thinking; + } + if (spec.level && agent.phase_overrides) delete agent.phase_overrides; + decisions.push({ + agent: agent.name, + from: { coding_agent: from.coding_agent, model: from.model, level: from.effort || from.thinking || null }, + to: { coding_agent: agent.coding_agent, model: agent.model, level: level || null }, + level_given: Boolean(spec.level), + spec: spec.raw, + }); + } + if (!decisions.length) throw new Error('--llm matched no agent of this run'); + return decisions; +} + +// ── persistence with the run (for --resume) ────────────────────────────────── + +export const LLM_OVERRIDE_FILE = 'llm_override.json'; + +/** Save the run's override so a `--resume` without `--llm` re-applies it. */ +export function writeLlmOverride(sessionDir, { specs, decisions }) { + const record = { specs: [...specs], decisions, at: new Date().toISOString() }; + try { + mkdirSync(sessionDir, { recursive: true }); + writeFileSync(join(sessionDir, LLM_OVERRIDE_FILE), JSON.stringify(record, null, 2)); + } catch { + /* best-effort: a resume would then run on the roster — printed, never silent */ + } + return record; +} + +/** The run's saved override, or null when absent/unreadable/malformed. */ +export function readLlmOverride(sessionDir) { + try { + const record = JSON.parse(readFileSync(join(sessionDir, LLM_OVERRIDE_FILE), 'utf8')); + if (record && Array.isArray(record.specs) && record.specs.every((s) => typeof s === 'string')) return record; + } catch { + /* no override saved with this run */ + } + return null; +} diff --git a/fia-templates/modules/session.mjs b/fia-templates/modules/session.mjs index 4e7cb16..8bb8bcb 100644 --- a/fia-templates/modules/session.mjs +++ b/fia-templates/modules/session.mjs @@ -3,7 +3,7 @@ import { readFileSync, writeFileSync, rmSync, mkdirSync, linkSync, renameSync } import { Tracer } from './tracer.mjs'; import { Run } from './runner.mjs'; import { engineerName, newId, nowIso } from './utils.mjs'; -import { resolveEngines } from './engines.mjs'; +import { ensureGrokTrust, resolveEngines } from './engines.mjs'; import { RECOVERY_CAP, appendResumeHistory, @@ -215,6 +215,27 @@ export function ensure(cfg, fdaId = null, { resume = false, retryUnchanged = fal ); } + // Grok Build runs the project hooks (desktop guard, fda-lock) ONLY in a + // folder its trust store lists — untrusted, they are skipped in silence, + // which is the one failure mode a guard must never have. Granted here, + // once per machine+folder, before any grok phase can start; refused out + // loud when it cannot be (the exact command is in the message). + const grokAgents = (cfg.agents || []).filter( + (a) => a.coding_agent === 'grok' && (!cfg._required || cfg._required.includes(a.name)), + ); + let grokTrust = null; + if (grokAgents.length) { + grokTrust = ensureGrokTrust(process.cwd()); + if (!grokTrust.trusted) { + throw new Error( + `grok (Grok Build) would skip this project's hooks: the folder is not trusted in ~/.grok/trusted_folders.toml` + + (grokTrust.error ? ` and the automatic grant failed (${grokTrust.error})` : '') + + '.\nGrant it once, in the project root, then re-run:\n grok --trust -p ok\n' + + `(agents on grok: ${grokAgents.map((a) => a.name).join(', ')})`, + ); + } + } + const id = fdaId || newId(8); acquireLock(dataDir, id); // Every child agent inherits this ({...process.env} in the engine spawns): @@ -283,6 +304,10 @@ export function ensure(cfg, fdaId = null, { resume = false, retryUnchanged = fal } tracer.processStart(id, 'fda', '', process.pid, process.argv.join(' ')); run.console.sessionStarted(id, run.engineer); + if (grokTrust?.granted) { + run.console.note('grok: project folder trusted for hooks (one-time, recorded in ~/.grok/trusted_folders.toml)'); + tracer.event({ fda_id: id, type: 'log', name: 'grok_trust_granted', payload: { root: process.cwd() } }); + } for (const d of decisions) { if (d.changed) { run.console.engineFallback(d.agent, d.from, d.to, d.reason); diff --git a/fia-templates/scripts/desktop-guard.mjs b/fia-templates/scripts/desktop-guard.mjs index f24d1d5..3404950 100644 --- a/fia-templates/scripts/desktop-guard.mjs +++ b/fia-templates/scripts/desktop-guard.mjs @@ -91,13 +91,40 @@ export function blockReason(hit) { ); } -/** PreToolUse verdict for a Claude hook payload → { block, reason? }. */ +// Shell tool ids across the two hook dialects this guard serves: Claude Code +// (`Bash`, snake_case payload) and Grok Build, which reads .claude/settings.json +// for compatibility but sends camelCase (`toolName`/`toolInput`) with its own +// tool ids. Reading only one dialect makes the other engine's guard a silent +// no-op — and this guard is the one that must never be. +const SHELL_TOOLS = new Set(['Bash', 'run_terminal_command', 'run_terminal_cmd']); + +/** PreToolUse verdict for a Claude Code or Grok Build hook payload → { block, reason? }. */ export function gateDecision(hook) { - if (hook?.tool_name !== 'Bash') return { block: false }; - const hit = blockedDesktopControl(hook?.tool_input?.command); + const tool = String(hook?.tool_name ?? hook?.toolName ?? ''); + if (!SHELL_TOOLS.has(tool)) return { block: false }; + const input = hook?.tool_input ?? hook?.toolInput ?? {}; + const hit = blockedDesktopControl(input?.command); return hit ? { block: true, reason: blockReason(hit) } : { block: false }; } +/** + * Deny in BOTH dialects at once (same shape as fda-lock.mjs): exit 2 + reason + * on stderr for Claude Code, plus a stdout JSON with grok's `decision`/`reason` + * and Claude's `hookSpecificOutput`. Claude ignores stdout on exit 2; grok + * honors the JSON deny regardless of exit code. + */ +export function denyOutput(reason) { + console.error(reason); + console.log( + JSON.stringify({ + decision: 'deny', + reason, + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, + }), + ); + return 2; +} + /** Cursor beforeShellExecution verdict → { permission, … }. */ export function cursorDecision(hook) { const hit = blockedDesktopControl(hook?.command); @@ -134,10 +161,7 @@ export async function runCli(argv, { input } = {}) { return 0; // unreadable hook payload → fail open } const verdict = gateDecision(hook); - if (verdict.block) { - console.error(verdict.reason); - return 2; - } + if (verdict.block) return denyOutput(verdict.reason); return 0; } diff --git a/fia-templates/scripts/fda-lock.mjs b/fia-templates/scripts/fda-lock.mjs index e961df3..2b9b46d 100644 --- a/fia-templates/scripts/fda-lock.mjs +++ b/fia-templates/scripts/fda-lock.mjs @@ -78,7 +78,43 @@ export function activeFdaLock(root, dataDir = null) { } } -const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']); +// Tool names across the two hook dialects this gate serves: Claude Code +// (Write/Edit/…/Bash) and Grok Build, which reads .claude/settings.json for +// compatibility but sends its OWN tool ids (search_replace, run_terminal_command). +const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'search_replace', 'write_file', 'write', 'edit']); +const SHELL_TOOLS = new Set(['Bash', 'run_terminal_command', 'run_terminal_cmd']); + +/** + * `{ name, input }` of the tool call in a PreToolUse payload, whichever dialect + * sent it: Claude Code uses snake_case (`tool_name`/`tool_input`), Grok Build + * camelCase (`toolName`/`toolInput`). Reading only one of them makes the + * other engine's hook a silent no-op — the failure mode a guard must never have. + */ +export function hookTool(hook) { + return { + name: String(hook?.tool_name ?? hook?.toolName ?? ''), + input: hook?.tool_input ?? hook?.toolInput ?? {}, + }; +} + +/** + * Deny in BOTH dialects at once: exit 2 + the reason on stderr (Claude Code + * feeds stderr back to the model on exit 2; grok takes exit 2 as deny too) + * AND a stdout JSON carrying grok's `decision`/`reason` plus Claude's + * `hookSpecificOutput` block. Claude Code ignores stdout on exit 2, grok + * honors the JSON deny regardless of exit code — so one object serves both. + */ +export function denyOutput(reason) { + console.error(reason); + console.log( + JSON.stringify({ + decision: 'deny', + reason, + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, + }), + ); + return 2; +} // Bash write-context heuristics, shared shape with the Pi fia-guard extension: // a command only blocks when it plausibly mutates files inside the repo. @@ -120,16 +156,15 @@ export function bashWritesInRepo(root, command) { return tokens.some((t) => /[/.]/.test(t) && insideRoot(root, t)); } -/** PreToolUse verdict for a Claude hook payload → { block, reason? }. */ +/** PreToolUse verdict for a Claude Code or Grok Build hook payload → { block, reason? }. */ export function gateDecision(hook, { root, lock }) { - const tool = hook?.tool_name; - const input = hook?.tool_input || {}; + const { name: tool, input } = hookTool(hook); if (WRITE_TOOLS.has(tool)) { - const target = input.file_path || input.notebook_path || ''; + const target = input.file_path || input.notebook_path || input.target_file || input.path || ''; if (insideRoot(root, target)) return { block: true, reason: gateReason(lock, target) }; return { block: false }; } - if (tool === 'Bash' && bashWritesInRepo(root, String(input.command || ''))) { + if (SHELL_TOOLS.has(tool) && bashWritesInRepo(root, String(input.command || ''))) { return { block: true, reason: gateReason(lock, 'this command') }; } return { block: false }; @@ -199,10 +234,7 @@ export async function runCli(argv, { root = process.cwd(), env = process.env, in return 0; // unreadable hook payload → fail open } const verdict = gateDecision(hook, { root: hook?.cwd || root, lock }); - if (verdict.block) { - console.error(verdict.reason); - return 2; - } + if (verdict.block) return denyOutput(verdict.reason); return 0; } return 0; diff --git a/fia-templates/scripts/fia-llm.mjs b/fia-templates/scripts/fia-llm.mjs index 5b6b96b..7f6ac71 100644 --- a/fia-templates/scripts/fia-llm.mjs +++ b/fia-templates/scripts/fia-llm.mjs @@ -23,10 +23,16 @@ import { realpathSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { activeFdaLock } from './fda-lock.mjs'; -import { apiKeyProvider, checkEngines, engineIssue, providerOfModel } from '../modules/engines.mjs'; +import { GROK_EFFORTS, apiKeyProvider, checkEngines, engineIssue, grokModels } from '../modules/engines.mjs'; +import { levelField, resolveTarget } from '../modules/llm-target.mjs'; import { dataDirOf } from '../modules/utils.mjs'; import { CODING_AGENTS, EFFORTS, THINKING, loadRoster, saveRoster, validateRosterPatch } from './roster.mjs'; +// The target grammar ("fable", "grok 4.6", "cursor sonnet-4.5", "provider/id") +// lives in modules/llm-target.mjs — shared with the run-scoped `--llm` flag of +// every FDA, so both spellings can never drift. Re-exported for callers/tests. +export { resolveTarget }; + const DEFAULT_CONFIG = process.env.FIA_CONFIG || 'imp/fia.config.yaml'; // The run phases each roster agent owns (informational — phases are declared @@ -39,9 +45,6 @@ export const AGENT_PHASES = { documenter: 'document', }; -const CLAUDE_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable']); -const ENGINE_WORDS = { claude: 'claude_code', claude_code: 'claude_code', pi: 'pi', codex: 'pi', cursor: 'cursor' }; - /** Numbered display roster: loadRoster + phases, or { available: false }. */ export function rosterView(configPath) { const roster = loadRoster(configPath); @@ -64,47 +67,6 @@ export function pickAgent(agents, ref) { return found; } -/** - * `{ coding_agent, model }` from what the engineer typed. Claude aliases and - * `claude-*` ids imply claude_code; `provider/id` implies pi; anything else - * needs an explicit engine (cursor ids look like bare words). Guards the two - * billing traps: Claude through Pi (extra usage) is refused with the fix, and - * a leading engine word ("cursor sonnet-4.5", "claude opus") is accepted. - */ -export function resolveTarget(raw, { engine } = {}) { - let model = String(raw ?? '').trim(); - if (engine !== undefined && !CODING_AGENTS.includes(engine)) { - throw new Error(`--engine must be one of ${CODING_AGENTS.join('|')}`); - } - const words = model.split(/\s+/); - if (!engine && words.length > 1 && ENGINE_WORDS[words[0].toLowerCase()]) { - engine = ENGINE_WORDS[words[0].toLowerCase()]; - model = words.slice(1).join(' '); - } - if (!model) throw new Error('missing model — e.g. fable, opus, openai-codex/gpt-5.6-sol'); - let coding_agent = engine; - if (!coding_agent) { - if (CLAUDE_ALIASES.has(model.toLowerCase()) || /^claude-/i.test(model)) coding_agent = 'claude_code'; - else if (model.includes('/')) coding_agent = 'pi'; - else { - throw new Error( - `cannot tell which engine runs "${model}" — name it: --engine claude_code|pi|cursor ` + - '(pi models are provider/id like openai-codex/gpt-5.6-sol; cursor ids come from `cursor-agent --list-models`)', - ); - } - } - if (coding_agent === 'pi') { - if (!model.includes('/')) throw new Error(`pi models are provider/model-id (e.g. openai-codex/${model || 'gpt-5.6-sol'})`); - if (providerOfModel(model) === 'anthropic') { - throw new Error( - 'Claude INSIDE Pi bills per token as "extra usage" — use the claude_code engine instead ' + - '(e.g. `set opus`): the official `claude` CLI runs on the Claude Pro/Max plan.', - ); - } - } - return { coding_agent, model }; -} - /** * Switch one agent's LLM. Refuses while an FDA run is live (the permission * gate would attribute the write to the running phase), writes through @@ -146,8 +108,18 @@ export function applyChange({ root = process.cwd(), configPath, backupDir } = {} `"${to.model}" runs through the ${provider} API key — every token bills to that key, OUTSIDE your Claude/Codex subscription.`, ); } - const issue = engineIssue(to, checkEngines()); + const engines = checkEngines(process.env, { root }); + const issue = engineIssue(to, engines); if (issue) warnings.push(`engine not ready on this machine: ${issue} — fix it before the next run (declared fallbacks still apply).`); + if (to.coding_agent === 'grok' && !issue && !engines.grok.trusted) { + warnings.push( + 'grok has not trusted this project folder yet — it would skip the FIA hooks in silence. ' + + 'The first run grants it automatically (or do it now: `grok --trust -p ok` in the project root).', + ); + } + if (to.coding_agent === 'grok' && engines.grok.api_key_env) { + warnings.push('XAI_API_KEY is set in this shell — the FIA removes it from grok runs so they stay on the subscription; unset it to avoid surprises elsewhere.'); + } return { name: entry.name, from: { coding_agent: entry.coding_agent, model: entry.model }, to, backup, warnings }; } @@ -157,12 +129,11 @@ const tty = () => process.stdout.isTTY; const dim = (s) => (tty() ? `\x1b[2m${s}\x1b[22m` : s); const bold = (s) => (tty() ? `\x1b[1m${s}\x1b[22m` : s); -// claude_code exposes `effort`, pi exposes `thinking`; cursor's level lives in -// the model id itself (…-thinking), so a stale field is never shown for it. +// claude_code and grok expose `effort`, pi exposes `thinking`; cursor's level +// lives in the model id itself (…-thinking), so a stale field is never shown. const levelOf = (a) => { - if (a.coding_agent === 'claude_code') return a.effort ? `effort ${a.effort}` : ''; - if (a.coding_agent === 'pi') return a.thinking ? `thinking ${a.thinking}` : ''; - return ''; + const field = levelField(a.coding_agent); + return field && a[field] ? `${field} ${a[field]}` : ''; }; export function renderList(view, engines) { @@ -178,8 +149,9 @@ export function renderList(view, engines) { } lines.push(''); lines.push(dim(`Defaults (inherited when an agent omits a field): ${view.defaults.coding_agent} · ${view.defaults.model}`)); - lines.push(dim('Change one: imp llm set e.g. `imp llm set 1 fable` · `imp llm set builder openai-codex/gpt-5.6`')); - lines.push(dim('Models: claude aliases sonnet|opus|haiku|fable · pi = provider/id · cursor ids need --engine cursor. Fallbacks/chains: /agents.')); + lines.push(dim('Change one: imp llm set e.g. `imp llm set 1 fable` · `imp llm set builder grok-4.6 --effort high`')); + lines.push(dim('Models: claude aliases sonnet|opus|haiku|fable · grok-4.6|grok-4.5 (Grok Build) · pi = provider/id · cursor ids need --engine cursor. Fallbacks/chains: /agents.')); + lines.push(dim('One run only (roster untouched): node imp/fda_*.mjs … --llm "grok-4.6 high" · --llm "builder=opus xhigh"')); return lines.join('\n'); } @@ -196,9 +168,11 @@ function printResult(result) { const USAGE = `Usage: imp llm list the agents (interactive on a TTY) imp llm --json machine-readable roster + engine status - imp llm set [--engine claude_code|pi|cursor] - [--effort ${EFFORTS.join('|')}] + imp llm set [--engine ${CODING_AGENTS.join('|')}] + [--effort ${EFFORTS.join('|')}] (grok: ${GROK_EFFORTS.join('|')}) [--thinking ${THINKING.join('|')}] +Models: sonnet|opus|haiku|fable (claude) · grok-4.6|grok-4.5 (grok) · provider/id (pi) · picker id + --engine cursor +One run only, roster untouched: node imp/fda_*.mjs "" --llm "grok-4.6 high" · --llm "builder=opus xhigh" Also: npm run llm · /llm inside Pi · visual editor with fallbacks: /agents`; function parseArgv(argv) { @@ -257,6 +231,8 @@ export async function runCli(argv, { root = process.cwd() } = {}) { agents: view.agents.map((a) => ({ ...a, engine_issue: engineIssue(a, engines) })), engines, efforts: EFFORTS, + grok_efforts: GROK_EFFORTS, + grok_models: engines.grok?.installed ? grokModels() : null, thinking_levels: THINKING, coding_agents: CODING_AGENTS, }), @@ -284,7 +260,7 @@ export async function runCli(argv, { root = process.cwd() } = {}) { } const target = ( await rl.question( - `New LLM for ${entry.name} (current: ${entry.coding_agent} · ${entry.model}) — e.g. fable, opus, openai-codex/gpt-5.6-sol, "cursor ": `, + `New LLM for ${entry.name} (current: ${entry.coding_agent} · ${entry.model}) — e.g. fable, opus, grok-4.6, openai-codex/gpt-5.6-sol, "cursor ": `, ) ).trim(); if (!target) continue; diff --git a/fia-templates/scripts/fia-tui.mjs b/fia-templates/scripts/fia-tui.mjs index 636da07..d82d226 100644 --- a/fia-templates/scripts/fia-tui.mjs +++ b/fia-templates/scripts/fia-tui.mjs @@ -770,6 +770,12 @@ function AgentsTab({ roster, usage, engines, width }) { { key: 'u', color: engines.cursor?.installed ? 'green' : 'red' }, `cursor ${engines.cursor?.installed ? '✓' : '✗'}`, ), + h(Text, { key: 's3', color: 'gray' }, ' · '), + h( + Text, + { key: 'g', color: engines.grok?.installed && engines.grok?.logged ? 'green' : engines.grok?.installed ? 'yellow' : 'red' }, + `grok ${engines.grok?.installed ? (engines.grok?.logged ? '✓' : '✓ (login)') : '✗'}`, + ), ] : 'checking…', ), diff --git a/fia-templates/scripts/fia-viewer-page.mjs b/fia-templates/scripts/fia-viewer-page.mjs index 57a93ea..cdb7cdd 100644 --- a/fia-templates/scripts/fia-viewer-page.mjs +++ b/fia-templates/scripts/fia-viewer-page.mjs @@ -696,10 +696,11 @@ function renderTabVisao(ph){ if(ph.kind==='agent' && r){ var engine = r.coding_agent==='claude_code' ? '⌘ Claude Code (subscription)' : r.coding_agent==='cursor' ? '▮ Cursor (subscription)' + : r.coding_agent==='grok' ? '✦ Grok Build (xAI subscription)' : 'π Pi — '+esc(String(r.model||'').split('/')[0]||'codex'); html += frow('Engine', engine); html += frow('Model', ''+esc(r.model)+''); - html += frow(r.coding_agent==='claude_code' ? 'Effort' : 'Thinking', esc(r.effort || r.thinking || '—')); + html += frow(agIsEffortEngine(r.coding_agent) ? 'Effort' : 'Thinking', esc(r.effort || r.thinking || '—')); if(r.purpose) html += frow('Role', esc(r.purpose)); if(r.tools && r.tools.length) html += frow('Tools', '
'+r.tools.map(function(t){ return ''+esc(t)+''; }).join('')+'
'); @@ -1732,9 +1733,20 @@ function renderPlanDocView(box){ // ── Agents view ────────────────────────────────────────────────────────────── var AG_EFFORTS = ['low','medium','high','xhigh','max','ultracode']; +var AG_GROK_EFFORTS = ['low','medium','high','xhigh']; var AG_THINKING = ['minimal','low','medium','high']; -var AG_DEFAULT_MODEL = { claude_code:'opus', pi:'openai-codex/gpt-5.6-sol', cursor:'sonnet-4.5-thinking' }; -var AG_DEFAULT_LEVEL = { claude_code:'high', pi:'medium', cursor:'' }; +var AG_ENGINES = ['claude_code','pi','cursor','grok']; +var AG_DEFAULT_MODEL = { claude_code:'opus', pi:'openai-codex/gpt-5.6-sol', cursor:'sonnet-4.5-thinking', grok:'grok-4.6' }; +var AG_DEFAULT_LEVEL = { claude_code:'high', pi:'medium', cursor:'', grok:'high' }; +// claude_code and grok carry their reasoning level in "effort"; pi in +// "thinking"; cursor in the model id itself. +function agIsEffortEngine(eng){ return eng === 'claude_code' || eng === 'grok'; } +function agLevelStored(x, withDefault){ + if(agIsEffortEngine(x.coding_agent)) return x.effort || (withDefault ? AG_DEFAULT_LEVEL[x.coding_agent] : ''); + if(x.coding_agent === 'pi') return x.thinking || (withDefault ? 'medium' : ''); + return ''; +} +function agLevelsFor(eng){ return eng === 'claude_code' ? AG_EFFORTS : eng === 'grok' ? AG_GROK_EFFORTS : AG_THINKING; } var FDA_MAP = [ { fda:'fda_sdlc', via:'/task · /goal · /bug', phases:[['request',''],['plan','planner'],['build','builder'],['test',''],['review','reviewer'],['commit',''],['document','documenter']] }, { fda:'fda_plan_build_test', via:'build with test/fix loop', phases:[['request',''],['plan','planner'],['build','builder'],['test_n',''],['fix_n','builder'],['commit','']] }, @@ -1758,10 +1770,9 @@ function agInitEdits(){ if(S.agents.edits[a.name]) return; S.agents.edits[a.name] = { coding_agent: a.coding_agent, model: a.model, - level: a.coding_agent === 'claude_code' ? (a.effort || 'high') : a.coding_agent === 'pi' ? (a.thinking || 'medium') : '', + level: agLevelStored(a, true), fallbacks: (a.fallbacks || []).map(function(fb){ - return { coding_agent: fb.coding_agent, model: fb.model, - level: fb.coding_agent === 'claude_code' ? (fb.effort || '') : fb.coding_agent === 'pi' ? (fb.thinking || '') : '' }; + return { coding_agent: fb.coding_agent, model: fb.model, level: agLevelStored(fb, false) }; }), }; }); @@ -1772,7 +1783,7 @@ function agDirty(name){ var out = []; if(e.coding_agent !== a.coding_agent) out.push('engine ' + a.coding_agent + ' → ' + e.coding_agent); if(e.model !== a.model) out.push('model ' + a.model + ' → ' + e.model); - var storedLevel = a.coding_agent === 'claude_code' ? (a.effort || 'high') : a.coding_agent === 'pi' ? (a.thinking || 'medium') : ''; + var storedLevel = agLevelStored(a, true); if(e.coding_agent === a.coding_agent && e.level !== storedLevel) out.push('reasoning ' + storedLevel + ' → ' + e.level); var sf = JSON.stringify((a.fallbacks || []).map(function(fb){ return [fb.coding_agent, fb.model, fb.effort || fb.thinking || '']; })); var ef = JSON.stringify((e.fallbacks || []).map(function(fb){ return [fb.coding_agent, fb.model, fb.level || '']; })); @@ -1806,6 +1817,14 @@ function agModelGroups(engine){ return [{ g: E.cursorModels ? 'cursor-agent --list-models' : 'examples — confirm with cursor-agent --list-models', items: ms.map(function(m){ return { id:m, locked:!installed, hint: installed ? '' : 'install cursor-agent' }; }) }]; } + if(engine === 'grok'){ + var gk = eng.grok || {}; + var gLocked = !(gk.installed && gk.logged); + var gHint = !gk.installed ? 'install grok' : !gk.logged ? 'grok login' : ''; + var gm = E.grokModels && E.grokModels.length ? E.grokModels : [{ id:'grok-4.6' }, { id:'grok-4.5' }]; + return [{ g: E.grokModels ? 'Grok Build (xAI subscription) — from grok models' : 'Grok Build (xAI subscription)', + items: gm.map(function(m){ return { id:m.id, locked:gLocked, hint:gHint }; }) }]; + } // pi: curated ids + every pi model already present in the config, grouped by provider var ids = ['openai-codex/gpt-5.6-sol','openai-codex/gpt-5.6','github-copilot/gpt-5.6','openrouter/moonshotai/kimi-k3','xai/grok-4.5']; var d = S.agents.data; @@ -1829,6 +1848,10 @@ function agEngineIssueFor(edit){ var eng = E.engines || {}; if(edit.coding_agent === 'claude_code' && eng.claude_code && !eng.claude_code.installed) return 'claude CLI is not installed'; if(edit.coding_agent === 'cursor' && eng.cursor && !eng.cursor.installed) return 'cursor-agent is not installed'; + if(edit.coding_agent === 'grok' && eng.grok){ + if(!eng.grok.installed) return 'grok (Grok Build) is not installed — curl -fsSL https://grok.com/install.sh | bash'; + if(!eng.grok.logged) return 'grok is not logged in — run "grok login" (subscription; never XAI_API_KEY)'; + } if(edit.coding_agent === 'pi'){ if(eng.pi && !eng.pi.installed) return 'pi is not installed'; var i = edit.model.indexOf('/'); @@ -1886,6 +1909,18 @@ function renderAgentsSidebar(){ ? '
  • installed · login: cursor-agent status
  • ' : '
  • not found on PATH
  • ') + '' + (cu.installed ? '' : '
    curl https://cursor.com/install -fsS | bash
    ') + ''; + var gk = eng.grok || {}; + var gkDot = gk.installed ? (gk.logged ? 'ok' : 'err') : 'err'; + var gkModels = (E.grokModels || []).map(function(m){ return m.id; }).join(' · ') || 'grok-4.6 · grok-4.5'; + html += '
    Grok Buildgrok
      ' + + (gk.installed + ? '' + (gk.logged ? '✓' : '✗') + '' + + (gk.logged ? 'installed · logged in (xAI subscription)' : 'installed · not logged in') + '' + + '
    • ·models: ' + esc(gkModels) + ' · effort low…xhigh
    • ' + + (gk.trusted ? '' : '
    • ·project trust: granted automatically at the first grok run (hooks)
    • ') + + (gk.api_key_env ? '
    • !XAI_API_KEY is set — the FIA removes it from grok runs (subscription only)
    • ' : '') + : '
    • not found on PATH
    • ') + + '
    ' + (!gk.installed ? '
    curl -fsSL https://grok.com/install.sh | bash  →  grok login
    ' : !gk.logged ? '
    grok login
    ' : '') + '
    '; html += '↻ check again'; html += ''; $('list').innerHTML = html; @@ -1908,14 +1943,14 @@ function agPickerHtml(name, slot, edit){ }); }); html += '
    custom
    set
    '; + (edit.coding_agent === 'pi' ? 'provider/model-id' : edit.coding_agent === 'grok' ? 'grok-' : 'model id') + '" />set'; html += ''; } return html + ''; } function agSegHtml(name, slot, edit){ var id = name + '_' + slot; - return '
    ' + ['claude_code','pi','cursor'].map(function(engv){ + return '
    ' + AG_ENGINES.map(function(engv){ var label = engv === 'claude_code' ? 'claude' : engv; return '' + label + ''; }).join('') + '
    '; @@ -1923,7 +1958,7 @@ function agSegHtml(name, slot, edit){ function agLevelHtml(name, slot, edit){ var id = name + '_' + slot; if(edit.coding_agent === 'cursor') return 'on Cursor, reasoning lives in the model id (…-thinking)'; - var levels = edit.coding_agent === 'claude_code' ? AG_EFFORTS : AG_THINKING; + var levels = agLevelsFor(edit.coding_agent); return '
    ' + levels.map(function(lv){ return '' + lv + ''; }).join('') + '
    '; @@ -2116,11 +2151,11 @@ function agentsSave(){ var e = S.agents.edits[n]; return { name: n, coding_agent: e.coding_agent, model: e.model, - effort: e.coding_agent === 'claude_code' ? (e.level || null) : null, + effort: agIsEffortEngine(e.coding_agent) ? (e.level || null) : null, thinking: e.coding_agent === 'pi' ? (e.level || null) : null, fallbacks: e.fallbacks.map(function(fb){ var out = { coding_agent: fb.coding_agent, model: fb.model }; - if(fb.coding_agent === 'claude_code' && fb.level) out.effort = fb.level; + if(agIsEffortEngine(fb.coding_agent) && fb.level) out.effort = fb.level; if(fb.coding_agent === 'pi' && fb.level) out.thinking = fb.level; return out; }), diff --git a/fia-templates/scripts/fia-viewer.mjs b/fia-templates/scripts/fia-viewer.mjs index f704882..1b71a2b 100644 --- a/fia-templates/scripts/fia-viewer.mjs +++ b/fia-templates/scripts/fia-viewer.mjs @@ -28,7 +28,7 @@ import { parse as parseYaml } from 'yaml'; import { PAGE } from './fia-viewer-page.mjs'; import { piSessionsDirFor, listPiSessions, readPiSession, readPiRun } from './pi-sessions.mjs'; import { readPlanOverview, readPlanScreens, readPlanTasks, readPlanDesign, readPlanDoc } from './plan-docs.mjs'; -import { checkEngines, PI_ENV_KEYS, PI_OAUTH_PROVIDERS } from '../modules/engines.mjs'; +import { checkEngines, grokModels, PI_ENV_KEYS, PI_OAUTH_PROVIDERS } from '../modules/engines.mjs'; import { saveRoster, validateRosterPatch } from './roster.mjs'; const DEFAULT_DB = process.env.FIA_DB || 'imp/data/fia.db'; @@ -392,12 +392,14 @@ export function startViewer({ if (!doc) return json(res, { error: 'bad path' }, 400); json(res, doc); } else if (url.pathname === '/api/engines') { - const engines = checkEngines(); + const engines = checkEngines(process.env, { root: projectRoot }); json(res, { engines, piEnvKeys: PI_ENV_KEYS, piOauthProviders: PI_OAUTH_PROVIDERS, cursorModels: engines.cursor.installed ? await cursorModels() : null, + // grok's own models cache (ids + advertised reasoning levels). + grokModels: engines.grok?.installed ? grokModels() : null, running: runningCount(), now: new Date().toISOString(), }); diff --git a/fia-templates/scripts/roster.mjs b/fia-templates/scripts/roster.mjs index 59a0bf9..d506e1f 100644 --- a/fia-templates/scripts/roster.mjs +++ b/fia-templates/scripts/roster.mjs @@ -12,11 +12,23 @@ import { copyFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { parse as parseYaml, parseDocument } from 'yaml'; -import { MAX_FALLBACKS } from '../modules/engines.mjs'; +import { ENGINE_NAMES, GROK_EFFORTS, MAX_FALLBACKS } from '../modules/engines.mjs'; export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']; export const THINKING = ['minimal', 'low', 'medium', 'high']; -export const CODING_AGENTS = ['claude_code', 'pi', 'cursor']; +/** claude_code | pi | cursor | grok — ONE list, read from the engine registry. */ +export const CODING_AGENTS = [...ENGINE_NAMES]; +export { GROK_EFFORTS }; + +/** The `effort` problem for an entry, or null: grok has a shorter ladder than claude. */ +function effortIssue(entry) { + if (entry.effort == null) return null; + if (!EFFORTS.includes(entry.effort)) return `effort must be ${EFFORTS.join('|')}`; + if (entry.coding_agent === 'grok' && !GROK_EFFORTS.includes(entry.effort)) { + return `grok effort must be ${GROK_EFFORTS.join('|')} (Grok Build has no max/ultracode)`; + } + return null; +} export const modelOk = (m) => typeof m === 'string' && m.trim() && m.length <= 200 && !/[\n\r]/.test(m); @@ -63,7 +75,8 @@ export function validateRosterPatch(patch) { if (!a || typeof a.name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(a.name)) return 'agent name missing or invalid'; if (!CODING_AGENTS.includes(a.coding_agent)) return `${a.name}: coding_agent must be one of ${CODING_AGENTS.join('|')}`; if (!modelOk(a.model)) return `${a.name}: model is required`; - if (a.effort != null && !EFFORTS.includes(a.effort)) return `${a.name}: effort must be ${EFFORTS.join('|')}`; + const effortProblem = effortIssue(a); + if (effortProblem) return `${a.name}: ${effortProblem}`; if (a.thinking != null && !THINKING.includes(a.thinking)) return `${a.name}: thinking must be ${THINKING.join('|')}`; if (a.fallbacks !== undefined) { if (!Array.isArray(a.fallbacks) || a.fallbacks.length > MAX_FALLBACKS) @@ -72,7 +85,7 @@ export function validateRosterPatch(patch) { if (!fb || !CODING_AGENTS.includes(fb.coding_agent) || !modelOk(fb.model)) { return `${a.name}: each fallback needs coding_agent and model`; } - if (fb.effort != null && !EFFORTS.includes(fb.effort)) return `${a.name}: fallback effort invalid`; + if (effortIssue(fb)) return `${a.name}: fallback ${effortIssue(fb)}`; if (fb.thinking != null && !THINKING.includes(fb.thinking)) return `${a.name}: fallback thinking invalid`; } } diff --git a/pi-templates/.pi/prompts/bug.md b/pi-templates/.pi/prompts/bug.md index e369047..90f6d7e 100644 --- a/pi-templates/.pi/prompts/bug.md +++ b/pi-templates/.pi/prompts/bug.md @@ -65,7 +65,9 @@ Symptom: $@ Omit the investigation path for a direct bug. Never run this while an FDA is active; without the script, continue without inventing a replacement. -7. Run `node imp/fda_bug.mjs ai-docs/actual-todo/.md` and follow along. +7. Run `node imp/fda_bug.mjs ai-docs/actual-todo/.md` and follow along + (an LLM named in my request for this run — `--llm "…"`, "on grok 4.6 high" + — goes on that command as `--llm ""`; never into the roster). Tell me, in one line, that the FDA gates the RED for validity: the reproduction test must fail on a real assertion — a passing test means "bug not reproduced", and a module/syntax/env failure doesn't count as proof. diff --git a/pi-templates/.pi/prompts/goal.md b/pi-templates/.pi/prompts/goal.md index 8d5f216..733d83e 100644 --- a/pi-templates/.pi/prompts/goal.md +++ b/pi-templates/.pi/prompts/goal.md @@ -25,6 +25,13 @@ if the engineer's instructions contain `--light`, use `fda_plan_build_test.mjs`; the default is `fda_sdlc.mjs`. Prototype takes precedence over `--light` because it is the brief's explicit per-task opt-in. +**LLM for this goal run only:** `--llm "…"` in my instructions (or words like +"on grok 4.6 high", "builder on opus xhigh") is appended verbatim as +`--llm ""` to EVERY FDA this loop dispatches (repeat the flag per +agent when I name several). The script normalizes and validates it, prints +the `old → new` line at each run start and keeps it on `--resume`. Never edit +`imp/fia.config.yaml` for this — a durable switch is `/llm`. + Loop, until no unblocked task remains OR a milestone boundary closes (step 3): 1. `task-sequencer` → next unblocked issue → brief in `ai-docs/actual-todo/` diff --git a/pi-templates/.pi/prompts/llm.md b/pi-templates/.pi/prompts/llm.md index 1e1f735..9714166 100644 --- a/pi-templates/.pi/prompts/llm.md +++ b/pi-templates/.pi/prompts/llm.md @@ -20,12 +20,20 @@ Show me the LLM roster and let me switch models conversationally. $@ the builder to opus", "2 → cursor sonnet-4.5-thinking"), apply it with the script — NEVER edit `imp/fia.config.yaml` yourself: - `node imp/scripts/fia-llm.mjs set [--engine claude_code|pi|cursor] [--effort …] [--thinking …]` + `node imp/scripts/fia-llm.mjs set [--engine claude_code|pi|cursor|grok] [--effort …] [--thinking …]` Model resolution the script already does (don't second-guess it): claude aliases (sonnet|opus|haiku|fable) and `claude-*` ids → `claude_code`; - `provider/id` → `pi`; bare cursor ids need `--engine cursor` (or say - "cursor "). `anthropic/*` on pi is refused by design — extra usage. + `grok-4.6`/`grok-4.5` (or "grok 4.6") → `grok` (Grok Build, xAI + subscription — effort low|medium|high|xhigh); `provider/id` → `pi`; bare + cursor ids need `--engine cursor` (or say "cursor "). `anthropic/*` on + pi is refused by design — extra usage. + + If instead I ask for a model "just for this task / this run" ("run task 12 + on grok 4.6 high"), that is NOT a roster change: tell me to say it on the + command (`/task 12 --llm "grok 4.6 high"`, `/goal --llm "builder=opus + xhigh"`) — or run it that way yourself when the task is clear — and leave + the roster alone. 3. Report back exactly what the script printed: the `old → new` line, every ⚠ warning verbatim (API-key billing, engine not logged in), and that the diff --git a/pi-templates/.pi/prompts/quick.md b/pi-templates/.pi/prompts/quick.md index 65f54c3..9b46749 100644 --- a/pi-templates/.pi/prompts/quick.md +++ b/pi-templates/.pi/prompts/quick.md @@ -15,7 +15,9 @@ Request: $@ 2. SIMPLE → run `node imp/fda_quick.mjs ""`. The FDA enforces the guardrails (design-system registry, backend rules, one focal test when the change is testable behavior) and appends the audit entry to - `ai-docs/todos/quick-log.md` — no issue, no roadmap noise. + `ai-docs/todos/quick-log.md` — no issue, no roadmap noise. If my request + names an LLM for this run (`--llm "…"`, "on grok 4.6 low"), append + `--llm ""` to that command — never edit the roster for it. 3. COMPLEX → do NOT push through. Route me in one line — new behavior → /feature, defect → /bug — and name the triage criterion that failed. 4. Check `ai-docs/inbox.md`: an unchecked item this run resolves gets ticked diff --git a/pi-templates/.pi/prompts/task.md b/pi-templates/.pi/prompts/task.md index 3018859..f4ff37b 100644 --- a/pi-templates/.pi/prompts/task.md +++ b/pi-templates/.pi/prompts/task.md @@ -1,11 +1,20 @@ --- -description: Execute ONE task via FDA (the next one, or the one I point to) -argument-hint: "[number or description]" +description: Execute ONE task via FDA (the next one, or the one I point to) — optionally on another LLM just for this run +argument-hint: "[number or description] [--llm \"grok 4.6 high\" | \"builder=opus xhigh\"]" --- Read `.pi/skills/fia/SKILL.md` and the cookbook `.pi/skills/fia/cookbooks/harness_bridge.md`, and follow Step 2. Requested task: $@ (if empty, the next unblocked one from `ai-docs/todos/issues/`). +**LLM for this run only.** If my request carries `--llm "…"` — or plain words +like "on grok 4.6 high", "with opus xhigh", "builder on codex" — that is a +run-scoped choice: append `--llm ""` to the FDA command in step 2 +(repeat the flag for several agents, e.g. `--llm "builder=grok-4.6 high" +--llm "reviewer=opus"`). The script normalizes the spelling, clamps the level +to the engine, refuses ambiguous ids with the fix, prints the `old → new` +line (repeat it to me) and keeps it on `--resume`. NEVER edit +`imp/fia.config.yaml` for this — a durable switch is `/llm`. + 1. Delegate to the `task-sequencer` to generate the brief in `ai-docs/actual-todo/` (it stops on the **theme gate** right after the greenfield foundation task — no closed `theme` decision log yet. Resolve with me per the cookbook: `/theme`, diff --git a/pi-templates/.pi/skills/fia/SKILL.md b/pi-templates/.pi/skills/fia/SKILL.md index 44f3952..15ef2cf 100644 --- a/pi-templates/.pi/skills/fia/SKILL.md +++ b/pi-templates/.pi/skills/fia/SKILL.md @@ -35,8 +35,8 @@ Short commands (prompt templates in `.pi/prompts/`) are the student entry points | `/grill [target]` | stress-tests the PRD, records decisions | decision-log | | `/prd [focus]` | quick reviewer opinion on the PRD | — | | `/map` | PRD → map.yaml + screens + tasks + milestones | harness_bridge (Step 1) | -| `/task [n]` | runs ONE task via FDA | harness_bridge (Step 2) | -| `/goal` | all tasks until done | harness_bridge (Step 3) | +| `/task [n] [--llm " [level]"]` | runs ONE task via FDA; `--llm` (or "on grok 4.6 high" in the request) runs THAT task on another LLM — pass it to the FDA as `--llm "…"`, never edit the roster | harness_bridge (Step 2) · update_roster (`--llm`) | +| `/goal [--llm "…"]` | all tasks until done (`--llm` applies to every FDA the loop dispatches) | harness_bridge (Step 3) | | `/feature "what you want"` | new feature in an existing system — delta interview → delta spec + new issues | harness_bridge (Step 4) | | `/bug "the symptom"` | records the defect, `fda_bug` proves a valid RED, then fixes it | harness_bridge (Step 2) | | `/quick "small change"` | triage — simple stays simple: `fda_quick` + quick-log entry; complex routes to /feature or /bug | harness_bridge (Step 2) | @@ -50,7 +50,7 @@ Short commands (prompt templates in `.pi/prompts/`) are the student entry points | `/design [images]` | layout redesign from references, inside the design system | design | | `/example [URL or slug]` | register an external reference (repo, code, docs, design) on the examples shelf | examples | | `/agents` | visual roster editor — engines, models, fallbacks (viewer "Agents" tab) | update_roster | -| `/llm ["1 → fable"?]` | numbered list of the FDA agents + the LLM each runs on; switch by number or name via `imp/scripts/fia-llm.mjs set` (never edit the YAML directly). Also `imp llm` in a terminal | update_roster | +| `/llm ["1 → fable"?]` | numbered list of the FDA agents + the LLM each runs on; switch by number or name via `imp/scripts/fia-llm.mjs set` (never edit the YAML directly). Engines: claude_code · pi (Codex/any provider) · cursor · grok (Grok Build, `grok-4.6`). Also `imp llm` in a terminal | update_roster | | `/defer [n \| resume n]` | postpone a task that cannot proceed now (missing API keys, a pending decision): status → deferred + its sealed holdout probes quarantined via `imp/scripts/task-defer.mjs` (rename only, reversible — NEVER `mv` in `imp/data/holdout/` by hand); `resume` brings it back. Also `imp defer` in a terminal | run_fda | | `/onboarding [focus?] [--report-only]` | first command on an existing system — chains /absorb → /stack → /kit in one guided pass (resumable via its decision-log rail; `--report-only` defers the kit decisions), ends ready for /idea or /feature | decision-log | | `/absorb [focus]` | existing project → as-built PRD + map + conventions + stack manifest + component registry + the maintained `ai-docs/wiki/` (stamped, then checked by `npm run wiki:check`) | — | diff --git a/pi-templates/.pi/skills/fia/cookbooks/harness_bridge.md b/pi-templates/.pi/skills/fia/cookbooks/harness_bridge.md index cbec8f2..5d340a3 100644 --- a/pi-templates/.pi/skills/fia/cookbooks/harness_bridge.md +++ b/pi-templates/.pi/skills/fia/cookbooks/harness_bridge.md @@ -116,6 +116,9 @@ After the artifacts land, show them to the engineer: `npm run plan -- --detach` node imp/fda_plan_build_test.mjs ai-docs/actual-todo/.md # brief already autocontained (skip planner, save ~1-2.5M tokens): node imp/fda_build_test.mjs ai-docs/actual-todo/.md +# the engineer asked for another LLM for THIS task only ("on grok 4.6 high", +# `/task 12 --llm "builder=opus xhigh"`): pass it through — never edit the roster +node imp/fda_plan_build_test.mjs ai-docs/actual-todo/.md --llm "grok 4.6 high" # bigger/riskier: plan (auto-skipped on self-contained briefs) → build → # test + fix rounds → review (green suites only) → commit. Docs default to one # fda_document per MILESTONE (`sdlc:` block in imp/fia.config.yaml). diff --git a/pi-templates/.pi/skills/fia/cookbooks/run_fda.md b/pi-templates/.pi/skills/fia/cookbooks/run_fda.md index d83a558..b1f6003 100644 --- a/pi-templates/.pi/skills/fia/cookbooks/run_fda.md +++ b/pi-templates/.pi/skills/fia/cookbooks/run_fda.md @@ -9,12 +9,21 @@ node imp/fda_plan_build_test.mjs "implement the plan" --fda-id .md +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "grok 4.6 high" # THIS run on another LLM; roster untouched +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "builder=opus xhigh" --llm "reviewer=grok-4.5" ``` `fda_prototype` refuses prompts without an exact top-level `Mode: prototype` line. That marker is the deliberate authorization to skip tests and review; `/task` and `/goal` select the runner from it automatically. +`--llm "[agent[,agent]=] []"` is the run-scoped LLM override: +the engineer's words go through verbatim (the script normalizes `grok 4.6`, +clamps levels per engine, refuses ambiguous ids with the fix), the switch is +printed and traced (`llm_override`), and it is saved with the run so +`--resume` keeps the same LLM without repeating the flag. A durable change is +`/llm` (`imp llm set …`), never a YAML edit by you. + Watch: ```bash diff --git a/pi-templates/.pi/skills/fia/cookbooks/update_roster.md b/pi-templates/.pi/skills/fia/cookbooks/update_roster.md index f16cbb7..297c346 100644 --- a/pi-templates/.pi/skills/fia/cookbooks/update_roster.md +++ b/pi-templates/.pi/skills/fia/cookbooks/update_roster.md @@ -10,15 +10,36 @@ student answers "1 → fable" (or "builder → openai-codex/gpt-5.6") and the change is applied by `node imp/scripts/fia-llm.mjs set ` — the SAME write path as the Agents tab: comments preserved, backup under `imp/data/backups/`, atomic write, refused while an FDA run is live. Claude -aliases route to `claude_code`, `provider/id` routes to `pi` (`anthropic/*` -is refused — extra usage), cursor ids need `--engine cursor`. Pi must never -edit `imp/fia.config.yaml` directly — the script owns the write. +aliases route to `claude_code`, `grok-` ids (or "grok 4.6") route to +`grok`, `provider/id` routes to `pi` (`anthropic/*` is refused — extra usage), +cursor ids need `--engine cursor`. Pi must never edit `imp/fia.config.yaml` +directly — the script owns the write. + +## One run only — `--llm` (the roster stays as it is) + +When the engineer wants a DIFFERENT LLM for just this task ("run this one on +grok 4.6 high", "do it with opus xhigh", `/task 12 --llm "grok 4.6 high"`), +do NOT touch the roster: pass the words through to the FDA as +`--llm ""` — every `node imp/fda_*.mjs` accepts it, repeatable: + +```bash +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "grok 4.6 high" # every agent of this run +node imp/fda_sdlc.mjs ai-docs/actual-todo/.md --llm "builder=opus xhigh" # only the builder +node imp/fda_quick.mjs "rename the button" --llm "reviewer,scout=openai-codex/gpt-5.6-sol" +``` + +Grammar: `[[,]=][ ]` — the script normalizes +spellings (`grok 4.6` → `grok-4.6`, `max` on grok → `xhigh`), refuses +ambiguous ids with the exact fix, prints every switch at run start, traces it +(`llm_override`) and saves it with the run so `--resume` keeps the same LLM. +Report the printed `old → new` line to the engineer. Never "remember" the +choice by editing the YAML — a durable change is `/llm`, not `--llm`. ## The visual way — /agents (recommended for fallbacks and overviews) `/agents` (inside `pi`) or `npm run agents` opens the FIA viewer's "Agents" tab (http://127.0.0.1:4600#agents). There the student sees the engine login status -(claude/pi/cursor), changes each FDA agent's engine, model and reasoning, and +(claude/pi/cursor/grok), changes each FDA agent's engine, model and reasoning, and edits an optional `fallbacks:` chain — no YAML by hand. Save writes `imp/fia.config.yaml` preserving comments (a backup is kept; saving is locked while an FDA runs). Recommend it first; the manual edit below covers the same @@ -33,8 +54,9 @@ FDAs never name models — they name agents. The engine/model pair lives ONLY in | `claude_code` | official `claude` CLI | `claude` once (Pro/Max plan limits) | alias `sonnet`/`opus`/`haiku`/`fable` or full name; `effort: low\|medium\|high\|xhigh\|max\|ultracode` | | `pi` | Pi headless | subscriptions: `pi` → `/login openai-codex` or `github-copilot`; API keys via env | `provider/model-id`; `thinking: minimal\|low\|medium\|high` (Codex reasoning effort) | | `cursor` | Cursor Agent CLI | `cursor-agent login` (Cursor subscription) | id from `cursor-agent --list-models`; effort variants live in the id (e.g. `sonnet-4.5-thinking`) | +| `grok` | Grok Build (`grok` CLI, headless) | `grok login` once (xAI subscription; `curl -fsSL https://grok.com/install.sh \| bash` installs it). Detected from `~/.grok/auth.json` — NEVER `XAI_API_KEY` (per-token API; the FIA strips it) | `grok-4.6` / `grok-4.5` (`grok models`); `effort: low\|medium\|high\|xhigh` (no max). The first grok run trusts the project folder once so the FIA hooks apply | -Pi API-key providers (any of them, per agent): `openrouter/…` (OPENROUTER_API_KEY — one key, every model), `xai/…` (XAI_API_KEY — Grok), `groq/…`, `google/…` (GEMINI_API_KEY), `fireworks/…`, `deepseek/…`, `mistral/…` and more (see Pi providers doc). List models inside `pi` with `/model`. +Pi API-key providers (any of them, per agent): `openrouter/…` (OPENROUTER_API_KEY — one key, every model), `xai/…` (XAI_API_KEY — Grok through the API, per token; the subscription route is the `grok` engine above), `groq/…`, `google/…` (GEMINI_API_KEY), `fireworks/…`, `deepseek/…`, `mistral/…` and more (see Pi providers doc). List models inside `pi` with `/model`. Rule of thumb: heavy reasoning (planner, reviewer) on a frontier model; volume work (builder, scout, documenter) on a fast/cheap one — different providers in the SAME run is the point. diff --git a/src/lib/grok-auth.js b/src/lib/grok-auth.js new file mode 100644 index 0000000..112690b --- /dev/null +++ b/src/lib/grok-auth.js @@ -0,0 +1,52 @@ +/** + * Grok Build (the `grok` CLI, xAI subscription) — installer-side detection. + * Status only: the installer never runs an agent and never logs anyone in. + * + * Same signals the FIA runtime uses (fia-templates/modules/engines.mjs): + * - installed: `grok` on PATH, or the installer's own ~/.grok/bin/grok + * (install.sh drops it there and only appends to the shell rc); + * - logged in: ~/.grok/auth.json holds an OIDC entry with a refresh token + * (the CLI refreshes it itself — an expired `expires_at` is not a logout). + * XAI_API_KEY is deliberately NOT a signal: it means per-token API billing, + * outside the subscription — the FIA strips it from grok runs. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { which } from './proc.js'; + +export const GROK_INSTALL_HINT = 'curl -fsSL https://grok.com/install.sh | bash'; +const GROK_HOME = join(homedir(), '.grok'); + +export async function hasGrok() { + if (await which('grok')) return true; + return existsSync(join(GROK_HOME, 'bin', process.platform === 'win32' ? 'grok.exe' : 'grok')); +} + +export function readGrokAuth() { + const path = join(GROK_HOME, 'auth.json'); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +export function grokLoggedIn() { + const auth = readGrokAuth(); + if (!auth || typeof auth !== 'object') return false; + return Object.values(auth).some( + (entry) => entry && typeof entry === 'object' && Boolean(entry.refresh_token || entry.key || entry.access_token), + ); +} + +/** One human line for the engines panels (preflight, doctor, finish). */ +export async function grokStatusLine({ okMark = '✅', pendingMark = '○' } = {}) { + if (!(await hasGrok())) { + return `${pendingMark} Grok Build (xAI subscription) — not installed (optional).\n Install: ${GROK_INSTALL_HINT}\n Then run \`grok login\`.`; + } + return grokLoggedIn() + ? `${okMark} Grok Build (xAI subscription) — installed and logged in. Use it: \`imp llm set builder grok-4.6\` (or one run: --llm "grok-4.6 high").` + : `${pendingMark} Grok Build (xAI subscription) — installed, login pending.\n Log in: grok login (never set XAI_API_KEY — that bills per token).`; +} diff --git a/src/steps/doctor.js b/src/steps/doctor.js index 6f35f0a..267de4c 100644 --- a/src/steps/doctor.js +++ b/src/steps/doctor.js @@ -39,6 +39,7 @@ import { PI_PACKAGES, } from '../lib/pi-auth.js'; import { CLAUDE_INSTALL_HINT } from './preflight.js'; +import { grokStatusLine } from '../lib/grok-auth.js'; import { collectFindings } from './verify.js'; import { classifyHarnessState, readHarnessManifest } from '../lib/harness-manifest.js'; import { isRuntimeCode, missingRuntimeCode, runtimeHint } from '../lib/runtime-health.js'; @@ -105,6 +106,8 @@ async function enginesSection() { ? ok('Cursor CLI (cursor-agent) — installed (Cursor subscription).') : info('Cursor CLI (cursor-agent) — not installed (optional; see https://cursor.com/cli).'), ); + const grokLine = await grokStatusLine({ okMark: '', pendingMark: '' }); + rows.push(grokLine.startsWith(' Grok Build (xAI subscription) — installed and logged in') ? ok(grokLine.trim()) : info(grokLine.trim())); rows.push(info('None is mandatory — doctor only reports; which subscriptions to use is your call.')); return { title: 'Engines (subscriptions)', rows }; } diff --git a/src/steps/finish.js b/src/steps/finish.js index 963582c..b081841 100644 --- a/src/steps/finish.js +++ b/src/steps/finish.js @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { COMMUNITY } from '../config.js'; import { has, run, runInherit } from '../lib/proc.js'; import { piCodexReady } from '../lib/pi-auth.js'; +import { grokStatusLine, hasGrok } from '../lib/grok-auth.js'; import { osKind } from '../lib/platform.js'; import { CLAUDE_INSTALL_HINT } from './preflight.js'; import { STACK_CATEGORIES, STACK_LATER } from '../stack-catalog.js'; @@ -205,6 +206,9 @@ export async function finish(ctx) { // required: the professional decides which subscriptions to use. const claudeReady = await has('claude'); const codexReady = piCodexReady(); + // Grok Build shows up only when it is on this machine: it is a third, + // optional subscription engine, never something the install asks for. + const grokLine = ctx.fiaInstalled && (await hasGrok()) ? await grokStatusLine() : null; ui.note( [ claudeReady @@ -215,6 +219,7 @@ export async function finish(ctx) { ? '✅ Codex (ChatGPT Plus/Pro, via Pi) — logged in.' : `○ Codex (ChatGPT Plus/Pro, via Pi) — login pending.\n Log in: run \`${agentCmd(ctx)}\` and type /login openai-codex.` : null, + grokLine, '', 'Neither is mandatory. Agents give the best results with Claude and/or Codex,', ctx.fiaInstalled diff --git a/src/steps/preflight.js b/src/steps/preflight.js index 58f350b..5ff5cfa 100644 --- a/src/steps/preflight.js +++ b/src/steps/preflight.js @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { has, run, runInherit } from '../lib/proc.js'; import { ensurePiReady, piCodexReady } from '../lib/pi-auth.js'; +import { grokLoggedIn, hasGrok } from '../lib/grok-auth.js'; import { osKind, detectPackageManagers } from '../lib/platform.js'; import * as ui from '../lib/ui.js'; @@ -26,10 +27,12 @@ export const CLAUDE_INSTALL_HINT = { // walks the user through its own login on first run anyway. export async function checkEngines(ctx = {}) { - ui.step('Checking the engines (Claude Code and Codex)…'); + ui.step('Checking the engines (Claude Code, Codex and Grok Build)…'); const claude = await has('claude'); const codex = piCodexReady(); - ctx.engines = { claude, codex }; + const grokInstalled = await hasGrok(); + const grok = grokInstalled && grokLoggedIn(); + ctx.engines = { claude, codex, grok }; if (claude) { ui.success('Claude Code found.'); @@ -42,8 +45,15 @@ export async function checkEngines(ctx = {}) { } else { ui.info('Codex login not done yet — normal: it is the last step, inside Pi (/login openai-codex).'); } + // Grok Build is a third subscription engine — purely optional, recognized + // automatically when its login exists (never a warning when absent). + if (grok) { + ui.success('Grok Build login found (xAI subscription) — available as an engine.'); + } else if (grokInstalled) { + ui.info('Grok Build installed but not logged in — `grok login` enables it as an engine (optional).'); + } - if (!claude && !codex) { + if (!claude && !codex && !grok) { ui.note( [ 'Neither Claude Code nor a Codex login was found. Nothing stops here —', diff --git a/test/desktop-guard.test.js b/test/desktop-guard.test.js index 7604024..eb1d1f7 100644 --- a/test/desktop-guard.test.js +++ b/test/desktop-guard.test.js @@ -149,3 +149,26 @@ test('harness Cursor shim: deny on control, allow-open without the runtime', { s cpSync(CURSOR_SHIM, join(bare, '.cursor', 'hooks', 'desktop-guard-cursor.mjs')); assert.deepEqual(run(bare, { command: 'orca computer list-windows' }), { permission: 'allow' }); }); + +// ── Grok Build dialect: camelCase payload + grok shell tool id, bilingual deny ─ + +test('gateDecision: grok payload (toolName run_terminal_command / toolInput.command) is guarded too', () => { + assert.equal(gateDecision({ toolName: 'run_terminal_command', toolInput: { command: INCIDENT } }).block, true); + assert.equal(gateDecision({ toolName: 'run_terminal_command', toolInput: { command: 'npm test' } }).block, false); + assert.equal(gateDecision({ toolName: 'search_replace', toolInput: { file_path: 'x' } }).block, false); +}); + +test('gate CLI: the deny is bilingual — exit 2 + stderr for Claude Code, stdout JSON decision for grok', () => { + const blocked = runScript(['gate'], { + input: JSON.stringify({ hookEventName: 'pre_tool_use', toolName: 'run_terminal_command', toolInput: { command: INCIDENT } }), + }); + assert.equal(blocked.status, 2); + assert.match(blocked.stderr, /Blocked/); + const out = JSON.parse(blocked.stdout.trim()); + assert.equal(out.decision, 'deny'); + assert.match(out.reason, /Playwright/); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + const allowed = runScript(['gate'], { input: JSON.stringify({ toolName: 'run_terminal_command', toolInput: { command: 'git status' } }) }); + assert.equal(allowed.status, 0); + assert.equal(allowed.stdout.trim(), ''); +}); diff --git a/test/fda-lock.test.js b/test/fda-lock.test.js index 3ea7cef..5e67c73 100644 --- a/test/fda-lock.test.js +++ b/test/fda-lock.test.js @@ -266,3 +266,38 @@ test( assert.throws(() => acquireLock(join(root, 'imp', 'data'), 'newrun'), /already active/); }, ); + +// ── Grok Build dialect: camelCase payload, grok tool ids, bilingual deny ───── + +test('gateDecision: reads the grok payload (toolName/toolInput, search_replace/run_terminal_command)', () => { + const root = projectWith(LIVE); + const ctx = { root, lock: LIVE }; + assert.equal( + gateDecision({ toolName: 'search_replace', toolInput: { file_path: 'src/a.ts', old_string: 'a', new_string: 'b' } }, ctx).block, + true, + 'grok edits arrive as search_replace + file_path', + ); + assert.equal(gateDecision({ toolName: 'run_terminal_command', toolInput: { command: 'rm src/a.ts' } }, ctx).block, true); + assert.equal(gateDecision({ toolName: 'run_terminal_command', toolInput: { command: 'npm test' } }, ctx).block, false); + assert.equal(gateDecision({ toolName: 'read_file', toolInput: { target_file: 'src/a.ts' } }, ctx).block, false, 'reads stay allowed'); + assert.equal(gateDecision({ toolName: 'search_replace', toolInput: { file_path: join(tmpdir(), 'x.txt') } }, ctx).block, false); +}); + +test('gate CLI: a grok-dialect block answers in BOTH dialects (exit 2 + stderr + stdout JSON deny)', () => { + const root = projectWith(LIVE); + const r = runScript(['gate'], { + root, + input: JSON.stringify({ hookEventName: 'pre_tool_use', cwd: root, toolName: 'search_replace', toolInput: { file_path: join(root, 'src', 'a.ts') } }), + }); + assert.equal(r.status, 2); + assert.match(r.stderr, /FIA run active/); + const out = JSON.parse(r.stdout.trim()); + assert.equal(out.decision, 'deny', 'grok reads decision/reason'); + assert.match(out.reason, /FIA run active/); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny', 'Claude Code reads hookSpecificOutput'); + assert.equal(out.hookSpecificOutput.hookEventName, 'PreToolUse'); + // Allowed calls print nothing at all on stdout (no stray JSON for either host). + const ok = runScript(['gate'], { root, input: JSON.stringify({ toolName: 'read_file', toolInput: { target_file: 'x' } }) }); + assert.equal(ok.status, 0); + assert.equal(ok.stdout.trim(), ''); +}); diff --git a/test/fia-grok-engine.test.js b/test/fia-grok-engine.test.js new file mode 100644 index 0000000..4f2caaf --- /dev/null +++ b/test/fia-grok-engine.test.js @@ -0,0 +1,398 @@ +// Grok Build as a FIA engine (fia-templates/modules/engines.mjs + +// agent-grok.mjs): subscription detection from ~/.grok/auth.json, folder +// trust parsing (grok skips project hooks in silence without it), the models +// cache, the headless argv contract verified against grok 1.0.5, and the +// stream parser fed by a fake `grok` binary — no real CLI is ever spawned. +// +// HOME is pointed at a temp dir BEFORE the dynamic import (the module resolves +// ~/.grok paths once at load time), same pattern as fia-engines.test.js. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const fakeHome = mkdtempSync(join(tmpdir(), 'fia-grok-home-')); +process.env.HOME = fakeHome; +process.env.USERPROFILE = fakeHome; +const grokDir = join(fakeHome, '.grok'); +mkdirSync(join(grokDir, 'bin'), { recursive: true }); +mkdirSync(join(fakeHome, '.pi', 'agent'), { recursive: true }); + +const engines = await import('../fia-templates/modules/engines.mjs'); +const { buildGrokArgs, grokEffortOf, grokEnv, runGrok } = await import('../fia-templates/modules/agent-grok.mjs'); +const { validate } = await import('../fia-templates/modules/agents.mjs'); +const { classifyEngineFailure } = await import('../fia-templates/modules/continuation.mjs'); + +const { + ENGINE_NAMES, + GROK_EFFORTS, + engineIssue, + ensureGrokTrust, + grokBinary, + grokFolderTrusted, + grokLoggedIn, + grokModels, + isGrokModelId, + readGrokAuth, + checkEngines, + apiKeyProvider, +} = engines; + +// The real auth.json shape (grok 1.0.5): one entry per OIDC issuer::client id. +const AUTH_ENTRY = { + 'https://auth.x.ai::client-id': { + key: 'eyJ…bearer', + auth_mode: 'oidc', + refresh_token: 'rjKo…', + expires_at: '2026-08-23T00:00:00Z', // already expired — the CLI refreshes it + email: 'someone@example.com', + }, +}; + +test('grok is a registered engine with a shorter effort ladder than claude', () => { + assert.ok(ENGINE_NAMES.includes('grok')); + assert.deepEqual(GROK_EFFORTS, ['low', 'medium', 'high', 'xhigh']); + assert.ok(!GROK_EFFORTS.includes('max'), 'grok has no max'); +}); + +test('grokLoggedIn: any OIDC entry with a refresh token or key counts, expiry does not log out', () => { + assert.equal(grokLoggedIn(AUTH_ENTRY), true); + assert.equal(grokLoggedIn({ 'x::y': { key: 'k' } }), true); + assert.equal(grokLoggedIn({ 'x::y': { auth_mode: 'oidc' } }), false, 'entry without any token'); + assert.equal(grokLoggedIn({}), false); + assert.equal(grokLoggedIn(null), false); + assert.equal(grokLoggedIn('nope'), false); +}); + +test('readGrokAuth: missing and corrupt auth.json both yield null', () => { + assert.equal(readGrokAuth(join(fakeHome, 'absent.json')), null); + const corrupt = join(fakeHome, 'corrupt.json'); + writeFileSync(corrupt, '{not json'); + assert.equal(readGrokAuth(corrupt), null); +}); + +test('isGrokModelId: bare grok- ids only', () => { + assert.equal(isGrokModelId('grok-4.6'), true); + assert.equal(isGrokModelId('grok-4.5'), true); + assert.equal(isGrokModelId('xai/grok-4.5'), false, 'that is the Pi (API key) route'); + assert.equal(isGrokModelId('grok'), false); + assert.equal(isGrokModelId('opus'), false); +}); + +test('grokFolderTrusted: parses the TOML store, cascades to subdirectories, case-safe on the header', () => { + const store = join(fakeHome, 'trusted_folders.toml'); + const trustedRoot = mkdtempSync(join(tmpdir(), 'fia-grok-trusted-')); + const untrusted = mkdtempSync(join(tmpdir(), 'fia-grok-untrusted-')); + const escaped = trustedRoot.replace(/\\/g, '\\\\'); + writeFileSync( + store, + `[folders."${escaped}"]\ntrusted = true\ndecided_at = 1787536860\n\n[folders."${untrusted.replace(/\\/g, '\\\\')}"]\ntrusted = false\ndecided_at = 1\n\n[other]\ntrusted = true\n`, + ); + assert.equal(grokFolderTrusted(trustedRoot, store), true); + assert.equal(grokFolderTrusted(join(trustedRoot, 'apps', 'web'), store), true, 'trust cascades down'); + assert.equal(grokFolderTrusted(untrusted, store), false, 'trusted = false is not trust'); + assert.equal(grokFolderTrusted(tmpdir(), store), false, 'a parent of a trusted folder is not trusted'); + assert.equal(grokFolderTrusted(trustedRoot, join(fakeHome, 'no-such.toml')), false, 'no store → untrusted'); +}); + +test('grokModels: reads ids + advertised efforts from the cache, ships a default list without it', () => { + const cache = join(fakeHome, 'models_cache.json'); + writeFileSync( + cache, + JSON.stringify({ + models: { + 'grok-4.6': { + info: { + id: 'grok-4.6', + name: 'Grok 4.6', + context_window: 500000, + reasoning_effort: 'high', + reasoning_efforts: [{ id: 'xhigh' }, { id: 'high' }, { id: 'medium' }, { id: 'low' }], + }, + }, + 'grok-4.5': { info: { id: 'grok-4.5', reasoning_efforts: [{ value: 'high' }, { value: 'medium' }, { value: 'low' }] } }, + hidden: { info: { id: 'grok-secret', hidden: true } }, + }, + }), + ); + const models = grokModels(cache); + assert.deepEqual( + models.map((m) => [m.id, m.efforts.join('/'), m.context_window]), + [ + ['grok-4.6', 'xhigh/high/medium/low', 500000], + ['grok-4.5', 'high/medium/low', 0], + ], + ); + const shipped = grokModels(join(fakeHome, 'absent-cache.json')); + assert.deepEqual( + shipped.map((m) => m.id), + ['grok-4.6', 'grok-4.5'], + ); + assert.deepEqual(shipped[0].efforts, GROK_EFFORTS); +}); + +test('engineIssue: grok needs the binary AND the subscription login; never an API key', () => { + const offline = { grok: { installed: false, logged: false } }; + const noLogin = { grok: { installed: true, logged: false } }; + const ready = { grok: { installed: true, logged: true } }; + const agent = { coding_agent: 'grok', model: 'grok-4.6' }; + assert.match(engineIssue(agent, offline), /grok CLI .*not found/); + assert.match(engineIssue(agent, offline), /grok\.com\/install\.sh/, 'the exact install command'); + assert.match(engineIssue(agent, noLogin), /grok login/); + assert.match(engineIssue(agent, noLogin), /never set XAI_API_KEY/, 'the billing trap is named'); + assert.equal(engineIssue(agent, ready), null); + // Subscription engine → never flagged as per-token billing. + assert.equal(apiKeyProvider(agent), null); +}); + +test('grokBinary + checkEngines: ~/.grok/bin/grok counts even off PATH; login from auth.json', () => { + const savedPath = process.env.PATH; + const savedGrokPath = process.env.GROK_PATH; + delete process.env.GROK_PATH; + try { + process.env.PATH = mkdtempSync(join(tmpdir(), 'fia-grok-emptypath-')); + assert.equal(grokBinary(), null, 'nothing installed yet'); + let snap = checkEngines(); + assert.deepEqual(snap.grok, { installed: false, logged: false, trusted: false, api_key_env: false }); + + const local = join(grokDir, 'bin', process.platform === 'win32' ? 'grok.exe' : 'grok'); + writeFileSync(local, '#!/bin/sh\n'); + assert.equal(grokBinary(), local, "the installer's own bin dir is found"); + snap = checkEngines(); + assert.equal(snap.grok.installed, true); + assert.equal(snap.grok.logged, false, 'no auth.json yet'); + assert.match(engineIssue({ coding_agent: 'grok', model: 'grok-4.6' }, snap), /grok login/); + + writeFileSync(join(grokDir, 'auth.json'), JSON.stringify(AUTH_ENTRY)); + snap = checkEngines(); + assert.equal(snap.grok.logged, true, 'the subscription login is recognized automatically'); + assert.equal(engineIssue({ coding_agent: 'grok', model: 'grok-4.6' }, snap), null); + + process.env.GROK_PATH = '/custom/grok'; + assert.equal(grokBinary(), '/custom/grok', 'GROK_PATH wins'); + } finally { + process.env.PATH = savedPath; + if (savedGrokPath === undefined) delete process.env.GROK_PATH; + else process.env.GROK_PATH = savedGrokPath; + } +}); + +test('ensureGrokTrust: already-trusted is a no-op; no binary is a clear error, never a throw', () => { + const store = join(grokDir, 'trusted_folders.toml'); + const root = mkdtempSync(join(tmpdir(), 'fia-grok-root-')); + writeFileSync(store, `[folders."${root.replace(/\\/g, '\\\\')}"]\ntrusted = true\n`); + assert.deepEqual(ensureGrokTrust(root), { trusted: true, granted: false }); + const other = mkdtempSync(join(tmpdir(), 'fia-grok-root2-')); + const r = ensureGrokTrust(other, { bin: null }); + assert.equal(r.trusted, false); + assert.match(r.error, /not found/); +}); + +test('validate: grok is an accepted coding_agent for agents and fallbacks', () => { + const SYSTEM = new URL('../fia-templates/data/prompt_engineering/scout/system.md', import.meta.url).pathname; + const USER = new URL('../fia-templates/data/prompt_engineering/scout/user.md', import.meta.url).pathname; + const cfg = { + agents: [ + { + name: 'scout', + coding_agent: 'grok', + model: 'grok-4.6', + effort: 'high', + fallbacks: [{ coding_agent: 'grok', model: 'grok-4.5' }], + prompt_engineering: { system: SYSTEM, user: USER }, + }, + ], + }; + assert.doesNotThrow(() => validate(cfg, ['scout'])); + cfg.agents[0].coding_agent = 'xai'; + assert.throws(() => validate(cfg, ['scout']), /coding_agent xai not supported \(claude_code\|pi\|cursor\|grok\)/); +}); + +// ── agent-grok: argv contract ──────────────────────────────────────────────── + +test('buildGrokArgs: prompt in -p, stream-json dialect, rules APPEND the role, resume by session id', () => { + const args = buildGrokArgs({ prompt: 'do X', model: 'grok-4.6', effort: 'high', systemPrompt: '# Scout' }); + assert.equal(args[0], '-p'); + assert.equal(args[1], 'do X', 'the prompt is the value of -p, never a positional'); + assert.equal(args[args.indexOf('--output-format') + 1], 'streaming-messages-json'); + assert.equal(args[args.indexOf('--permission-mode') + 1], 'bypassPermissions'); + assert.equal(args[args.indexOf('--model') + 1], 'grok-4.6'); + assert.equal(args[args.indexOf('--reasoning-effort') + 1], 'high'); + assert.equal(args[args.indexOf('--rules') + 1], '# Scout', 'append, never --system-prompt-override'); + assert.ok(!args.includes('--system-prompt-override')); + assert.ok(!args.includes('--resume')); + + const resumed = buildGrokArgs({ prompt: 'fix', model: 'grok-4.6', systemPrompt: '# Scout', sessionId: 'sess-1' }); + assert.equal(resumed[resumed.indexOf('--resume') + 1], 'sess-1'); + assert.equal(resumed[resumed.indexOf('--rules') + 1], '# Scout', 'the role rides EVERY invocation (stable prefix)'); +}); + +test('grokEffortOf: grok ladder passes, claude/pi-only tiers clamp, per-model ladders clamp further', () => { + assert.equal(grokEffortOf('high'), 'high'); + assert.equal(grokEffortOf('max'), 'xhigh'); + assert.equal(grokEffortOf('ultracode'), 'xhigh'); + assert.equal(grokEffortOf('minimal'), 'low'); + assert.equal(grokEffortOf(''), null); + assert.equal(grokEffortOf('bogus'), null); + const models = [ + { id: 'grok-4.6', efforts: ['xhigh', 'high', 'medium', 'low'] }, + { id: 'grok-4.5', efforts: ['high', 'medium', 'low'] }, + ]; + assert.equal(grokEffortOf('xhigh', 'grok-4.6', models), 'xhigh'); + assert.equal(grokEffortOf('xhigh', 'grok-4.5', models), 'high', 'grok-4.5 advertises no xhigh'); + assert.equal(grokEffortOf('max', 'grok-4.5', models), 'high'); + assert.equal(grokEffortOf('high', 'grok-9.9', models), 'high', 'unknown model → no per-model clamp'); + // Thinking (pi field) is accepted as the level source too. + assert.equal(buildGrokArgs({ prompt: 'x', thinking: 'medium' }).at(-1), 'medium'); +}); + +test('grokEnv: XAI_API_KEY never reaches the child (subscription only)', () => { + const env = grokEnv({ PATH: '/bin', XAI_API_KEY: 'sk-xai' }, { FIA_FDA_RUN: 'abc' }); + assert.equal(env.XAI_API_KEY, undefined); + assert.equal(env.PATH, '/bin'); + assert.equal(env.FIA_FDA_RUN, 'abc'); +}); + +test('classifyEngineFailure: grok\'s "You are not authenticated" is a login death', () => { + assert.equal(classifyEngineFailure('Error: You are not authenticated. Run `grok login`.'), 'login'); + assert.equal(classifyEngineFailure('error: unknown model id "grok-9"'), 'crash'); +}); + +// ── agent-grok: stream parsing through a fake binary ──────────────────────── + +/** A fake `grok` that prints `lines` to stdout (and `stderr`), exits `code`. */ +function fakeGrok(lines, { code = 0, stderr = '' } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'fia-grok-fakebin-')); + const script = join(dir, 'grok.mjs'); + writeFileSync( + script, + `const out = ${JSON.stringify(lines)};\nfor (const l of out) process.stdout.write(l + '\\n');\n` + + `if (${JSON.stringify(stderr)}) process.stderr.write(${JSON.stringify(stderr)});\nprocess.exit(${code});\n`, + ); + const bin = join(dir, 'grok'); + writeFileSync(bin, `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`); + chmodSync(bin, 0o755); + return { bin, dir }; +} + +const STREAM = [ + JSON.stringify({ type: 'system', subtype: 'init', session_id: 'sess-42', apiKeySource: 'oauth', model: 'grok-4.6' }), + JSON.stringify({ + type: 'assistant', + session_id: 'sess-42', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'call-1', name: 'read_file', input: { target_file: 'hello.txt' } }], + usage: { input_tokens: 21000, output_tokens: 40, cache_read_input_tokens: 128, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'user', + session_id: 'sess-42', + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call-1', content: 'hello' }] }, + }), + JSON.stringify({ + type: 'assistant', + session_id: 'sess-42', + message: { + role: 'assistant', + content: [{ type: 'text', text: '{"status":"success","summary":"ok"}' }], + usage: { input_tokens: 82, output_tokens: 24, cache_read_input_tokens: 21248, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'result', + subtype: 'success', + result: '{"status":"success","summary":"ok"}', + total_cost_usd: 0.0091, + usage: { input_tokens: 21082, output_tokens: 64, cache_read_input_tokens: 21376, cache_creation_input_tokens: 0 }, + session_id: 'sess-42', + }), +]; + +test('runGrok: parses the stream-json dialect — text, session id, totals, live context, tool events', { skip: process.platform === 'win32' }, async () => { + const { bin } = fakeGrok(STREAM); + const saved = process.env.GROK_PATH; + process.env.GROK_PATH = bin; + const rawOutputPath = join(mkdtempSync(join(tmpdir(), 'fia-grok-raw-')), 'raw_output.jsonl'); + const events = []; + try { + const r = await runGrok( + { prompt: 'x', model: 'grok-4.6', effort: 'low', rawOutputPath, cwd: tmpdir(), env: {} }, + { onEvent: (e) => events.push(e.type) }, + ); + assert.equal(r.returncode, 0); + assert.equal(r.text, '{"status":"success","summary":"ok"}', 'the result event is the canonical text'); + assert.equal(r.session_id, 'sess-42'); + assert.equal(r.tokens, 21082 + 64 + 21376, 'the result usage replaces the per-message sum'); + assert.equal(r.cache_read_tokens, 21376); + assert.equal(r.cost, 0.0091); + assert.equal(r.context_tokens, 82 + 21248, 'live context = the LAST model call, not the sum'); + assert.deepEqual(events, ['system', 'assistant', 'user', 'assistant', 'result'], 'every event reaches the recorder'); + } finally { + if (saved === undefined) delete process.env.GROK_PATH; + else process.env.GROK_PATH = saved; + } +}); + +test('runGrok: a whole-stdout JSON object (--output-format json shape) still yields text + usage', { skip: process.platform === 'win32' }, async () => { + const pretty = JSON.stringify( + { + text: '{"status":"success"}', + stopReason: 'end_turn', + sessionId: 'sess-7', + usage: { input_tokens: 100, cache_read_input_tokens: 50, cache_creation_input_tokens: 0, output_tokens: 10, total_tokens: 160 }, + total_cost_usd: 0.002, + }, + null, + 2, + ).split('\n'); + const { bin } = fakeGrok(pretty); + const saved = process.env.GROK_PATH; + process.env.GROK_PATH = bin; + const rawOutputPath = join(mkdtempSync(join(tmpdir(), 'fia-grok-raw2-')), 'raw_output.jsonl'); + try { + const r = await runGrok({ prompt: 'x', model: 'grok-4.6', rawOutputPath, cwd: tmpdir(), env: {} }); + assert.equal(r.text, '{"status":"success"}'); + assert.equal(r.session_id, 'sess-7'); + assert.equal(r.tokens, 160); + assert.equal(r.context_tokens, 150); + assert.equal(r.cost, 0.002); + } finally { + if (saved === undefined) delete process.env.GROK_PATH; + else process.env.GROK_PATH = saved; + } +}); + +test('runGrok: a logged-out death surfaces the exit code and stderr for the classifier', { skip: process.platform === 'win32' }, async () => { + const { bin } = fakeGrok([], { code: 1, stderr: 'Error: You are not authenticated. Run grok login.\n' }); + const saved = process.env.GROK_PATH; + process.env.GROK_PATH = bin; + const rawOutputPath = join(mkdtempSync(join(tmpdir(), 'fia-grok-raw3-')), 'raw_output.jsonl'); + try { + const r = await runGrok({ prompt: 'x', model: 'grok-4.6', rawOutputPath, cwd: tmpdir(), env: {} }); + assert.equal(r.returncode, 1); + assert.equal(r.tokens, 0); + const { readFileSync } = await import('node:fs'); + assert.match(readFileSync(rawOutputPath, 'utf8'), /\[stderr\] Error: You are not authenticated/); + } finally { + if (saved === undefined) delete process.env.GROK_PATH; + else process.env.GROK_PATH = saved; + } +}); + +test('runGrok: a missing binary is rc 127 with the install command', async () => { + const saved = process.env.GROK_PATH; + process.env.GROK_PATH = join(fakeHome, 'definitely-not-grok'); + const rawOutputPath = join(mkdtempSync(join(tmpdir(), 'fia-grok-raw4-')), 'raw_output.jsonl'); + try { + const r = await runGrok({ prompt: 'x', model: 'grok-4.6', rawOutputPath, cwd: tmpdir(), env: {} }); + assert.equal(r.returncode, 127); + assert.match(r.text, /grok\.com\/install\.sh/); + assert.match(r.text, /grok login/); + } finally { + if (saved === undefined) delete process.env.GROK_PATH; + else process.env.GROK_PATH = saved; + } +}); diff --git a/test/fia-llm-override.test.js b/test/fia-llm-override.test.js new file mode 100644 index 0000000..e424ea1 --- /dev/null +++ b/test/fia-llm-override.test.js @@ -0,0 +1,196 @@ +// Run-scoped LLM override (`--llm`) — fia-templates/modules/llm-target.mjs + +// the FDA prologue in fda-cli.mjs. The roster on disk is never written: the +// spec is parsed with the same grammar `imp llm set` uses, applied to the +// run's config copy, printed, and saved with the run so `--resume` keeps the +// same LLM (a model that changed mid-run would break the session + cache). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + LLM_OVERRIDE_FILE, + applyLlmOverrides, + describeLlm, + engineLevels, + levelField, + normalizeLevel, + parseLlmSpec, + readLlmOverride, + resolveTarget, + writeLlmOverride, +} from '../fia-templates/modules/llm-target.mjs'; +import { applyRunLlm, parseFdaArgs } from '../fia-templates/modules/fda-cli.mjs'; +import { loadConfig } from '../fia-templates/modules/agents.mjs'; + +const TEMPLATE_CONFIG = fileURLToPath(new URL('../fia-templates/fia.config.yaml', import.meta.url)); + +/** The shipped roster, loaded the way an FDA loads it (defaults merged). */ +function shippedCfg() { + return loadConfig(TEMPLATE_CONFIG); +} + +test('resolveTarget: grok spellings all land on the grok engine with a canonical id', () => { + assert.deepEqual(resolveTarget('grok-4.6'), { coding_agent: 'grok', model: 'grok-4.6' }); + assert.deepEqual(resolveTarget('grok 4.6'), { coding_agent: 'grok', model: 'grok-4.6' }); + assert.deepEqual(resolveTarget('grok grok-4.5'), { coding_agent: 'grok', model: 'grok-4.5' }); + assert.deepEqual(resolveTarget('4.6', { engine: 'grok' }), { coding_agent: 'grok', model: 'grok-4.6' }); + // The API-key route stays what it always was: Pi + xai provider. + assert.deepEqual(resolveTarget('xai/grok-4.5'), { coding_agent: 'pi', model: 'xai/grok-4.5' }); + assert.throws(() => resolveTarget('opus', { engine: 'grok' }), /grok models are grok- ids/); + assert.throws(() => resolveTarget('grok'), /cannot tell which engine/); +}); + +test('levels: field + ladder per engine, clamping across ladders', () => { + assert.equal(levelField('claude_code'), 'effort'); + assert.equal(levelField('grok'), 'effort'); + assert.equal(levelField('pi'), 'thinking'); + assert.equal(levelField('cursor'), null); + assert.deepEqual(engineLevels('grok'), ['low', 'medium', 'high', 'xhigh']); + assert.equal(normalizeLevel('grok', 'max'), 'xhigh', 'grok has no max'); + assert.equal(normalizeLevel('grok', 'ultracode'), 'xhigh'); + assert.equal(normalizeLevel('grok', 'minimal'), 'low'); + assert.equal(normalizeLevel('pi', 'xhigh'), 'high', 'pi tops out at high'); + assert.equal(normalizeLevel('claude_code', 'minimal'), 'low'); + assert.equal(normalizeLevel('claude_code', 'ultracode'), 'ultracode'); + assert.equal(normalizeLevel('cursor', 'high'), null, 'cursor carries the level in the model id'); + assert.equal(normalizeLevel('grok', ''), null); + assert.equal(normalizeLevel('grok', 'bogus'), null); +}); + +test('parseLlmSpec: the grammar an engineer actually types', () => { + assert.deepEqual(parseLlmSpec('grok-4.6@high'), { agents: null, coding_agent: 'grok', model: 'grok-4.6', level: 'high', raw: 'grok-4.6@high' }); + assert.equal(parseLlmSpec('grok 4.6 high').model, 'grok-4.6'); + assert.equal(parseLlmSpec('grok 4.6 High').level, 'high', 'case-insensitive level'); + assert.equal(parseLlmSpec('grok 4.6').level, null); + assert.deepEqual(parseLlmSpec('builder=opus xhigh').agents, ['builder']); + assert.equal(parseLlmSpec('builder=opus xhigh').coding_agent, 'claude_code'); + assert.deepEqual(parseLlmSpec('builder, reviewer = openai-codex/gpt-5.6-sol:high').agents, ['builder', 'reviewer']); + assert.equal(parseLlmSpec('builder, reviewer = openai-codex/gpt-5.6-sol:high').level, 'high'); + assert.equal(parseLlmSpec('cursor sonnet-4.5-thinking').coding_agent, 'cursor'); + assert.equal(parseLlmSpec('fable').model, 'fable'); + assert.throws(() => parseLlmSpec(''), /--llm needs a value/); + assert.throws(() => parseLlmSpec('=opus'), /agent name/); + assert.throws(() => parseLlmSpec('bad agent=opus'), /agent name/); + assert.throws(() => parseLlmSpec('gpt-5.6'), /cannot tell which engine/); + assert.throws(() => parseLlmSpec('anthropic/claude-opus-5'), /extra usage/); +}); + +test('applyLlmOverrides: a bare spec retargets every agent; the roster copy only', () => { + const cfg = shippedCfg(); + const decisions = applyLlmOverrides(cfg, ['grok 4.6 high']); + assert.equal(decisions.length, cfg.agents.length); + for (const a of cfg.agents) { + assert.equal(a.coding_agent, 'grok'); + assert.equal(a.model, 'grok-4.6'); + assert.equal(a.effort, 'high'); + assert.equal(a.thinking, 'high', 'both fields set so the choice survives an engine relay'); + assert.equal(a.phase_overrides, undefined, 'an explicit level applies to EVERY phase of this run'); + } + const builder = decisions.find((d) => d.agent === 'builder'); + assert.deepEqual(builder.from, { coding_agent: 'pi', model: 'openai-codex/gpt-5.6-sol', level: 'medium' }); + assert.deepEqual(builder.to, { coding_agent: 'grok', model: 'grok-4.6', level: 'high' }); + assert.equal(builder.level_given, true); + // The YAML on disk is untouched. + assert.match(readFileSync(TEMPLATE_CONFIG, 'utf8'), /name: builder\n\s+coding_agent: pi/); +}); + +test('applyLlmOverrides: named specs win over the bare one; no level carries the agent level, clamped', () => { + const cfg = shippedCfg(); + const decisions = applyLlmOverrides(cfg, ['grok-4.6', 'builder=opus xhigh', 'reviewer,scout=openai-codex/gpt-5.6-sol']); + const by = Object.fromEntries(cfg.agents.map((a) => [a.name, a])); + assert.equal(by.builder.coding_agent, 'claude_code'); + assert.equal(by.builder.effort, 'xhigh'); + assert.equal(by.builder.phase_overrides, undefined); + assert.equal(by.reviewer.coding_agent, 'pi'); + assert.equal(by.reviewer.thinking, 'high', 'reviewer kept its own level (high)'); + assert.ok(by.reviewer.phase_overrides, 'no explicit level → the per-phase tuning stays'); + assert.equal(by.scout.thinking, 'low', 'scout kept low'); + // planner + documenter fall to the bare spec; planner (claude effort high) → grok effort high. + assert.equal(by.planner.coding_agent, 'grok'); + assert.equal(by.planner.effort, 'high'); + assert.equal(by.documenter.coding_agent, 'grok'); + assert.equal(by.documenter.effort, 'low', 'documenter thinking low → grok effort low'); + assert.equal(decisions.length, 5); + assert.equal(decisions.find((d) => d.agent === 'reviewer').level_given, false); +}); + +test('applyLlmOverrides: cursor drops the level fields; unknown agents are refused by name', () => { + const cfg = shippedCfg(); + applyLlmOverrides(cfg, ['builder=cursor sonnet-4.5-thinking']); + const builder = cfg.agents.find((a) => a.name === 'builder'); + assert.equal(builder.coding_agent, 'cursor'); + assert.equal(builder.effort, undefined); + assert.equal(builder.thinking, undefined); + assert.throws(() => applyLlmOverrides(shippedCfg(), ['ghost=opus']), /no agent "ghost" in the roster — agents: planner, builder/); + assert.throws(() => applyLlmOverrides({ agents: [] }, ['opus']), /matched no agent/); +}); + +test('describeLlm renders the engine-specific level field', () => { + assert.equal(describeLlm({ coding_agent: 'grok', model: 'grok-4.6', effort: 'high', thinking: 'high' }), 'grok · grok-4.6 · effort high'); + assert.equal(describeLlm({ coding_agent: 'pi', model: 'openai-codex/gpt-5.6-sol', thinking: 'medium' }), 'pi · openai-codex/gpt-5.6-sol · thinking medium'); + assert.equal(describeLlm({ coding_agent: 'cursor', model: 'sonnet-4.5-thinking', effort: 'high' }), 'cursor · sonnet-4.5-thinking'); +}); + +test('write/readLlmOverride: round-trip with the run; malformed reads as absent', () => { + const sessionDir = join(mkdtempSync(join(tmpdir(), 'fia-llm-ov-')), 'sessions', 'run1'); + const record = writeLlmOverride(sessionDir, { specs: ['grok-4.6 high'], decisions: [{ agent: 'builder' }] }); + assert.ok(record.at); + const back = readLlmOverride(sessionDir); + assert.deepEqual(back.specs, ['grok-4.6 high']); + assert.equal(back.decisions[0].agent, 'builder'); + writeFileSync(join(sessionDir, LLM_OVERRIDE_FILE), '{"specs": "not a list"}'); + assert.equal(readLlmOverride(sessionDir), null); + assert.equal(readLlmOverride(join(sessionDir, 'nope')), null); +}); + +test('parseFdaArgs: --llm is repeatable and trimmed', () => { + const args = parseFdaArgs(['do it', '--llm', ' grok-4.6 high ', '--llm', 'builder=opus']); + assert.deepEqual(args.llm, ['grok-4.6 high', 'builder=opus']); + assert.deepEqual(parseFdaArgs(['do it']).llm, []); +}); + +test('applyRunLlm: the flag applies and prints; a resume without the flag re-applies the saved override', () => { + const root = mkdtempSync(join(tmpdir(), 'fia-llm-run-')); + const dataDir = join(root, 'imp', 'data'); + mkdirSync(join(dataDir, 'sessions', 'abc12345'), { recursive: true }); + const logs = []; + const orig = console.log; + console.log = (...a) => logs.push(a.join(' ')); + try { + const cfg = shippedCfg(); + cfg.defaults.data_dir = dataDir; + const fresh = applyRunLlm(cfg, { llm: ['scout=grok 4.6 low'], resume: false, fdaId: null }); + assert.equal(fresh.source, 'flag'); + assert.equal(fresh.decisions.length, 1); + assert.equal(cfg.agents.find((a) => a.name === 'scout').coding_agent, 'grok'); + assert.ok(logs.some((l) => /LLM override for THIS run only/.test(l))); + assert.ok(logs.some((l) => /scout: pi · openai-codex\/gpt-5.6-sol \(low\)\s+→\s+grok · grok-4.6 · effort low/.test(l)), logs.join('\n')); + + // Nothing saved yet for that run → a resume runs on the roster. + const cfg2 = shippedCfg(); + cfg2.defaults.data_dir = dataDir; + const none = applyRunLlm(cfg2, { llm: [], resume: true, fdaId: 'abc12345' }); + assert.equal(none.decisions.length, 0); + assert.equal(cfg2.agents.find((a) => a.name === 'scout').coding_agent, 'pi'); + + // Saved with the run → the resume keeps the same LLM without the flag. + writeLlmOverride(join(dataDir, 'sessions', 'abc12345'), { specs: fresh.specs, decisions: fresh.decisions }); + const cfg3 = shippedCfg(); + cfg3.defaults.data_dir = dataDir; + const resumed = applyRunLlm(cfg3, { llm: [], resume: true, fdaId: 'abc12345' }); + assert.equal(resumed.source, 'saved'); + assert.equal(cfg3.agents.find((a) => a.name === 'scout').model, 'grok-4.6'); + assert.ok(logs.some((l) => /re-applied from run abc12345/.test(l))); + + // An explicit flag on resume wins over the saved one. + const cfg4 = shippedCfg(); + cfg4.defaults.data_dir = dataDir; + const replaced = applyRunLlm(cfg4, { llm: ['scout=fable'], resume: true, fdaId: 'abc12345' }); + assert.equal(replaced.source, 'flag'); + assert.equal(cfg4.agents.find((a) => a.name === 'scout').model, 'fable'); + } finally { + console.log = orig; + } +}); diff --git a/test/fia-llm.test.js b/test/fia-llm.test.js index d2c38d7..4efe0e5 100644 --- a/test/fia-llm.test.js +++ b/test/fia-llm.test.js @@ -152,3 +152,43 @@ test('a backup accumulates per switch (nothing is ever overwritten in place)', ( const backups = readdirSync(join(root, 'imp', 'data', 'backups')).filter((f) => f.startsWith('fia.config.')); assert.ok(backups.length >= 1, 'at least one backup kept'); }); + +// ── Grok Build (xAI subscription) through the switcher ────────────────────── + +test('resolveTarget: grok- ids and "grok 4.6" route to the grok engine', () => { + assert.deepEqual(resolveTarget('grok-4.6'), { coding_agent: 'grok', model: 'grok-4.6' }); + assert.deepEqual(resolveTarget('grok 4.6'), { coding_agent: 'grok', model: 'grok-4.6' }); + assert.deepEqual(resolveTarget('grok-4.5', { engine: 'grok' }), { coding_agent: 'grok', model: 'grok-4.5' }); + // The per-token route is still spelled provider/id on pi — a different thing on purpose. + assert.deepEqual(resolveTarget('xai/grok-4.6'), { coding_agent: 'pi', model: 'xai/grok-4.6' }); +}); + +test('applyChange: grok accepts its own effort ladder and refuses claude-only tiers', () => { + const { root, configPath } = makeProject(); + const result = applyChange({ root, configPath }, { agent: 'builder', target: 'grok-4.6', effort: 'xhigh' }); + assert.deepEqual(result.to, { coding_agent: 'grok', model: 'grok-4.6' }); + const view = rosterView(configPath); + assert.equal(view.agents[1].coding_agent, 'grok'); + assert.equal(view.agents[1].effort, 'xhigh'); + assert.ok(!result.warnings.some((w) => /API key/.test(w)), 'subscription engine — no per-token warning'); + assert.throws( + () => applyChange({ root, configPath }, { agent: 'builder', target: 'grok-4.6', effort: 'max' }), + /grok effort must be low\|medium\|high\|xhigh/, + ); +}); + +test('runCli --json exposes the grok ladder alongside the claude one', async () => { + const { root } = makeProject(); + const logs = []; + const origLog = console.log; + console.log = (...a) => logs.push(a.join(' ')); + try { + assert.equal(await runCli(['--json'], { root }), 0); + const payload = JSON.parse(logs.at(-1)); + assert.deepEqual(payload.grok_efforts, ['low', 'medium', 'high', 'xhigh']); + assert.ok(payload.coding_agents.includes('grok')); + assert.ok('grok' in payload.engines, 'engine snapshot carries grok'); + } finally { + console.log = origLog; + } +});