- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a ten-year-old PHP app, start a coding agent and type "add a refund endpoint". The agent does what it is built to do. It lists the root folder, reads composer.json, greps for Controller, opens three files that turn out to be dead, finds a second tests/ folder nobody runs, and only then starts on your task.
The agent is doing its job. Claude Code's docs describe every task as a loop of "gather context, take action, and verify results", and searching and reading files is how it gathers. On a repo it has never seen, gathering is most of the first few turns. Every file it reads lands in the context window, and each new session starts with a fresh one, so the next session pays for the same tour again.
Most of what the agent is hunting for, you can compute with four commands and hand to Claude Code or Codex before the first prompt.
What goes in the map, and why 2,000 tokens
A useful map answers the questions the agent would otherwise answer by exploring:
- What languages is this, and how much of each?
- Where does the code actually live?
- Which files change all the time, and which are fossils?
- How do I run the tests, the build and the linter?
- What would a newcomer get wrong?
The first four come out of commands. The fifth only you can write.
The 2,000-token ceiling is my choice, not a vendor limit. The vendor limits are much looser. Codex stops adding instruction files once their combined size reaches project_doc_max_bytes, which is 32 KiB by default. Anthropic suggests you target under 200 lines per CLAUDE.md, because "longer files consume more context and reduce adherence". A map loads in every session, so it should be the cheapest part of the file. A small number also forces you to choose, and choosing is the point.

The map doesn't need every room. It needs the five the agent will walk into first.
Step 1: languages and layout
scc counts lines per language fast. Install it with brew install scc or go install github.com/boyter/scc/v4@latest. tokei does the same job (brew install tokei or cargo install tokei). Use whichever you already have.
scc --no-complexity --exclude-dir vendor,node_modules \
--sort code
--no-complexity skips the complexity calculation, which the scc README says can reduce runtime. --exclude-dir adds to the defaults (.git, .hg, .svn) rather than replacing them. Excluding vendor matters on older PHP apps, where it is sometimes committed, and it would otherwise make your app look like it is mostly someone else's code. The tokei equivalent is tokei --exclude vendor --sort code.
For layout, count tracked files per folder, two levels deep. git ls-files only lists what git tracks, so ignored build output stays out:
git ls-files \
| awk -F/ 'NF>2 {print $1"/"$2"/"; next}
NF==2 {print $1"/"}' \
| sort | uniq -c | sort -rn | head -n 20
On an older Laravel app, those twenty lines might show that app/Http/ and app/Services/ hold the code and that a legacy/ folder holds hundreds of files. Whether legacy/ is alive is the next question.
Step 2: churn hotspots from git log
Files that changed often in the last year are where the work happens, and where your agent's task will most likely land.
git log --since="12 months ago" --no-merges \
--format= --name-only \
| sed '/^$/d' \
| sort | uniq -c | sort -rn | head -n 15
--name-only shows "only the name of each changed file", --no-merges skips merge commits, and the empty --format= removes the commit headers. sed deletes the blank separator lines. What's left is a ranked list of the files people actually touch.
Read it next to the layout. A folder with hundreds of files and no entries in the churn list is either stable or dead. You know which. The agent doesn't, so say so in step 4.
Step 3: the real entry points
An agent that guesses phpunit when the team runs composer test with a custom config wastes a turn and maybe a failing run. The scripts are already in the repo, and jq reads them out of the JSON files:
jq -r '.scripts // {} | to_entries[]
| "- composer \(.key): \(.value)"' composer.json
jq -r '.scripts // {} | to_entries[]
| "- npm run \(.key): \(.value)"' package.json
sed -nE 's/^([A-Za-z0-9_-]+):([^=]|$).*/- make \1/p' \
Makefile | sort -u
The sed line picks Makefile targets and skips NAME:=value assignments and dot-targets like .PHONY. Run only the lines for files you have. Then delete the scripts nobody uses. A legacy package.json often still lists a Gulp task from 2017, and the agent will believe it.
Step 4: assemble, write the part only you know, and trim
Put it in one script so you can rerun it when the repo moves:
#!/usr/bin/env bash
# build-map.sh: writes PROJECT_MAP.md for this repo
# no pipefail: `head` closing the pipe early is fine here
set -eu
out=PROJECT_MAP.md
{
echo "# Project map (generated $(date +%F))"
echo
echo "## Languages"
echo '```
text'
scc --no-complexity --exclude-dir vendor,node_modules \
--sort code | head -n 12
echo '
```'
echo
echo "## Folders (tracked files)"
git ls-files \
| awk -F/ 'NF>2 {print $1"/"$2"/"; next}
NF==2 {print $1"/"}' \
| sort | uniq -c | sort -rn | head -n 15
echo
echo "## Hot files (last 12 months)"
git log --since="12 months ago" --no-merges \
--format= --name-only \
| sed '/^$/d' | sort | uniq -c | sort -rn | head -n 10
echo
echo "## Commands"
if [ -f composer.json ]; then
jq -r '.scripts // {} | to_entries[]
| "- composer \(.key): \(.value)"' composer.json
fi
if [ -f package.json ]; then
jq -r '.scripts // {} | to_entries[]
| "- npm run \(.key): \(.value)"' package.json
fi
if [ -f Makefile ]; then
sed -nE 's/^([A-Za-z0-9_-]+):([^=]|$).*/- make \1/p' \
Makefile | sort -u
fi
echo
if [ -f MAP_NOTES.md ]; then cat MAP_NOTES.md; fi
} > "$out"
MAP_NOTES.md is the hand-written part, and it is worth more than everything above it. Keep it to a few lines of plain facts:
## Notes from the team
- `legacy/` is dead since the 2022 rewrite. Don't edit it.
- Run one test: `composer test -- --filter RefundTest`
- Money is integer cents everywhere. Never floats.
- Controllers stay thin. Logic goes in `app/Services/`.
Those four lines are examples for an imaginary app. Write your own. No command can tell the agent that legacy/ is dead or that money is stored in cents.
Now check the budget. A word count is a rough guide. For the real number, Anthropic's token counting endpoint is free to use (it has its own rate limits) and returns {"input_tokens": N}:
jq -Rs '{model: "claude-opus-5-5",
messages: [{role: "user", content: .}]}' PROJECT_MAP.md \
| curl -s https://api.anthropic.com/v1/messages/count_tokens \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d @-
jq -Rs reads the whole file as one raw string and wraps it in a request body. Count against the model you'll use: the same docs note that Claude 4.7 and later use a newer tokenizer that produces "approximately 30 percent more tokens" for the same text than earlier models.
Over budget? Trim in this order: fewer folder lines, fewer hot files, drop languages under a few hundred lines, drop scripts nobody runs. Never trim the notes.
Step 5: hand it to Claude Code or Codex
For Claude Code, import the map from your CLAUDE.md. Claude Code expands @path imports and loads them into context with the file that imports them:
# CLAUDE.md
@PROJECT_MAP.md
The same page notes that imports organize a file but "don't reduce its context cost", which is one more reason to keep the map small. It also says a CLAUDE.md that only tells Claude in words to read another file works only if Claude decides to open it. Use the import, not a sentence.
For Codex, the AGENTS.md docs describe discovery, overrides and the size cap, but no import syntax. Put the map's contents in a section of AGENTS.md itself. Codex builds its instruction chain once per run, so restart it after you regenerate. Claude Code can read AGENTS.md directly from v2.1.277, so one file can serve both agents.
Then start the session and watch what the first turn does. If the agent still opens composer.json to look for the test command, your Commands section is missing something. In Claude Code, /context shows what is using space, so you can see what the map costs next to everything else that loads.

A Claude Code session after pressing the Context chip: Claude Code's /context breakdown (44.4k of 1M tokens, 4%), the per-session context gauges in the sidebar, and the status strip with the token total and estimated cost.
The same map, built in
I build Hermes IDE, so weigh this section with that in mind. Hermes has a built-in version of most of this: the languages and architecture, the budget and the hand-off. Its README lists automatic scanning, which detects each project's languages, frameworks, architecture and conventions, and context injection: your agent is pointed at a context file with what the scan found, kept within a token budget you set per project. It also has memory and context pins: facts and pinned files saved for a project reach every later session's agent, also after a restart.
Side by side:
| Job | By hand | Hermes |
|---|---|---|
| Languages, frameworks |
scc / tokei
|
Automatic scan |
| Architecture, conventions |
git ls-files, your reading |
Automatic scan |
| Token budget |
count_tokens, trim by hand |
Per-project budget |
| Team-only facts | MAP_NOTES.md |
Memory and context pins |
| Hand-off |
@PROJECT_MAP.md, AGENTS.md
|
Context file, injected |
The scan does not read your mind any more than scc does. "legacy/ is dead" is still yours to write, as a pin. The script still earns its place. It is plain files in git, and it runs in CI and on a teammate's laptop without any IDE. Its churn list comes from your git history, which isn't on the README's list of what the scan detects, and churn is a good hint about which files the next task will touch. If you use Hermes, compare its context file with your map once and pin the facts it missed. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux: download it here.
If this was useful
The notes section is where a legacy app's real architecture shows up: which folder is dead, where the logic is supposed to live, which rule the framework never enforced. If writing "controllers stay thin, logic goes in services" felt like wishful thinking about your own codebase, Decoupled PHP is the book I wrote about getting there: clean and hexagonal architecture for PHP apps, with the framework kept at the edge so the next migration, by you or by an agent, has clear seams to work along.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (2)
A project map earns its token budget when it contains the change boundaries, not just a directory summary: entry points, invariants, tests, deployment path, and known dangerous areas. I’d version the map with the repository and show its evidence links, so the agent can say which part is stale instead of treating a compact summary as timeless truth.
The git churn filter is solid for tracking where active work lives. The spot where this bites us on old codebases is the opposite edge. You usually have two or three load-bearing singletons that nobody has committed to in three years specifically because everyone knows touching them breaks checkout. An agent looks at a file with three lifetime commits, assumes it is an abandoned leaf node, and rewrites the interface. We started pairing the churn rank with a raw import count so high-fan-in files with zero commits get flagged as untouchable before the prompt runs.