DEV Community

Cover image for Before Your Agent's First Prompt on a Legacy Repo, Build a 2,000-Token Project Map
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Before Your Agent's First Prompt on a Legacy Repo, Build a 2,000-Token Project Map


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:

  1. What languages is this, and how much of each?
  2. Where does the code actually live?
  3. Which files change all the time, and which are fossils?
  4. How do I run the tests, the build and the linter?
  5. 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.

A cartographer's table with a folded paper map of a sprawling old building, a magnifier and a burnt-orange pin marking one room
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
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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/`.
Enter fullscreen mode Exit fullscreen mode

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 @-
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 in Hermes IDE showing the /context breakdown, per-session context gauges in the sidebar and a status strip with token total and estimated cost
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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (2)

Collapse
 
alexshev profile image
Alex Shev •

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.

Collapse
 
reidmarlow profile image
Reid Marlow •

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.