welldigit / etruscan
Etruscan - Laravel package to project PHP-attribute-annotated classes into a vault of linked markdown notes: a graph-ready map of your codebase.
Package info
pkg:composer/welldigit/etruscan
Requires
- php: ^8.4
- illuminate/console: ^13.0
- illuminate/support: ^13.0
- laravel/mcp: ^0.9
- nikic/php-parser: ^5.7
- spatie/laravel-package-tools: ^1.16
- symfony/finder: ^7.2|^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/boost: ^2.5
- laravel/pint: ^1.20
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.7
- rector/rector: ^2.5
README
Mark the classes that matter with PHP attributes. Etruscan projects them into a knowledge graph of plain markdown, committed to the repo: structure derived from real code by static analysis, intent written by humans, both readable by any coding agent in a few hundred tokens instead of a few thousand lines of source.
Made by Well Digit — etruscan.dev
Two kinds of knowledge
A codebase carries two kinds of knowledge, and an agent working in it needs both.
Structure — what exists, what references what — lives in the source. Any tool can re-derive it, and agents do, on every task: grep, open, read, repeat. Most of a context window goes to rebuilding a picture the previous task already built and threw away.
Intent — which classes matter, what each one is for, why the design took this shape — is not in the source at all. No amount of crawling recovers it. It lives in the heads of the people who wrote the code.
Etruscan puts both into one artifact. Structure is derived by static analysis, so it cannot lie and cannot drift. Intent is declared by humans: an attribute marks the class as worth knowing, and one sentence — written straight into the note, by you or an agent writing as you — says what it is for. The artifact is plain markdown in your repo — diffed in PRs, reviewed like code, regenerated on demand.
The vault is the meeting point of human context and machine context. A person spends one sentence saying what a class is for; every agent that ever works in the repo reads that sentence for a handful of tokens instead of reconstructing an approximation from source. The map makes agents cheaper and sharper at once, and the human words on it survive every regeneration.
What a node looks like
Annotate a class:
use WellDigit\Etruscan\Attributes\EtruscanNode; use WellDigit\Etruscan\Attributes\Vocabulary\EtruscanContext; use WellDigit\Etruscan\Attributes\Vocabulary\EtruscanLayer; #[EtruscanNode('monitor-create')] #[EtruscanLayer('action')] #[EtruscanContext('monitor')] final readonly class MonitorCreate { /* … */ }
Run the projector:
php artisan etruscan:generate
Each node becomes one note, with an empty ## Description already waiting. That slot is the generator's only contribution to the section: it writes the heading, never the words. Fill it with the sentence that matters — it is the manual, human-written part of the map, and regeneration will never touch it.
--- alias: monitor-create class: MonitorCreate fqcn: Core\Domain\Monitor\Actions\MonitorCreate source: src/Core/Domain/Monitor/Actions/MonitorCreate.php layer: action context: monitor generated_by: etruscan --- ## Description Creates a monitor for the account after guarding the plan cap. ## References - [[monitor]] - [[monitor-create-data]] ## Referenced by - [[monitor-store-controller]]
## References lists the nodes this class points at, extracted from real code. ## Referenced by is the inverse — who points at it — so a dependency can be traced in either direction from the note itself.
Declared, not inferred
Most attempts to give agents a map infer it. Embedding search retrieves whatever looks similar to the question. Auto-derived dependency graphs include every class and therefore rank none. Both answer with guesses.
An Etruscan graph is declared. A person decided this class is a node, gave it a stable name, and said what it is for. That judgment is the signal: information absent from the source, which no tool can re-derive at any price.
Declaration buys determinism.
- "Who references
monitor-create?" has an answer.## Referenced byis an inverse index — the exact query grep handles worst, and the first question that matters before changing anything. - Retrieval is file reads. No vector store, no chunking, no similarity thresholds, no reranking, no infrastructure. The graph is markdown in a folder.
- Traversal is bounded. A note is ~25 lines: identity, one line of intent, outbound edges, inbound edges. Following five hops costs a few hundred tokens. The crawl it replaces costs a dozen tool calls and thousands of lines of source — per task, every task.
- Scope is queryable. Axes slice the graph: hand an agent the
billingcontext or theactionlayer instead of the whole repo.
The contract
Regeneration follows fixed ownership rules. They are the human–agent contract, enforced mechanically:
| Section | Owner | On regeneration |
|---|---|---|
| Frontmatter (identity, axes) | derived from code | always rewritten |
## References, ## Referenced by |
derived from code | always rewritten |
## Description |
human — or an agent writing as one | heading seeded empty; the text never touched, carried by alias |
| Everything else in the note | human — or an agent writing as one | never touched |
Generation seeds the empty ## Description heading in every note — the slot is the standing invitation — but the words under it are only ever yours, keyed to the alias, the one permanent name. (Plain prose is tidy-wrapped to a readable width — the words never change; a description carrying its own formatting — lists, code fences, headings — passes through byte-for-byte.) Rewrite the class, swap the taxonomy, regroup the folders — the derived sections reshape around your sentence, and your sentence stays. Docblocks are code documentation and never flow into the vault: what the map says about a class is exactly what a human chose to say about it.
A note whose class lost its #[EtruscanNode] is deleted only when it contains nothing of yours; otherwise it is kept and reported (--purge overrides). Hand-written notes without the generation marker are never touched. Manual text travels with its note when the folder layout changes.
The scanner parses source with nikic/php-parser. Nothing is autoloaded or executed, and one unparseable file never breaks a run.
If you are an agent
This repository ships its own vault: .etruscan/ is Etruscan's map of Etruscan. In any project that uses the package:
- Read the map before crawling source. Resolve the alias, read the note, follow the edges. Open the source file only when you need implementation detail —
source:in the frontmatter is the exact path. - Use
## Referenced byfor impact analysis. Before changing a class, the inbound edges tell you what breaks. - Treat human text as protected. The
## Descriptionand everything below the derived sections was written by a person and is the one part of the map you cannot reconstruct. Deliberate improvement is welcome — correcting a description your own change has just invalidated counts, and deserves a mention to the human. Mechanical rewriting and "syncing with code" are not. - Never edit derived sections by hand. Change the code or the attributes, then run
php artisan etruscan:generate. - Keep the map complete. A class without a node is invisible to retrieval. When you add a class that matters, annotate it, regenerate, and fill the empty
## Descriptionthe generator seeds in the new note — as a human would.
With Laravel Boost, the etruscan-navigate skill encodes 1–4, etruscan-annotate encodes 5, and the core guideline keeps regeneration part of your normal working loop. When the etruscan MCP server is connected, prefer its tools over raw file reads — map-overview, search-map, lookup-node, trace-node — one call each, measured so the team can improve the map (see below).
Identity and axes
#[EtruscanNode('alias')] marks a class as a node — that part is fixed. The alias is the permanent name of a node: it keys the file, the wikilinks, and the human notes. Every other dimension is an axis, a lens you can reshape at any time. Swap the whole taxonomy and regenerate: the graph reorganizes around the same stable nodes without losing a note, a link, or a human's words.
Four axes ship as a default taxonomy:
| Attribute | Frontmatter key | Question it answers | Example values |
|---|---|---|---|
EtruscanLayer |
layer |
What kind of class is this, architecturally? | action, model, query, data, observer, policy |
EtruscanDomain |
domain |
Which business area owns it? | booking, invoice, monitor |
EtruscanContext |
context |
Which bounded context does it operate in? Repeatable when a class serves several. | monitor, team, billing |
EtruscanSlice |
slice |
Which vertical feature does it help deliver, across layers and domains? | SystemSetup, Checkout, Onboarding |
They are defaults, not a closed set. Any subclass of EtruscanAxis is an axis — no registration needed:
use Attribute; use WellDigit\Etruscan\Attributes\EtruscanAxis; #[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)] final readonly class EtruscanCriticality extends EtruscanAxis {}
#[EtruscanCriticality('high')] now renders as criticality: high — the key is the class short name, lowercased, minus the leading Etruscan. Custom keys must not shadow the identity keys (alias, class, fqcn, extends, source); Etruscan fails loud if they do.
Installation
composer require welldigit/etruscan php artisan vendor:publish --tag=etruscan-config
Install the agent skills into Laravel Boost
Etruscan's two skills (etruscan-annotate, etruscan-navigate) and its guideline live in the package and are discovered by Laravel Boost automatically — pull them into your agent config with one command:
php artisan boost:install # first-time Boost setup — sets up Boost and picks up Etruscan php artisan boost:update # already using Boost? just refresh to pick up Etruscan
Confirm they landed with php artisan boost:list-skills — you should see etruscan-annotate and etruscan-navigate sourced from welldigit/etruscan.
No Boost, or want the skills without the Composer package at all? Pull them straight from GitHub:
php artisan boost:add-skill welldigit/etruscan --all
Getting started
Three steps take you from an un-annotated app to a live map.
1. Align scanned_folders with your codebase — the one setting you must get right. The scanner only ever looks inside the folders listed in config/etruscan.php (default ['app', 'src']). If your meaningful classes live elsewhere — src/Core/…, app/Domain, packages/*/src — set it there, or the scan finds nothing and the vault comes out empty.
// config/etruscan.php 'scanned_folders' => ['app', 'src'], // ← edit to match YOUR layout 'vault_path' => '.etruscan', // where the notes are written (hidden by default)
Both are env-driven, so you can set them in .env without publishing the config — ETRUSCAN_SCANNED_FOLDERS (comma-separated) overrides the scanned folders, and ETRUSCAN_VAULT overrides the vault path:
ETRUSCAN_SCANNED_FOLDERS=app,src,packages/acme/src ETRUSCAN_VAULT=.etruscan
Relative paths resolve against the application root; absolute paths are used as-is.
2. Let an agent annotate the codebase for you. Adding #[EtruscanNode] to hundreds of classes by hand is the tedious part — so hand it to a Boost-aware agent, which ships with the etruscan-annotate skill for exactly this. Paste a prompt like:
Use the etruscan-annotate skill to bootstrap an Etruscan map for this project. Read the codebase, then propose the axis taxonomy (layer / domain / context / …) and which classes deserve to be nodes — show me the plan as a table before touching any files. Once I approve, apply the
#[EtruscanNode]and axis attributes, generate the vault, then fill each note's empty## Descriptionwith a first pass where the intent is clear from the code, and flag the classes whose why only I can explain.
The agent proposes the schema, you review and tune it, then it writes the attributes across the codebase and seeds the notes. Note the last clause of the prompt: the descriptions are yours — the agent only ever writes them the way you would, into the notes, where regeneration never touches them. It seeds what the code reveals and flags what it cannot know; the classes it flags are where your one sentence of intent is worth the most. (Already annotated by hand? Skip straight to step 3.)
3. Generate the vault.
php artisan etruscan:generate # add --dry-run to preview first
Browse .etruscan/ in any editor, or render the graph with php artisan etruscan:graph. From here, the etruscan-navigate skill lets an agent read the map instead of crawling files.
4. Connect the map to your agent (MCP).
Using Laravel Boost? Nothing to do — Etruscan exposes its four map tools inside the laravel-boost MCP server your agent already connects to; verify with php artisan boost:mcp being present in .mcp.json. Not using Boost? Add Etruscan's own server to your agent's MCP config:
{ "mcpServers": { "etruscan": { "command": "php", "args": ["artisan", "etruscan:mcp"] } } }
Without either, agents still work — they read the vault files directly — but consultations are not measured.
Command
php artisan etruscan:generate # project using config php artisan etruscan:generate --dry-run # report without writing php artisan etruscan:generate --group-by=domain # per-run layout override php artisan etruscan:generate --group-by=layer,domain # nested folders, one level per axis php artisan etruscan:generate --group-by=none # force flat layout php artisan etruscan:generate --purge # also delete orphans holding human words
Configuration
| Key | Env | Default | Meaning |
|---|---|---|---|
scanned_folders |
ETRUSCAN_SCANNED_FOLDERS (comma-separated) |
['app', 'src'] |
Folders scanned, resolved against the app root unless already absolute; missing ones are skipped harmlessly |
vault_path |
ETRUSCAN_VAULT |
'.etruscan' |
Output vault directory (hidden by default), resolved against the app root unless already absolute |
group_by |
ETRUSCAN_GROUP_BY |
null (flat) |
Axis key(s) for subfolder grouping; comma-separated list or array nests folders in order |
generated_marker |
— | generated_by |
Frontmatter key marking generated notes |
generated_value |
— | etruscan |
Value stamped under the marker key |
vocabulary |
— | [] |
Optional allowed values per axis; etruscan:check flags off-vocabulary values (see below) |
usage_tracking |
ETRUSCAN_USAGE_TRACKING |
true |
Record MCP map consultations to {vault}/.reports/usage.jsonl for etruscan:usage; .reports/ is git-ignored by default, so the log stays on this machine unless you deliberately commit it |
Folder layout is a projection, not structure: group_by writes notes into {vault}/{axisValue}/{alias}.md, several keys nest folders in order, and a note missing an axis simply skips that level. Wikilinks are path-independent, so links keep working in any layout.
Graph view
Render the whole node graph as a single self-contained HTML page — force-directed layout, node colors by any axis, search, and a per-node panel with description, metadata, and inbound/outbound references:
php artisan etruscan:graph # {vault}/.reports/graph.html php artisan etruscan:graph --output=public/codebase.html # custom location
The page embeds the graph data and has no external dependencies. Open it in any browser, online or offline, and share it with anyone — reading the map requires no repo access and no tooling. Node descriptions on the page are read from the vault notes — their only home — so generate the vault before rendering the graph.
Checking the map
Aliases are hand-derived and must be globally unique; axis values are free-form strings. Both invite mistakes — two classes colliding on one alias, or a serivce typo quietly fragmenting your taxonomy. etruscan:check audits for exactly these:
php artisan etruscan:check # report problems, exit non-zero on errors php artisan etruscan:check --strict # treat warnings as failures too (for CI)
It reports four things:
- Duplicate aliases — two classes claiming one alias (an error; generation would also fail loud on it).
- Unknown vocabulary — for any axis listed in the
vocabularyconfig, values outside the allowed set (an error, with the closest match suggested). Axes left free-form instead get a fuzzy warning when two values look like a typo of each other (servicevsserivce). - Orphan nodes — annotated classes with no inbound or outbound links, which often means a missed annotation or a mis-scoped node (a warning).
- Broken links —
[[wikilinks]]in your manual notes pointing at aliases that no longer exist (an error).
Lock an axis down in config once its taxonomy stabilises:
// config/etruscan.php 'vocabulary' => [ 'layer' => ['action', 'model', 'service', 'query', 'data', 'observer', 'policy'], 'domain' => ['booking', 'invoice', 'monitor'], ],
Errors fail the command (exit 1) so CI can gate on it; warnings only fail under --strict.
MCP server
Etruscan ships its own MCP server, so agents query the map through structured tools instead of globbing markdown — one call replaces a search-plus-read round trip:
| Tool | What it answers |
|---|---|
map-overview |
"What is this codebase made of?" — every node grouped by a taxonomy axis |
search-map |
"Where is X handled?" — ranked matches over aliases, classes, axes and descriptions |
lookup-node |
"Tell me about this class" — the full note, ending with the source path to open |
trace-node |
"What does it use / who uses it?" — one dependency hop with descriptions |
With Laravel Boost, connection is automatic: Etruscan registers its tools in Boost's boost.mcp.tools.include, so they are served by the laravel-boost MCP server your agent already connects to — no .mcp.json change. Without Boost, add Etruscan's own server to your agent's MCP config (for Claude Code, .mcp.json in the project root):
{
"mcpServers": {
"etruscan": { "command": "php", "args": ["artisan", "etruscan:mcp"] }
}
}
or claude mcp add etruscan -- php artisan etruscan:mcp. Either way the tools read the vault on disk, so regenerate after changing annotated classes and they answer from the fresh map.
Security posture: the server speaks stdio only — it is not an HTTP route, opens no port, and runs solely when someone with shell access starts it. All four tools are read-only over the vault's markdown (aliases are looked up, never used as file paths), the only write is the local usage log, and the server is not registered at all when APP_ENV=production.
Measuring usefulness
Because agents reach the map through the MCP tools, usage is measured server-side, as ground truth — no honor system, no agent cooperation required. Every consultation is appended to {vault}/.reports/usage.jsonl — and .reports/ ships its own .gitignore, so the log never leaves your machine unless you deliberately commit it. Then:
php artisan etruscan:usage # the report php artisan etruscan:usage --days=7 # recent window php artisan etruscan:usage --json # machine-readable, for CI or dashboards php artisan etruscan:usage --html # self-contained dashboard → {vault}/.reports/usage.html (--output overrides)
The --html dashboard is a single offline page like the graph view: stat cards, consultations per day with misses in red (watch them fall as you annotate), most-consulted nodes, and the annotation-candidate list — shareable with anyone, no tooling required.
The report shows what the log proves: consultations per tool, the most-consulted nodes (the ones whose descriptions earn the most polish), and — the most valuable signal — misses: the exact aliases and queries agents asked for that the map could not answer. Misses are pre-validated annotation candidates; feed them to the etruscan-annotate skill and watch them disappear from the next report. That trend — miss velocity falling toward zero — is the honest benchmark of the map paying off.
The report also totals the context the map actually served: characters per consultation, summed and shown as an estimated token count (~4 chars/token — an estimate, since every agent tokenizes differently). It measures what the map delivered, not the agent's total context, but it makes the trade concrete: a few hundred tokens of curated map instead of the file-globbing round-trips it replaced.
What this measures: every consultation through the MCP tools. What it cannot see: agents reading vault files directly (the skills steer them to the tools for exactly this reason). Disable recording with ETRUSCAN_USAGE_TRACKING=false; the tools keep answering either way. For a shared team log, edit .etruscan/.reports/.gitignore to un-ignore usage.jsonl (edit it — a deleted file is reseeded) and add .etruscan/.reports/usage.jsonl merge=union to .gitattributes so parallel branches merge cleanly.
Development
composer install composer test # Pest (via Orchestra Testbench) composer phpstan # PHPStan level 7 + Larastan composer rector:dry # automated refactoring check composer pint:dry # code style check composer check # all of the above
Etruscan consumes its own cooking: every class in src/ is annotated, and .etruscan/ is the package's own vault — regenerate it with composer vault, and its graph page with composer vault:graph. Those scripts pass absolute paths because they run through Testbench, where base_path() points at its own skeleton app, not this repo.
License
MIT. See LICENSE.md. © Petro Lashyn / Well Digit.