Configuration

Configure

Run phax init to create phax.json and phax.schema.json in the current directory. When stdin is a TTY it launches an interactive wizard (like npm init) that prompts for the project slug, gate commands, and optional compliance/publish toggles:

phax init           # interactive wizard (TTY) or non-interactive (detected defaults)
phax init --yes     # non-interactive: accept detected defaults without prompting
phax init --force   # reconfigure an existing phax.json (prompts again in a TTY)

In a non-TTY environment (CI, pipes) phax init automatically falls back to detected defaults with all optional toggles off — it never hangs waiting for input.

The wizard pre-fills the project slug from package.json's name field (slugified), detects the package manager from the packageManager field, and suggests gate commands from existing scripts (typecheck, lint, test:unit, format:check, build). It writes phax.json, phax.schema.json, and phax.user.schema.json.

phax.schema.json is a JSON Schema generated from the installed binary's config contract — wire it up as "$schema": "./phax.schema.json" for editor validation. After upgrading phax, run phax schema upgrade to regenerate it (see Schema upgrade).

Or add a phax.json manually at your repo root:

{
  "$schema": "./phax.schema.json",
  "version": 1,
  "name": "my-project",
  "security": { "profile": "secure" },
  "fileReconciliation": { "mode": "report_only" },
  "review": { "compliance": { "enabled": true } },
  "publish": { "auto": true, "remote": "origin", "baseBranch": "main" },
  "commands": {
    "setup": ["pnpm install"],
    "cleanup": ["rm -rf node_modules"]
  },
  "gateProfiles": {
    "full": [
      { "command": "pnpm typecheck", "surface": "local", "firing": "every-phase" },
      { "command": "pnpm test:unit", "surface": "local", "firing": "every-phase" },
      { "command": "pnpm lint", "surface": "structural", "firing": "every-phase" },
      { "command": "pnpm build", "surface": "product", "firing": "terminal" },
      {
        "command": "pnpm audit:security",
        "surface": "structural",
        "firing": "every-phase",
        "output": "diagnostics"
      }
    ]
  }
}

Each gate profile is a named list of attributed steps, not a flat command list. Every step carries these dimensions:

  • surface — a closed enum, local | structural | product, describing what the step verifies (local dev checks, structural/repo-wide checks, or product/build output). This is pure attribution: phax records it and never branches on it.
  • firing — every-phase | terminal. This is behavioral: every-phase steps run at every phase gate; terminal steps run only at the final phase gate, in addition to the every-phase steps.
  • output — optional, log | diagnostics, defaults to log. A "log" step's stdout/stderr are appended to the attempt log as raw text, same as today. A "diagnostics" step's stdout is decoded as a JSON document { "diagnostics": [{ "rule", "class": "invariant"|"completion", "scopes"?: [...], "location": { "file", "line"? }, "message", "repair" }, ...] }; the verdict comes from that document instead of the exit code. Every diagnostic declares a class: an "invariant" diagnostic always fails the step. A "completion" diagnostic names one or more scopes and fails the step only once every scope it names is closed, as reported by the registered scopes provider (see Scope provider) — otherwise it is pending: it does not fail the phase, and is shown to the fix-loop agent as optional work. A step with only pending diagnostics records pending (not pass/fail) in gate-attribution.json and never counts its surface as verified. Pending findings are persisted as checks-attempt-NN.pending.json next to the attempt log; a failing document is persisted as checks-attempt-NN.diagnostics.json, and its failing diagnostics — not the raw log — drive the fix prompt. A missing/undecodable document, or a non-zero exit with an empty list, is a provider error that still fails the step (with the raw log, since there is no document to show).

There is no fast/full depth convention to pick between — a project defines a single profile, and firing carries the cadence that used to be encoded in separate fast/full profile keys. The old flat { "full": ["pnpm test", ...] } array form is rejected at validation, naming the offending profile.

After each phase gate, phax records which steps ran, their surface, and their pass/fail result in <phase>/gate-attribution.json. At run end, the final report's ## Run Summary lists the set of surfaces verified during the run — a surface counts as verified only when every step of it that ran passed. Each phase's run record also names its verified surfaces, so phax records list and phax records explain can show surface coverage without opening artifacts.

The top-level name is the run namespace — run short-names are scoped under it. Provider routing is not configured here — it lives in the global ~/.phax/ config (see Multi-provider model routing). The optional security.profile (secure | unsafe | isolated, default secure) sets the default security posture for runs; see Security modes. The optional fileReconciliation.mode (report_only | warn, default report_only) controls how per-phase file reconciliation reports deviations from the plan; see Run. The optional review.compliance and publish blocks turn on an automatic plan-compliance review and a pushed pull request when each run reaches review; see Compliance review & publishing.

The optional authoring.spec and authoring.plan blocks each take an optional model and effort, resolved flag → config → catalog default (claude-opus-5-5 at high effort) for the headless authoring command:

"review": { "code": { "model": "claude-opus-5-5", "effort": "high" } }
→
"review": { "code": { "model": "claude-opus-5-5", "effort": "high" } },
"authoring": {
  "spec": { "model": "claude-opus-5-5", "effort": "high" },
  "plan": { "model": "claude-opus-5-5", "effort": "high" }
}

Both keys are optional and independent — an absent model or effort falls back to the catalog default, so a version: 1 config without an authoring block loads unchanged.

Orient provider

Add an "orient" block to tell phax how to fetch orientation rows for the current project:

{
  "orient": { "command": "node ./orient.mjs" }
}

The command string is split on whitespace with no shell — use a wrapper script if the path contains spaces or you need a pipeline. phax writes a JSON request to the provider's stdin and reads a JSON response from stdout; the provider must exit 0 on both success and "not found" responses.

  • Index request — {"files": ["src/foo.ts", ...]}: respond with {"rows": [{"id", "title", "severity", "trigger"}, ...]} for every row whose trigger prefix matches any file in the list. severity is one of "error" | "warn" | "info".
  • Expand request — {"expand": "<id>"}: respond with {"row": {"id", "title", "severity", "trigger", "body"}} for a known id, or {"row": null} for an unknown one.
  • All fields are non-empty strings. A non-zero exit, non-JSON stdout, or a response that fails validation is a provider error (exit 1). An empty index or a null row prints "No orientation available." and exits 0.
  • During a run phax sends the index request for each phase's planned files and weaves the rows into the phase prompt. When orient is configured, phax orient is implicitly granted to the in-phase agent without an agentCommands entry.

Full contract: phax orient.

Scope provider

Add a "scopes" block to register the provider that answers, for a completion diagnostic (see Configure above), which scopes are already closed:

{
  "scopes": { "command": "node ./scopes.mjs" }
}

The command string is split on whitespace with no shell, same as orient. Before each non-terminal phase's gate — only when that gate has at least one output: "diagnostics" step — phax writes the plan projection to the provider's stdin:

{
  "phase": "phase-02",
  "phases": [
    { "id": "phase-01", "files": ["src/core/billing/port.ts"] },
    { "id": "phase-02", "files": ["src/core/billing/invoice.ts"] },
    { "id": "phase-03", "files": ["src/adapters/billing/stripe.ts"] }
  ]
}

phases[].files is each phase's planned files to create and edit, deduplicated, in plan order (optionalFilesToEdit is never included). The provider responds on stdout with {"closed": ["<scope>", ...]} and must exit 0. A completion diagnostic fails the step once every scope it names appears in closed; otherwise it is pending. The terminal phase closes every scope without querying the provider — it is never called. If a gate step returns a completion diagnostic but no scopes provider is registered, the gate fails through the fix loop with a configuration-error message naming phax.json. A non-zero exit, non-JSON stdout, or a response that fails validation likewise fails the gate through the fix loop, with the reason in the attempt log — the same treatment as an orient provider error.

Validate it before running:

phax validate
# also validate a phax-plan.json:
phax validate --plan phax-plan.json

Plan auditor

Add a "planAuditor" block to register a provider that reviews a plan's shape before a run touches it:

{
  "planAuditor": { "command": "node ./audit-plan.mjs" }
}

The command string is split on whitespace with no shell, same as orient and scopes. phax plans lint queries it — never phax run — and only once the plan's deterministic extraction succeeds. It writes the plan projection to the provider's stdin:

{
  "phases": [
    { "id": "phase-01", "files": ["src/greet.ts"] },
    { "id": "phase-02", "files": ["tests/greet.test.ts"] }
  ]
}

This is the same projection the scope provider receives, minus the gated phase id: phases[].files is each phase's planned files to create and edit, deduplicated, in plan order (optionalFilesToEdit is never included). Models, efforts, prompts, anchors and commit metadata never leave phax. The provider responds on stdout with {"findings": [{"message", "phases": [...]}]} and must exit 0. Every finding renders as a warning on the lint's advisory check, one row per phase named in phases (- when the list is empty). A non-zero exit, non-JSON stdout, or a response that fails validation is a single advisory warning naming the reason, as is an auditor that outruns the 30s cap phax spawns it under. Advisory findings never set the lint's exit code, and with no planAuditor registered — or on a plan the deterministic parser cannot read — there are no advisory findings.

Configuration layers

phax resolves configuration from four layers, least-to-most specific (most personal wins):

LayerFilePurpose
Built-in defaults—~/.phax state root, maxFixAttempts: 1, etc.
Project configphax.json (committed)Team baseline: gate profiles, identity, security grants
Global user config~/.phax/config.jsonMachine-wide preferences: model, state root, MCP mode
Per-project user configphax.local.json (gitignored)This user × this repo overrides

Scalars — the highest present layer wins (e.g. state.root, agent.maxFixAttempts, security.profile).

Allowlists — union across all layers, so user layers can only add to the project's security baseline, never silently remove it. This applies to security.filesystem.allowRead, security.filesystem.allowWrite, security.agentCommands, security.mcp.allow, and gateProfiles (union by key; the higher layer's command array wins for a shared key).

phax.local.json is gitignored — it is the right place for per-developer preferences like model selection or trust overrides that should not be committed. ~/.phax/config.json is for preferences that apply to all repos on your machine. A JSON Schema for both user layers is generated alongside phax.schema.json as phax.user.schema.json.

Schema upgrade

After upgrading phax, regenerate phax.schema.json to match the new binary's config contract:

phax schema upgrade

This rewrites phax.schema.json and phax.user.schema.json next to the nearest phax.json and reports whether the files changed or were already current. It never modifies phax.json.