# Project Optimizer

> crystl scores how well a project is set up for a coding agent, names the gaps, and tells you what each one costs.

The project optimizer reads a project the way your agent reads it, and tells you what is missing. It finds the instruction file, checks what is in it, looks at the size of your source files and at your `.claude/settings.json`, and gives you a score out of 100 with the gaps listed under it.

It is most useful on a project you have just opened. Fix the high findings before you fix any code — an agent that cannot build or test your project will guess, and a guess costs more than the five minutes the fix takes.

## Opening it

- A gem's **⋮** menu → **optimize**.
- The gem panel's **AGENT FILES** row → **optimize**.
- The terminal: `crystl optimize`.

The window runs a fresh analysis every time it opens. **re-analyze** rescans without closing it.

## From the terminal

```bash
crystl optimize                  # the project this shard is in
crystl optimize --gem myapp      # a named gem
crystl optimize --json           # machine-readable
crystl optimize --show           # also open the panel for the user
```

With no `--gem`, it scores **the calling shard's own gem** — not whatever gem is on screen. A worker running in a background gem reports its own project's gaps. Outside a shard it falls back to the selected gem. If you are in a shard whose gem is not open, it refuses rather than quietly scoring the wrong project.

It is read-only and free on every tier, and it exits 0 even when it finds things. A gap is information, not an error, so it will not break a script that runs it.

`--show` never moves you. If the gem you addressed is not the one on screen, the panel is refused and nothing opens.

Two things the CLI does not include: the credential scan (run `crystl keys scan`) and the mistakes-log pass line only appears when a log is there.

## The score

Four category scores, each out of 100, combined by weight:

| Category | Weight | What it is |
|---|---|---|
| **content** | 3 | what your instructions say |
| **files** | 2 | which instruction files exist, and how they connect |
| **code** | 2 | source files that have grown too large |
| **setup** | 1 | `.claude/settings.json`, plugin recommendations |

```
overall = (content×3 + files×2 + code×2 + setup×1) ÷ 8
```

Grades: **high** is 75 and up, **medium** is 40 to 74, **low** is under 40. The window shows the number beside the word, because `high` covers a quarter of the range and a word alone cannot tell you that you improved anything.

Each category is a merge under the hood. Content is the average of the instruction checks and the content-quality checks. Files is the file checks, minus what the AGENTS.md bridge costs, averaged with the cross-file checks. Setup is the settings checks averaged with the plugin recommendations.

### Nothing to read scores 50, not 100

If the optimizer cannot find or read a project's instructions, the content, quality and cross-file checks return **50**. If it finds no source files, code returns 50.

This is the fix that mattered most. Those checks used to return 100 when there was nothing to read — so an empty directory scored **94 and graded high**, while carrying the worst finding the tool can emit. Fifty means "unknown", which is what it is. An empty directory now scores 58.

### A leaked key moves the number

The panel scans the files git would commit for API keys, and those findings now cost points:

| Finding | Cost | Cap |
|---|---|---|
| a file holding a key you saved in crystl | 15 | 45 |
| a file holding something shaped like a vendor key | 5 | 15 |

Both come straight off the overall score, and both tiers stack. On top of that, a project cannot grade **high** while a key crystl itself holds sits in a committable file — the badge is capped at medium. The shaped tier is a guess, so it never touches the badge, and its cap of 15 is chosen so a guess alone can never drop a project out of high.

Those rows are put at the front of the gap list, ahead of every other finding including the other high ones. Full detail on the scan itself, including what "public files" means: [Key Scan](/docs/key-scan).

## Where your instructions are

Every content check runs against whatever your agent actually reads. The optimizer looks in this order: `CLAUDE.md`, `claude.md`, `.claude/CLAUDE.md`, then `AGENTS.md`. It follows a symlink, and it substitutes any `@path` import in place before it scores a word.

So a project that keeps everything in `AGENTS.md` and writes `@AGENTS.md` in its `CLAUDE.md` is scored on what `AGENTS.md` says. That is right — it is what your agent reads.

### The AGENTS.md bridge

Claude Code opens `CLAUDE.md` and nothing else. It does not read `AGENTS.md`. So a project can keep every rule it owns in `AGENTS.md`, look well documented, and hand Claude Code nothing.

The optimizer checks for that. If `AGENTS.md` exists and nothing connects it to `CLAUDE.md`, you get a high finding worth 30 points off the files score. Two things count as connected:

- an `@AGENTS.md` line in `CLAUDE.md`, followed as far as it goes — a chain through a third file still counts;
- a `CLAUDE.md` that is a symlink to `AGENTS.md`, or a symlink chain that ends there.

The link has to work. A dangling symlink is not a bridge, and gets its own finding.

The same walk names an import loop. If following your `@` lines comes back to a file it already read, the finding prints the chain with the repeated file at both ends, so you can see which line to delete.

## What it checks

Findings carry a severity and a category. The cost is what the finding takes off its own category score before the weighting above.

### files

| Finding | Severity | Cost |
|---|---|---|
| No CLAUDE.md | high | 35 |
| CLAUDE.md is a broken link | high | 35 |
| `<file>` cannot be read | high | 35 |
| AGENTS.md is not connected to CLAUDE.md | high | 30 |
| No AGENTS.md | medium | 25 |
| Imported file `<path>` does not exist | medium | 15 each, capped at 30 |
| Instruction imports form a loop | medium | 15 |
| CLAUDE.md and codex.md conflict | medium | 15 |
| CLAUDE.md is gitignored | medium | 10 |
| CLAUDE.local.md not gitignored | medium | 10 |
| .claude/settings.local.json not gitignored | medium | 10 |

"No CLAUDE.md" is skipped when Codex is the active agent and the project has no Claude setup at all — a correct Codex project keeps its instructions in `AGENTS.md` and is scored on those. "No AGENTS.md" is medium on purpose: nothing is broken, the project just is not portable to a second agent.

The conflict row only fires when a legacy `codex.md` sits beside `CLAUDE.md` and the two disagree on naming convention or on a file size limit.

### content

| Finding | Severity | Cost |
|---|---|---|
| No Build & Run section | high | 30 |
| No testing commands section | high | 25 |
| Build & Run is a placeholder | high | 20 |
| No code conventions or style guide | medium | 20 |
| No verification command | medium | 15 |
| `<N>` stale paths in CLAUDE.md | medium | 15 |
| No file size limit stated | low | 15 |
| Build section has no copy-pasteable commands | low | 15 |
| CLAUDE.md is very short (`<N>` words) | low | 10 |
| Instructions are too vague | low | 10 |
| No known issues or gotchas section | low | 10 |
| No commit guidelines | low | 5 |

A few of these need explaining:

- **Stale paths** names them rather than counting them: three, then `+N more`.
- **Very short** fires under 100 words, and prints the count.
- **Too vague** needs three or more phrases like "write clean code" *and* fewer than 200 words of content. A long file with one such phrase is fine.
- **No verification command** is satisfied by a verification section or by a recognised command anywhere in the file — `swift test`, `npm test`, `pytest`, `cargo check`, `go vet`, `make test`, `npm run lint`, `--noEmit`.
- **No file size limit** is satisfied by any phrasing that says a number and "lines" — "600-line ceiling" counts.
- **No commit guidelines** is satisfied by a commit or git heading, or by a phrase such as "atomic commit" or "one fix per commit".

### code

| Finding | Severity | Cost |
|---|---|---|
| `<N>` files over 800 lines | high | 10 per file, capped at 40 |

One threshold, not two. The finding lists three project-relative paths and a count of the rest. The walk stops after 500 files and says so in the finding when it does, so a count never hides its own denominator. Files whose names say they are not yours — editor backups, conflicted copies — are skipped before they are counted.

### setup

| Finding | Severity | Cost |
|---|---|---|
| `<N>` public files contain keys saved in crystl | high | see [the score](#a-leaked-key-moves-the-number) |
| `<N>` public files may contain keys | medium | see above |
| .claude/settings.json is not valid JSON | medium | 20 |
| No hooks configured | low | 3 |
| No permission rules in settings | low | 3 |
| `<Product>` project — install plugin | low | 3 |
| `<Product>` project — re-enable plugin for this gem | low | 3 |

The two settings rows read the file as JSON, and only fire when the file parses. An empty `"hooks": {}` counts as no hooks, because that is what it is. Permission rules are looked for under `permissions.allow` and `permissions.deny`, where Claude Code keeps them.

A `settings.json` that will not parse is its own finding. Your agent gets the same nothing crystl does, so every hook and every permission rule in that file is inert — and neither row above can say so, which is why this one exists.

Plugin rows fire when the project shows a signal for a plugin crystl knows about (Vercel, Supabase, Cloudflare, Sentry, Stripe in the seed catalog). They are skipped entirely on SSH gems, because plugin state lives on your Mac.

### Checks with no score

Some checks report without moving the number.

**The mistakes log.** If the project keeps one — `MISTAKES.md`, `LESSONS.md`, `LEARNINGS.md`, `GUARDRAILS.md`, `GOTCHAS.md`, `.claude/MISTAKES.md`, or a mistakes section inside the instructions — you get a **checked, no issues** line saying so, with the entry count. If the log is a file and your instructions never mention it, you get a low finding: your agent only opens that file when told to.

A project with no log gets nothing at all. No finding, no line. Almost nobody keeps one, so a finding here would fire on every project and mean nothing.

**The clean key scan.** When the scan runs and finds nothing, it says what it covered: `checked 7 patterns across 2726 public files`. A count with no coverage beside it is a promise the scan cannot keep.

## Checks that were removed

If you have seen these before, they are gone on purpose.

- **No .claudeignore.** Claude Code does not read one. The finding reported a problem that did not exist and offered to fix it with a file that cannot.
- **Subpackage missing CLAUDE.md.** Claude Code already reads the root file from anywhere in the tree. The row called the normal shape of a monorepo a defect.
- **No path-scoped rules.** It rewarded a `paths:` frontmatter key that an open upstream bug makes inert. Scoring a project up for writing it was telling you something untrue.
- **CLAUDE.md is too long.** Length is not a defect on its own, and the projects that scored worst were the ones that had written the most down.
- **No skills, no MCP servers, no rules directory, no settings.json.** Six rows that all asserted a problem from an absence. Every project that had not opted into one of crystl's conveniences got the same list, so the list stopped being about the project.
- **Stack not mentioned.** It fired *because* the project had a `package.json`, then asked you to write down what that file already says.

The pattern behind all of them: a finding has to be a fact about your project, not an inference from something not being there.

## Handing it to your agent

Two buttons on the analyze tab:

- **send to shard** writes the report and types the prompt into the gem's own shard.
- **copy for agent** writes the report and puts the prompt on your clipboard, to paste wherever you like.

Both write `.crystl/optimization-report.md`: the score, all six category numbers, the passed checks, and every gap with its detail. The report is stamped with the time and the crystl version that made it, because the check set moves between releases and an undated report cannot be told from a current one.

The credential rows are deliberately kept **out** of that file. They are a map of which committable file holds a key on which line, and that file is itself committable. They travel in the prompt instead, which nobody commits. When the report withholds rows, it says so rather than printing "no issues found".

## Explore tab

The second tab browses the project's file tree. Each directory shows its agent instruction files with edit buttons, its subdirectories, and a **+** for adding a file from your saved library.

---
Source: https://crystl.dev/docs/project-optimizer/
