jul6art / devtools
Workflow cartography and feedback loop for projects built with Claude Code
Requires
- php: ^8.5
- ext-dom: *
- ext-libxml: *
- nikic/php-parser: ^5.4
- symfony/console: ^7.4 || ^8.0
- symfony/filesystem: ^7.4 || ^8.0
- symfony/process: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95.27
- infection/infection: ^0.35.8
- mcp/sdk: ^0.9
- php-webdriver/webdriver: ^1.16
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.3.4
- phpstan/phpstan-phpunit: ^2.1.1
- phpstan/phpstan-symfony: ^2.1.1
- phpunit/phpunit: ^13.4.5
- rector/rector: ^2.8.3
- symfony/browser-kit: ^7.4 || ^8.0
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/dom-crawler: ^7.4 || ^8.0
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/panther: ^2.4
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/var-dumper: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Suggests
- ext-gd: To compare the screenshots of the scenarios with their baselines pixel by pixel
- mcp/sdk: ^0.9 — to serve the documentation to Claude Code over MCP: devtools mcp:serve
- symfony/framework-bundle: To register the commands in bin/console through Jul6Art\DevTools\Bridge\Symfony\DevToolsBundle
- symfony/panther: ^2.4 — to replay the scenarios in Chrome: devtools scenarios:run, and ScenarioTestCase
Provides
None
Conflicts
None
Replaces
None
README
Workflow cartography and feedback loop for projects built with Claude Code
DevTools gives a project a .devtools/ folder — versioned next to .git — in which Claude Code
keeps an exact, cheap-to-maintain map of what the project does, and later the memory of what humans
had to correct.
Code generated by Claude is good, and the same review comments still come back: the filter does not work, this needs a select2. They come back because they are stored as prose, re-read and applied from memory in an already loaded context. And before any feedback can be acted on, something more basic is missing: a structured answer to what does this change touch?
DevTools is built in that order:
- Workflows — inspect a codebase, whatever its language, and maintain one normalised Markdown page per workflow (route, command, async handler, listener, screen…) with Mermaid diagrams, plus a tracking XML per page so a re-scan rewrites only the workflows whose files actually changed;
- Impact, MCP and gate — which workflows a file or a commit touches, served to Claude Code as targeted context, and a gate that keeps Claude from declaring a task finished while it fails;
- Feedback and rules — typed feedback, promoted to a rule on its second occurrence, then to a mechanical assertion.
Where it stands. Step 1 is delivered and runs on real projects: three of them are documented this way, the largest holding 259 workflows over 310 pages. Step 2 is built — the impact of a change, the Claude Code hooks, the MCP server, scenarios replayed in Chrome against their baselines, the verifier, and the gate the
Stophook runs — and waits to be watched in real Claude Code sessions. Step 3 is built too: typed feedback and its review page, rules given where they apply, assertions in the gate, and the retrospective that says whether feedback goes down. The design lives indocs/specs.mdand the decisions indocs/adr/(French).
Requirements
- PHP ^8.5
- Symfony ^7.4 || ^8.0 — the Console component in standalone mode, the framework only for the bridge
mcp/sdk^0.9, optional — only for the MCP server,mcp:servesymfony/panther^2.4 and Chrome, optional — only to replay the scenarios,scenarios:run;ext-gdto compare their screenshots with the baselines- Node, and TypeScript in the analysed project, optional — only to read Angular and Express natively
Installation
DevTools runs in two modes, and the logic is the same in both: everything lives in the standalone core, the Symfony bundle is a thin wrapper.
Standalone — any project, any language
composer global require jul6art/devtools devtools list
Pass the project to analyse as an argument, or run it from the project root. The analysed project does not need PHP: a Node, Angular or Go codebase is inspected the same way.
Without Composer — the phar
Every release carries devtools.phar, its SHA-256 and a provenance attestation of the GitHub workflow that
built it:
curl -LO https://github.com/jul6art/devtools/releases/latest/download/devtools.phar gh attestation verify devtools.phar --repo jul6art/devtools # built by this repository's CI chmod +x devtools.phar && mv devtools.phar /usr/local/bin/devtools devtools self-update # later: the latest release, verified
self-update replaces the phar only once the download matches its published SHA-256 and the GitHub
CLI verified its attestation — without gh, it refuses and says how to update by hand. It is the only
command of DevTools, with ai:run (Writing in CI), that opens a connection. The commands that need a
package DevTools only suggests — mcp:serve, scenarios:run — are absent from the phar and say which
Composer installation brings them.
Without PHP — the Docker image
PHP, git and Node, the project mounted:
docker run --rm -v "$PWD:/project" ghcr.io/jul6art/devtools:3 workflows:inspect --no-ai
The image runs the release's phar; Node reads an Angular or Express project with the TypeScript of its own
node_modules.
Inside a Symfony project
composer require --dev jul6art/devtools
Then register the bridge in config/bundles.php for the environments that have it (Flex does this for
you, with dev and test):
Jul6Art\DevTools\Bridge\Symfony\DevToolsBundle::class => ['dev' => true, 'test' => true],
⚠️ Never all. The package is a development dependency: a production composer install --no-dev
does not have the class, and a kernel that registers it everywhere fails to boot on the server.
The commands are then also available as bin/console devtools:*, with the richer introspection only a
compiled kernel allows.
Configuration
# config/packages/dev/devtools.yaml devtools: # Leaves the bundle installed and inert when false. enabled: true # The language the workflow pages are written in, as two lowercase letters. Default: en. language: fr # Where the pages and their menu are written, relative to the project. Default: docs/workflows. docs_dir: docs/workflows
devtools.enabled, devtools.language and devtools.docs_dir are also exposed as container parameters.
The language of the pages is decided in this order: the --locale option of workflows:inspect, then
<language pages="…"/> of .devtools/config.xml, then this key, then English. The option is what a one-off
run uses; the file is what the team commits.
⚠️ It decides the PROSE, not the template. Section titles, table headers and the menu are English
whatever the language: they are read back — by the draft validator, and by the inspection that keeps what
Claude wrote — so they are part of the format, like the name of an XML element. A French project gets
French sentences under English headings, which is what it already got from Trigger or Routes.
These two keys are the bridge's only configuration, on purpose. What DevTools inspects, excludes and
generates is configured in .devtools/config.xml, which both modes read — a setting that existed only
in config/packages/ would silently not apply to a standalone run.
Usage
Which command, and when
| Moment | Command | What it does | Writes |
|---|---|---|---|
| Setting a project up (once) | devtools init |
creates .devtools/ and the config.xml you then edit |
.devtools/ |
devtools stack:detect |
writes what the project is built with; init and inspect do it too |
stack.xml |
|
devtools claude:install |
installs the Claude Code skill that writes the pages | .claude/skills/ |
|
devtools claude:install --hooks |
Claude Code is told what a file reaches before it edits it, and cannot end a task while the gate fails | .claude/settings.json |
|
devtools claude:install --agents |
the verifier: a second agent that judges a change to the interface on evidence | .claude/agents/ |
|
devtools claude:install --mcp |
Claude Code reads one page, the impact or the knowledge when it needs them | .mcp.json |
|
devtools git:install-hooks |
the check on pre-commit, the facts refreshed on post-merge |
.git/hooks/ |
|
| Documenting it (once, then on demand) | devtools workflows:inspect --locale=fr |
documents every workflow and asks Claude for the pages it cannot write | pages, tracking, briefs |
/devtools-inspect (in Claude Code) |
the skill: Claude writes one draft per brief in .devtools/pending/, applies them, and loops |
drafts | |
devtools workflows:apply |
checks each draft, rebuilds its page, and takes the workflow off the menu's « To check » list | pages, tracking, workflows.md |
|
| Before changing a file | devtools workflows:impact <file> |
the workflows it reaches, and their pages — with --linked, the screens of the front calling them |
nothing |
| A question about behaviour | devtools ask --pages "<question>" |
the pages that answer it, selected without AI; /devtools-ask in Claude Code answers from them |
nothing |
| A front and its API | devtools workflows:inspect ../api ../mobile --link |
both inspected, then each front call matched against an API route | graph/links.xml, pages, menus |
| Writing scenarios (on demand) | devtools scenarios:generate |
a skeleton per GET route, a brief for Claude for the rest; workflows:apply applies its drafts |
.devtools/scenarios/ |
devtools scenarios:run --base-url=… |
replays them in Chrome against the running application | .devtools/runs/ |
|
devtools baselines:accept <run> |
after looking at them: the screenshots of a run become the baselines | .devtools/baselines/ |
|
| Before calling a task finished | devtools gate |
freshness, scenarios, verification and assertions on what the change reaches — the Stop hook runs it |
.devtools/gate/ |
devtools verify:prepare / verify:collect |
the folder a second agent judges a change to the interface on, then its verdict, checked | .devtools/pending/ |
|
| After a review | devtools feedback:add |
records a remark: its workflow, element or file, its category, one line — or counts it again | .devtools/feedback/ |
devtools review |
a page: the captures of a run, an element clicked, a feedback in one line | .devtools/feedback/, baselines/ |
|
devtools feedback:list / feedback:resolve <id> |
what is open, by category or workflow; closed as fixed or set aside | .devtools/feedback/ |
|
| Seen twice | devtools rules:promote <feedback> |
asks Claude for the rule the feedback should become; workflows:apply checks its draft |
a brief, then .devtools/rules/ |
devtools rules:activate <rule> / rules:list |
you agree with a proposed rule: Claude is given it before editing a file it applies to | .devtools/rules/ |
|
devtools assertions:propose <rule> |
asks Claude for the check that enforces the rule in the gate; workflows:apply runs it first |
a brief, then .devtools/assertions/ |
|
| Once a month | devtools retro |
whether feedback goes down: per task delivered, by category, by rule | .devtools/reports/ |
devtools rules:archive <rule> |
a rule the retrospective found idle: no longer given, no longer checked | .devtools/rules/ |
|
| Every day (in the gate, in CI) | devtools workflows:check |
fails on a changed fact, an undocumented workflow, an orphaned page | nothing |
phpstan analyse |
the same drift, at the line, in the editor | nothing | |
| When the gate is red | devtools workflows:diff --code |
which fact changed, and the code behind it | nothing |
devtools workflows:review |
fact by fact: accept, refuse, skip | pages of accepted facts | |
devtools workflows:accept <fact> |
the change was wanted: the pages follow | pages, tracking, briefs | |
devtools workflows:reject <fact> |
it was not: prints the git restore; runs it with --restore |
nothing, or the code | |
| Occasionally | devtools workflows:inspect --prune |
after deleting code: removes the pages of gone workflows | deletes pages |
devtools workflows:inspect --force |
rewrites every page's facts, whatever the freshness says | every page | |
devtools knowledge:list / knowledge:promote |
the stack knowledge shared between projects | the library | |
devtools knowledge:amend <key> |
asks Claude to complete a sheet the project showed to be incomplete | a brief | |
devtools workflows:inspect --switch-adapter |
a stack Claude documented moves to the native adapter that now reads it | stack.xml, pages |
|
devtools workflows:rename <old> <new> |
after renaming a route: its page, history and references follow | pages, tracking | |
| To read it elsewhere | devtools export:site |
the documentation as a static site that works offline | build/devtools-site/ |
| With the phar | devtools self-update |
the latest release, once its checksum and provenance are verified | the phar |
| In CI (off by default) | devtools ai:run |
the pending briefs written through the Claude API, then applied — it spends tokens | drafts, pages |
The order matters twice, and only twice: init before anything else, and a brief before the draft
that answers it. Everything else is idempotent — a command run twice on an unchanged project does
nothing the second time.
⚠️ --prune deletes, and workflows:reject --restore is the only thing here that touches a
project's own code. Everything else either writes inside .devtools/ and the documentation directory,
or writes nothing at all.
devtools init [path]
Creates the .devtools/ folder of a project — the current directory when no path is given:
vendor/bin/devtools init
docs/workflows/ the documentation — what a human opens
├── workflows.md the menu
├── <type>/<id>.md one page per workflow
└── routes/<controller>/ with <routes group="controller"/>: one directory per controller
├── README.md its routes, in a table — the page the menu links to
└── <route>.md one page per route, the same nine sections as any other
.devtools/ the machinery — what DevTools reads back
├── config.xml the project's options — yours, DevTools never rewrites it
├── stack.xml the stacks detected
├── index.xml every workflow, and where the pages were written
├── workflows/ one tracking file (.xml) per workflow
├── knowledge/ how the project's stack works
├── discovery/ what Claude found in a stack without a native adapter
├── graph/ the file → workflows index and the overview diagram
├── schemas/ a copy of every XSD, so .devtools/ can be validated without DevTools
├── pending/ work area for Claude — ignored by git
└── reports/ one report per run — ignored by git
The pages go to docs/workflows/ by default; --docs=documentation/workflows on workflows:inspect, or
devtools.docs_dir in the bundle, puts them anywhere else in the project. index.xml records the choice, so
workflows:apply and the next run find them without being told.
Commit both directories — docs/workflows/ and .devtools/ (all of it except pending/ and reports/) —
and .claude/skills/ once claude:install has run: the documentation is the team's, reviewed in pull
requests like code. init adds the two work areas to the project's
.gitignore (without duplicating a line that already covers them) and changes nothing when run again.
⚠️ Every XML file in .devtools/ is validated against its schema, on write and on read. A file
edited by hand into an invalid state stops the next run with the file and the line named, rather than
being silently rewritten. A file written by a newer DevTools asks you to update DevTools.
devtools stack:detect [path]
Reads the project's manifests — composer.json and composer.lock, package.json and its lock,
go.mod, pyproject.toml, Cargo.toml, pom.xml — without running anything, and writes
.devtools/stack.xml:
vendor/bin/devtools stack:detect
------ ------------ ------------- --------- ------------ -----------------------------
Root Language Framework Adapter Knowledge Sources
------ ------------ ------------- --------- ------------ -----------------------------
api php symfony 7.4 symfony symfony-7 config, src
front typescript angular 22.0 angular angular-22 src/app
------ ------------ ------------- --------- ------------ -----------------------------
A monorepo gets one stack per directory holding a framework — or a Node application without one: a
package.json with an entry point (bin, main or a start script) AND runtime dependencies is
reported as framework node, and the directories of its entry points join its sources. The
package.json of a build tool (Webpack Encore beside a Symfony application) has neither, and stays
tooling. A tsconfig.json with checkJs keeps the language javascript. A framework version comes
from the lock file when there is one, from the constraint otherwise. The adapter says who finds the workflows:
symfony, laravel, angular, express and generic-php are native; everything else goes to Claude.
⚠️ A wrong detection is corrected in stack.xml, not with an option. Mark the element
locked="true" and every later detection keeps it — the correction is committed and shared:
<adapter locked="true">claude</adapter> <sources locked="true"><dir>src</dir><dir>lib</dir></sources>
devtools workflows:inspect [path]
Documents every workflow of the project in .devtools/ — init and stack:detect included:
vendor/bin/devtools workflows:inspect # standalone, current directory bin/console devtools:workflows:inspect # through the bundle, the application's directory
DevTools — cereezer Symfony 7.4
✔ 84 workflows 12 created · 3 updated · 69 unchanged · 0 orphaned
✔ 1,247 files parsed · 412 hashed · 3 git processes · 4 console calls
✔ 15 briefs written for Claude · 152 KB (~38,000 tokens estimated)
⚠ 1 static fallback (symfony @ .)
Duration 12.4 s · memory 214 MB · report .devtools/reports/inspect-2026-09-16-150000.md
A progress bar shows each stage while it runs, and the summary is green, yellow or red — the colour of
the exit code. Nothing is drawn when the output is not a terminal (CI, redirection), and --quiet
writes nothing at all. The same measurements are repeated at the bottom of the report.
The token figure is an estimate of what the redaction will cost, four bytes per token: DevTools never calls Claude, so it has no token to measure.
It writes one page and one tracking file per workflow, index.xml, graph/, and workflows.md, the
page to open first. Commit all of it; the report stays out of git.
| Option | |
|---|---|
--dry-run |
show what would be rewritten and why; write nothing |
--force / --force=route.order.new |
rewrite every workflow / this one (repeatable) |
--since=<commit> |
compare with this commit, and hash every file (a commit, a tag or a branch — never an option) |
--prune |
delete the page and tracking file of orphaned workflows (of the --only type, when given) |
--only=routes |
write the pages of one type only; the menu and the index stay complete |
--no-ai |
write no brief for Claude: factual pages only |
--locale=fr |
the language Claude writes the pages in, as two lowercase letters |
--docs=docs/workflows |
the directory the pages and the menu are written in |
--switch-adapter |
move a stack documented through Claude to the native adapter this version has for it — see Switching to a native adapter |
Exit code 0 when everything was documented, 1 with warnings — a console that did not answer, a stack
without an adapter, a file that could not be parsed — and 2 when nothing could be (invalid
configuration, identifier collision, another inspection running on the same project).
A run only rewrites what changed. Git points at the files changed since the documentation was
written — at most four git processes, whatever the size of the project — and the SHA-256 recorded for
each file decides: a change undone in a later commit is not a change. A file removed from or added to a
workflow, or a major version of a package it uses, rewrites it too; each rewrite adds a line to the
page's history saying why (files changed: src/Service/OrderPricing.php). Without git — a copy, an
archive, a rewritten history — every file is hashed and the decisions are the same.
A second run on an unchanged project modifies no file: committing the documentation does not make it change again. On a 300-route project, that re-scan takes well under a second.
Memory. Every command that analyses the code — inspect, check, diff, review, accept,
reject — raises PHP's limit to 1 GB when it is lower, and inspect says so: a project of two thousand files
peaks around 400 MB, and through the bundle the booted kernel is already in memory. A limit that is higher, or
unlimited, is left alone. A hook therefore needs no php -d memory_limit in front of workflows:check.
--force naming a workflow that does not exist is a warning, not a silent no-op.
⚠️ An entry point that disappeared is marked orphaned and listed under To check in the menu; its page is
only deleted with --prune. A workflow whose tracking file says <status>manual</status> is never
rewritten: when its code changes, the run warns instead.
⚠️ Only the files and the tests of a workflow are watched. A test appearing or disappearing rewrites the
workflows that list it; a navigation link added to a template shows up at the next rewrite caused by a file
of the workflow — or with --force.
⚠️ Without AI, a stack left to Claude is not documented: --no-ai never asks for a discovery, so an
Express or Angular project gets its pages only once a discovery has been applied (see Projects without a
native adapter).
devtools workflows:diff [path]
What changed in the workflows since their pages were written — not that something changed, but which fact, what it said, and what it says now:
DevTools — cereezer workflow diff
⚠ 1 fact changed · 87 workflows
731 files parsed · 532 hashed
modified decision App\Entity\User::deactivatedAt src/Entity/User.php:366
- new \DateTimeImmutable()
+ new \DateTimeImmutable('now')
87 workflows · async.field-device-stale, command.app.dev.login-link, route.admin.user.index (+84)
Facts are grouped, never workflows. On a real project, 233 of 259 workflows traverse the same entity: one changed line in it is one thing to read, not 233. The widest fact comes first.
A fact is an entry point, an attribute of one (path, methods, security), a decided value, a
mechanism, a dependency, a test or a package. ⚠️ Facts read through the project's console — the
listeners and their priorities — are only as fresh as its cache: a stale var/cache hides a change
that the code already has. ⚠️ A file whose bytes changed without changing any of
these is not a change — the hash is what triggers the comparison, never what it reports, and a method
that moved down a file reports nothing at all.
| Option | |
|---|---|
--code |
the git diff of the file behind each fact, since the commit its page was written from |
--only=routes |
one type of workflow |
--format=md |
Markdown, for a pull request |
--exit-code |
exit with 1 when a fact changed — for a hook or a CI job |
The command writes nothing at all, reports included: it runs the inspection in read-only mode. Exit
code 0, 1 with --exit-code when a fact changed, 2 when the project could not be inspected.
--code costs one git process per distinct reference, not one per fact: the tracking files of a project
almost always share the same commit.
A workflow that did not exist is named new, one that no longer does gone — neither is unfolded into its facts.
devtools workflows:check [path] — the gate
Fails when the documentation no longer matches the code, and writes nothing:
DevTools — cereezer workflow check
✖ 2 causes
route.order.export not documented
modified attribute app_order_new#security ROLE_USER → ROLE_ADMIN 3 workflows
| Cause of failure | |
|---|---|
| an entry point with no page | not documented |
| a page whose workflow is gone | orphaned |
| a fact changed since the pages were written | the fact, grouped, with its reach |
--require-ai, a page never written by Claude |
never written |
⚠️ A file whose bytes changed without changing a fact is not a cause. That is the whole difference with the freshness: the gate does not stop anyone for a comment added to a traversed file.
It is still counted, on the last line, because the model holds assignments and nothing else: a condition rewritten inside a method, a constant renamed, a voter that now returns another permission, a default value flipped — none of these is a fact, and each can make a page's prose false.
✔ nothing to report · 731 files parsed
3 workflows saw their code change without a single fact moving: their prose may be stale (--strict to make it a cause)
--strict turns them into causes. Use it on a project where the pages are read as a contract; leave it
off where the gate has to stay quiet on a refactoring.
| Option | |
|---|---|
--require-ai |
fail as well on a page that only holds facts |
--strict |
fail as well on a workflow whose code changed without changing a fact |
--only=routes |
one type of workflow |
--format=github |
::error file=…,line=…:: annotations, so the pull request shows the line |
Exit code 0 when there is nothing to report, 1 for at least one cause, 2 when the project could
not be inspected.
# .github/workflows/documentation.yml - run: vendor/bin/devtools workflows:check --format=github
devtools git:install-hooks [path]
| Hook | What it does |
|---|---|
pre-commit |
workflows:check — warns without blocking, unless --strict |
post-merge, post-checkout |
workflows:inspect --no-ai: the facts follow the branch |
Hooks go to core.hooksPath when the project configured one, .git/hooks/ otherwise. ⚠️ An existing
hook is never overwritten: the block is appended between # >>> devtools >>> and # <<< devtools <<<,
and git:uninstall-hooks removes exactly that block — a hook that held nothing else goes away with it.
⚠️ They never block a commit for a technical reason. DevTools missing, .devtools/ missing: the hook
exits 0. A gate that stops the work because of itself is a gate that gets uninstalled.
Accepting a change, or refusing it
Once workflows:check has failed, two decisions — and only two:
vendor/bin/devtools workflows:review # fact by fact, in a terminal vendor/bin/devtools workflows:accept 'App\Entity\User::email' # the change was wanted vendor/bin/devtools workflows:reject 'App\Entity\User::email' # it was not
Accepting rewrites the pages of the workflows carrying that fact — and only those. Their history
names the fact (modified decision App\Entity\User::email) instead of the file it lives in, and the
brief Claude receives carries it too, so the prose is reread where it matters rather than everywhere.
--all takes every changed fact, --no-ai keeps the facts right and leaves the prose for later.
Refusing writes nothing — neither documentation nor code. It prints the command that brings the file back to the state the page was written from:
✗ modified decision App\Entity\User::email
Run this to bring the code back:
git restore --source=8f3c21a -- src/Entity/User.php
⚠️ --restore runs it. It is the only thing in DevTools that touches a project's own code, so it is
bounded: never without the option, and never on a file that carries another changed fact — undoing
that one silently is exactly the accident this guard exists for. The command is printed instead.
⚠️ It brings back the whole file, at the commit the page was written from: a fact accepted earlier but not yet committed goes back with it. Accepting writes the documentation, not the code.
workflows:review walks the facts one by one, widest first, and asks. Nothing is written before the
last answer: interrupting a review leaves the project exactly as it was. Without a terminal it refuses
to guess and exits 1 — in CI, workflows:check is the command.
PHPStan — the drift where the code is written
PHPStan already runs in most projects' composer qa, and in the editor. The extension reports a changed
fact as an error, on the line that carries it:
# phpstan.neon includes: - vendor/jul6art/devtools/resources/phpstan/extension.neon
⚠️ This one needs the package in the project's autoload — composer require --dev jul6art/devtools.
PHPStan builds the rule through its own container, before any bootstrap file is loaded, so a DevTools
that only exists as a standalone binary somewhere else on the machine cannot be reached: the analysis
stops on Class '…\WorkflowDriftRule' not found. The commands (workflows:check, workflows:diff)
have no such constraint — they are the ones to wire into a hook when the package is not a dependency.
------ ---------------------------------------------------------------------
Line src/Entity/User.php
------ ---------------------------------------------------------------------
250 modified decision App\Entity\User::email: null !== $email -> '' !== trim($email)
🪪 devtools.workflowDrift
💡 87 workflows documented this. Accept it with
devtools workflows:accept 'App\Entity\User::email', or refuse it.
------ ---------------------------------------------------------------------
⚠️ The rule analyses nothing itself. It calls the same read-only inspection as workflows:diff, once
per PHPStan run: a second extractor built on the syntax tree PHPStan already holds would be faster, and
would silently diverge from the first one. DevTools computes, PHPStan displays.
It hangs on the node PHPStan visits once at the end of an analysis, so the result cache cannot hide a
drift that appeared since the last run. A project without a .devtools/ directory is never told
anything, and parameters.devtools.path points at the project when it is not the current directory.
.devtools/config.xml
Every element is optional; the file init writes shows the defaults:
| Element | Default | What it does |
|---|---|---|
<paths><exclude>public/build</exclude></paths> |
— | directories never scanned, added to .devtools, .git, build, dist, node_modules, var, vendor |
<graph depth="3"/> |
3 |
how many hops of dependencies a workflow follows |
<identifiers route-prefix="app_"/> |
app_ |
removed from route names when deriving identifiers |
<aliases><alias entrypoint="order_new" id="route.legacy.order.new"/></aliases> |
— | resolves two entry points deriving the same identifier |
<groups><group main="app_order_index"><satellite>app_order_export</satellite></group></groups> |
— | documents an entry point as part of another workflow |
<types><type name="webhooks" prefix="webhook"/></types> |
— | workflow types of your own |
<symfony console="docker compose exec -T php bin/console" env="dev"/> |
bin/console, dev |
how to run the project's console |
<laravel artisan="vendor/bin/sail artisan" env="local" timeout="60"/> |
php artisan, the project's APP_ENV |
how to run a Laravel project's Artisan |
<node binary="node" timeout="120"/> |
node, 120 |
how to run Node, which reads JavaScript and TypeScript for the Angular and Express adapters |
<ai enabled="true" model="claude-opus-5-5" budget-tokens="200000"/> |
off, Opus 5.5, 16000, 200000, medium, no web search |
ai:run — see Writing in CI |
<routes group="controller"/> |
entry-point |
one page per route at the root of routes/, or the same pages laid out under a directory per controller, with an index |
<php web-root="htdocs"/> |
public, web, www |
the web directory of a PHP project without framework |
<language pages="fr"/> |
en |
language Claude writes the prose of the pages in; --locale wins over it |
<knowledge library="…" share="true"/> |
see Knowledge | where the shared stack knowledge lives, and whether a new sheet is deposited there |
What a workflow contains
For a PHP project, the files of a workflow are found by reading the code, never by running it:
- from the entry point's method, not its whole file — plus what every method of the class shares (parent class, attributes, properties, constructor). The list route of a controller does not inherit the form of its creation route;
- through the classes actually used — an unused import is not a dependency — located with the
PSR-4 map of the project's
composer.json; a class ofvendor/becomes a package with the version ofcomposer.lock. A project autoloading throughclassmap,psr-0orfilesgets a warning saying so: those classes are not resolved to files, so they are missing from the workflows that use them; - through the templates rendered with a literal name, and their
extends,include,embed; - through the files required or included by a literal path —
require __DIR__.'/../lib/db.php'; - up to
<graph depth>hops (3 by default): controller → service → repository → entity.
A back-office has twenty routes per resource, and a menu of 233 entries is a list nobody reads.
<routes group="controller"/> groups them — and it groups the navigation, not the workflows: every
route keeps its own page, with the same identifier it has by default, and the controller gets a directory
and an index (ADR-0045). The menu then lists 50 controllers instead of 233 routes, and the page of a
controller holds three sections: a summary of three to five sentences, the table of its routes, and the
state machine of the resource when it has one.
⚠️ The grouping used to fold a controller's routes into one workflow, with its routes as triggers. A
controller with one route read well that way; one with thirteen produced a page whose single sequence
diagram had to tell thirteen different gestures. Moving to a group changes every identifier of a project
that used it: its pages become orphans, and --prune plus a full rewrite is the migration.
The menu gains a third level past a dozen entries of one type, one sub-heading per family (admin.… →
admin).
Existing tests are the test files that use the entry point or a file of its first hop. Two routes to
the same method are one workflow; <groups> in config.xml joins others.
⚠️ What is wired at runtime is not seen: a service fetched from the container by name, a template whose name is computed, a class built from a string. Such files appear under Not covered in the menu — the analysis says what it missed rather than pretending.
The pages
Every workflow page has the same nine sections, in the same order — which is what makes pages parsable, diffable, and comparable across stacks:
A group page — routes/<controller>/README.md — has three of its own, and the summary is the only one
Claude writes: Summary, Routes (the table), States.
| Section | Written by | Without Claude |
|---|---|---|
| Summary | Claude | — |
| Trigger | DevTools (the preconditions row: Claude) | entry point, satellites, security |
| Journey | Claude | — |
| Decisions | DevTools for the state machine and the navigation, Claude for the fields | the state machine and where the workflow leads |
| Data | Claude | — |
| Cross-cutting mechanisms | DevTools, enriched by Claude | the listeners and voters that run inside it, and what they decide |
| Points of attention | Claude | — |
| Related workflows | DevTools | dependencies and navigation, linked |
| History | DevTools | one line per rewrite, never rewritten |
⚠️ "Navigation / states" was a tenth section until ADR-0044. A state machine is a decision, on the
field marking: it opens "Decisions", followed by the navigation, then the field diagrams Claude writes,
below a Markdown comment a reader does not see and DevTools splits the section on. A page of the
ten-section template is recognised and rewritten in nine at the next inspection, its prose kept — and a
draft that draws a stateDiagram is refused: that one is DevTools' to draw.
⚠️ These headings were French until v3 (Résumé, Déclencheur, Parcours…). A page written by an
older DevTools is read through a map of the old headings to the new ones, so the prose Claude wrote comes
back where it belongs, and the next inspection rewrites the page in the current template — every page,
not only the ones a fact makes it rewrite. The migration changes nothing else: same facts, same prose, and
the tracking file is not touched, so no revision is added and no brief is opened. One workflows:inspect
and a project is migrated.
⚠️ The page lists neither the files nor the tests of the workflow. They live in its tracking file,
.devtools/workflows/<type>/<id>.xml, which is what links the workflow to its code and what freshness
reads. On a real project those two tables were 250 of a page's 344 lines.
⚠️ A section DevTools writes is regenerated from the code on every rewrite — a fact corrected by hand
there is lost; fix it where it comes from. What Claude wrote survives factual rewrites. workflows.md
lists every workflow by type with its counter, names in one line the types with no workflow at all, then
two sections that are never hidden: To check (stale, orphaned, waiting for Claude) and Not covered
(source files no workflow reaches).
Decisions — the diagram that answers "why this value?"
A flowchart of the files a route reaches restates a table; a flowchart of the logic that gives a field
one value rather than another answers the only question a reader really has. DevTools extracts those
decision points from the code it already parsed — a property written under an if, an elseif, a
match, a ternary or a ??, a method returning an enumeration case — and records them in the model and
the tracking file:
<decisions> <decision target="App\Entity\Order::status" value="'priced'" condition="!('' === $order->customer || null === $order->total)" file="src/Service/OrderPricing.php" line="29" confidence="high"/> </decisions>
Claude turns them into one diagram per field, conditions rewritten in business language; a draft is refused when it names a field the model does not record, or leaves one out.
Four more places decide a value, and are read the same way (ADR-0044):
| Where | Decides | Recorded as |
|---|---|---|
$order->setStatus($this->decide($order)) |
the field, in decide() of the same class |
each return of decide(), its condition prefixed decide() : … — one call, never two |
#[Assert\Choice([...])], #[Assert\Expression(...)], #[Assert\When(...)] |
the annotated property | the admitted values, valid / invalid under the expression, the constraints under when |
->add('site', EntityType::class, ['choices' => …, 'query_builder' => …]) |
the property of the form's data_class |
what the option computes, depends on what it reads ($options['user'], $this->sites) |
['required' => $x], ['disabled' => $x] |
the form field itself (CustomerType::site.disabled) |
true under $x, false under its opposite |
An assignment written once with no condition is not a decision, it is a default: only a field whose
value depends on something is kept. A receiver DevTools cannot type is recorded at confidence="medium"
under the name as written — named, never invented. Extraction stops at three nested conditions and fifty
points per workflow, and says so rather than pretending the model is whole.
Listeners and voters are mechanisms, not workflows
A listener has no page of its own: what a reader wants to know is what it can do during a given workflow. Kernel listeners run inside every route, console listeners inside every command, a Doctrine listener targeting an entity inside the workflows that reach it — each with its event, its priority and the fields it writes, in Cross-cutting mechanisms.
A voter runs inside the routes that ask for one of the attributes its supports() names — through
#[IsGranted], denyAccessUnlessGranted() or access_control — and inside no other. Its column reads
the attributes it votes on, and what it decides is access: granted or denied, under the conditions of
voteOnAttribute(), followed one call into the voter itself. A screen open because a permission code is
misspelled shows there.
⚠️ There is therefore no events workflow type, against the seven of the specs. A project that wants
such pages declares a custom type in config.xml.
Glossary
Two pages of the same project call the same thing an order, a purchase and a command; a newcomer does
not know that an Item is what the screens call an object. Each inspection collects the project's terms
from the model, deterministically — the short names of the entities and messages the workflows traverse, the
components of the interface, and every word three workflow identifiers or more share (orders in
route.orders.index, route.orders.show, route.orders.store, which joins the Order entity) — into
.devtools/glossary.xml, versioned:
<term name="Order" status="defined" occurrences="6"> <definition>Commande passée pour un client sur un produit, tarifée puis validée par un opérateur.</definition> <label>Commande</label> <synonym>purchase</synonym> <workflow>route.order.index</workflow> … <file>src/Entity/Order.php</file> </term>
A new term is a candidate: one brief, .devtools/pending/glossary.brief.xml, asks Claude to define all of
them in one or two sentences, with the synonyms the code and the pages use and the label the screens show.
workflows:apply refuses the draft whole when a definition runs longer, names a term the glossary does not
have, or redefines one set aside. Once defined, a term is listed in docs/workflows/glossary.md, linked
from the menu, and handed to the brief of every page whose workflow meets it — with the instruction to use
that word. A page draft that writes a declared synonym instead is accepted, with a warning.
A term that is no business word — a component named Layout, say — is set aside by hand: status="ignored"
in glossary.xml. It is never proposed again; a definition is never asked for twice either, and a renamed
workflow carries its terms along. Nothing is written without a candidate: a project whose vocabulary is
defined costs no brief.
Impact of a change
"What does this change touch?" — answered from the map the last inspection wrote, never from the code: two XML files, no parsing, a few milliseconds even on a project of three hundred routes. It is what a Claude Code hook asks before every edit, and what the gate uses to choose the scenarios to replay.
vendor/bin/devtools workflows:impact src/Service/OrderPricing.php # files, relative or absolute vendor/bin/devtools workflows:impact --commit=HEAD # the files of a commit vendor/bin/devtools workflows:impact --range=main..feature # a pull request vendor/bin/devtools workflows:impact --changed # the working tree, untracked files included [--format=text|json|xml] [--depth=0|1] [--path=<project>]
Each workflow comes with how the change reaches it — direct (it traverses the file), test (the file
tests it), dependency (the file declares a listener or a voter running inside it, or belongs to a
workflow it depends on) — the files that do, its page and its freshness. --depth=0 leaves the
dependencies out. A file no workflow references is listed as not covered, never ignored: a new file is
the usual case, and the next inspection says where it belongs. A path outside the project is refused.
--commit, --range and --changed cost one git process each.
⚠️ The map is as fresh as the last inspection. When a file asked about changed after it — or vanished
from a map that still names it — the answer starts with a warning: run workflows:inspect for an exact
one.
The json format is for tools:
{
"warnings": [],
"workflows": [
{
"id": "route.order.new",
"type": "routes",
"title": "GET|POST /orders/new",
"relation": "direct",
"files": ["src/Service/OrderPricing.php"],
"page": "docs/workflows/routes/order.new.md",
"status": "fresh"
}
],
"uncovered": ["src/Brand/New.php"]
}
The xml format follows impact.xsd, copied into .devtools/schemas/ like every schema.
Asking the documentation
"What happens when an operator validates an order?" — the answer is in the pages, and nobody has to read the
code again for it. Every inspection indexes them in .devtools/search.idx (a cache git ignores, built again
by a question whenever a page changed since): their titles, identifiers, summaries and prose, the names of
their files, and the words the glossary gives them.
vendor/bin/devtools ask --pages "Que se passe-t-il quand un opérateur valide une commande ?"
--- ---------------------------- ------- ----------------------------------------------- ------------ -------------------------
# Workflow Score Page Revised Found
--- ---------------------------- ------- ----------------------------------------------- ------------ -------------------------
1 route.order.new 4.18 docs/workflows/routes/order.new.md 2026-09-16 by its words
2 route.order.validate 3.11 docs/workflows/routes/order.validate.md 2026-09-16 by its words
3 command.app.import-catalog 2.50 docs/workflows/commands/app.import-catalog.md 2026-09-16 by its words
4 route.customer.edit 1.62 docs/workflows/routes/customer.edit.md 2026-09-16 by its words
5 route.order.show 0.73 docs/workflows/routes/order.show.md 2026-09-16 next to route.order.new
--- ---------------------------- ------- ----------------------------------------------- ------------ -------------------------
No AI, nothing sent: BM25 over the words of the pages — lowercase, accents and stems aside, so that
valide, validate and validation meet — then, one hop away from the three best, the workflows they depend
on or lead to. Eight pages at most, the stale ones said. --format=json is for tools; the MCP server answers
the same through search_workflows.
In Claude Code, the devtools-ask skill (installed by claude:install) runs that selection, reads the
pages, and answers from them alone, citing the identifiers of the workflows and the dates of their pages.
Out of a session — a terminal, a CI job — devtools ask "<question>" answers through the Claude API, with
the selected pages as its only context and the instruction to say "Not documented." rather than invent. It is
ai:run's client, off unless the project turns it on (<ai enabled="true"/>, the key in
ANTHROPIC_API_KEY): it spends tokens. The answer lists its sources with their revision dates, and warns when
it rests on a stale page or cites a workflow it was not given — which nothing then checks.
Linked projects
A mobile application knows of its back-end only /api/*: changing a resource of the API without knowing
which screens call it is the question this tool answers, across two repositories. Each project declares the
other in its .devtools/config.xml, with a path relative to itself:
<!-- in the API --> <links> <project name="mobile" path="../mobile" role="client"/> </links> <!-- in the mobile application --> <links> <project name="api" path="../api" role="server"/> </links>
vendor/bin/devtools workflows:inspect ../api ../mobile --link
Both are inspected as usual, then linked, in memory, from what both inspections wrote — no inspection more.
Each call of an integration of the client (GET ${environment.apiUrl}/items/${id}, read by the Angular
adapter) is matched against the routes of the server by method and path: the host goes, a parameter matches
any segment, and the routes alike the most win — /items/${id} is /items/{id} rather than
/items/export. When the start of a URL cannot be read, it is matched by its end, or replaced by the
base-url a <project> declares.
The calls land in .devtools/graph/links.xml in each project — the neighbour's name, its relative path,
the workflows on both sides, never an absolute path — and in their pages, after their own related
workflows:
## Related workflows - mobile · [`integration.item-service`](../../../../mobile/docs/workflows/integrations/item-service.md) — calls GET https://items.example.com/api/items/:param
The link opens when the two repositories sit side by side on disk, as they do where --link ran. A call no
route answers — a screen calling a route the API deleted — is an orphan call: it is listed under "To
check" in both menus, and workflows:check fails on it in both projects. So does a linked workflow that is
gone, on either side: run --link again. A neighbour that was never inspected keeps the links it had, with
a warning; in CI, with one repository checked out, the neighbour is not checked against.
workflows:impact --linked follows the links: a controller of the API changes, and the answer names the
integrations of the mobile application that call its routes (calls), then the screens that go through
those integrations (uses), read from the mobile application's own map.
Claude Code hooks
A tool Claude has to think of asking is a tool it forgets. A hook runs every time:
vendor/bin/devtools claude:install --hooks # merged into .claude/settings.json — commit it vendor/bin/devtools claude:uninstall --hooks # takes back exactly what it added
| Event | When | What Claude is told |
|---|---|---|
PreToolUse |
before Edit, Write, MultiEdit |
the workflows the file reaches, their pages, a few of their tests — or, for a file no workflow references, one line saying so — and the active rules that apply to it |
SessionStart |
at the start of a session | how many workflows are documented and where, those to check, and the MCP server when it is installed |
Stop |
when Claude is about to end a task | the gate: while it fails, the task is not finished — see The gate; once it passes, the task is written in the journal |
PreToolUse and SessionStart add to Claude's context and never block anything: a tool that writes no file, a file
outside the project or a file that reaches nothing and no rule applies to gets no answer at all, and whatever goes wrong — no
index, a broken map, an input DevTools does not understand — exits 0 with nothing injected and the cause on
the standard error. Measured on a project of three hundred routes, the PreToolUse answer takes about
75 ms end to end, PHP started.
The install merges: the hooks, permissions and settings the team already has stay untouched, an empty
object stays an object, and claude:uninstall --hooks gives the file back as it was — removing it when
DevTools' entries were all it held. The command it writes is
"${CLAUDE_PROJECT_DIR}/vendor/bin/devtools" claude:hook <Event> when the project installs DevTools, and
devtools claude:hook <Event> for a global installation.
The gate
Claude says "done", a human opens the browser and finds the defect: the gate moves that moment before the
human. While it fails, Claude cannot call the task finished — the Stop hook runs it, and blocks.
vendor/bin/devtools gate # the working tree, by default vendor/bin/devtools gate --commit=HEAD # or --range=main..feature, or files [--base-url=…] [--strict] [--format=text|json]
| Step | Judges, on what the change reaches | Fails when |
|---|---|---|
freshness |
the facts, as workflows:check reads them |
a fact changed, an entry point is new or gone |
scenarios |
the scenarios of the routes and components reached — replayed, compared with their baselines | one fails, or differs from its baseline |
verification |
a change to a template or a form, by the verifier | its verdict is not approved yet |
assertions |
the assertions of the active rules that watch a file of the change | one fails |
Every step runs, whatever the first found, so that one passage says everything there is to fix, each line
naming the workflow, the scenario, the step, the capture. A workflow reached without a scenario, scenarios that
cannot be replayed here — no Panther, no base URL — or code changed without a fact moving: warnings, which
--strict turns into failures; a project that starts must not be red everywhere. The last passage is kept in
.devtools/gate/last.xml, never committed.
The Stop hook (claude:install --hooks) runs the gate on the working tree when Claude is about to end a
task. Failed: the task is not finished, and the gate's text is what Claude reads next. Passed on this very
state of the change: it does not run again. ⚠️ The same failure three times in a row: Claude is asked to stop
trying and to tell you what still fails; the fourth time, it may end — a gate that blocks forever fixes
nothing.
The verifier
Whoever wrote the code checks it with the author's bias. The verifier judges a change to the interface in a fresh context: a Claude Code subagent that can only read, given a folder that holds what it needs and nothing of the conversation that wrote the change.
vendor/bin/devtools claude:install --agents # .claude/agents/devtools-verifier.md — commit it vendor/bin/devtools verify:prepare --changed # .devtools/pending/verification.<timestamp>/ vendor/bin/devtools verify:collect # checks the verdict, records the one that counts
The folder holds the routes and components the change reaches: their page, their scenarios, the captures of
the last run with their baselines and diffs — copies — and names the code they are made of. Not a commit
message, not a line of the conversation. The agent reads it, and answers with a verdict — approved,
rejected, inconclusive — and findings: a workflow, a severity, a category of the closed list, a CSS selector
of the captured page, a sentence. It cannot write: the session that called it writes its answer to
verdict.xml in the folder. verify:collect then checks it — a workflow it was shown, a category of the list,
a selector the captured page has, a rejection that says what blocks — and anything it cannot check counts as
inconclusive, never as approved.
The gate asks for all this on its own for any change to a template or a form: its verification step prepares
the folder, says what to do, and turns green once the verdict collected for that very state of the change is
approved.
MCP server
The hooks tell Claude what a file reaches; the MCP server lets it ask — one page instead of a directory to read. It runs on stdio, launched by Claude Code inside the project, and needs the MCP SDK, which DevTools only suggests:
composer require --dev mcp/sdk:^0.9 vendor/bin/devtools claude:install --mcp # merged into .mcp.json — commit it vendor/bin/devtools claude:uninstall --mcp # takes back exactly what it added
| Tool | Input | Answers |
|---|---|---|
list_workflows |
type?, status? |
identifier, type, title, freshness, confidence and page of each workflow |
get_workflow |
id |
its page, then its files, tests, dependencies, mechanisms and history |
search_workflows |
question, limit? |
the pages that answer it, as ask --pages --format=json lists them |
impact |
files or changed: true |
what workflows:impact --format=json answers |
get_knowledge |
stack? — a root or a key |
the knowledge sheet of the stack, read where the inspection would find it |
Every tool reads: none writes, none inspects, none reads the code — a call reads the index, one page and its tracking file, the impact map or the search index — built again in memory, never on disk, when a page changed since. A project never inspected gets the command to run; an unknown identifier, a path outside the project or an unreadable file is an error Claude reads, never a crash of the server.
The entry it writes is {"type": "stdio", "command": "${CLAUDE_PROJECT_DIR:-.}/vendor/bin/devtools", "args": ["mcp:serve"]} when the project installs DevTools, with "command": "devtools" for a global
installation. The servers the team declared stay untouched, and so does a server named devtools DevTools
did not write, unless --force. Claude Code asks each teammate to approve a server of .mcp.json once.
⚠️ Without the SDK, mcp:serve does not exist: asked for anyway, it answers with the line that
installs it. The SDK is not 1.0 yet — DevTools follows one minor version at a time.
Scenarios
"I checked it in the browser" is neither reproducible nor verifiable. A scenario is: written, versioned, and replayed by a runner — what a person does, step by step, and what the application must show.
vendor/bin/devtools scenarios:generate # every route and component vendor/bin/devtools scenarios:generate route.order.new # one workflow vendor/bin/devtools scenarios:generate --no-ai # skeletons only
<scenario xmlns="https://github.com/jul6art/devtools/schema/scenario/1" schema-version="1" id="route.order.new/happy-path" workflow="route.order.new"> <given> <login role="ROLE_OPERATOR"/> <fixture name="catalog-loaded"/> </given> <steps> <goto route="app_order_new"/> <fill field="order[customer]" value="ACME"/> <select2 field="order[product]" search="Widget" pick="Widget XL"/> <click text="Save"/> <assert-route name="app_order_show"/> <assert-visible selector=".flash-success"/> <screenshot name="after-save"/> </steps> </scenario>
Each one lives in .devtools/scenarios/<workflow>/<name>.xml and follows scenario.xsd, whose vocabulary is
closed: goto (with <param> for a route's parameters), fill, select, check, click (a text or
a selector), submit, wait-for, assert-route, assert-visible, assert-text, assert-count,
screenshot, and the business action select2. login and fixture are names the project resolves:
a scenario holds neither a password nor data.
- Without AI, a route that answers GET without a parameter gets a
smokeskeleton — visit, assert the route, capture — as the role its security asks for. It can be replayed at once. The others are named. - With AI, each route and component without a scenario of its own gets a brief in
.devtools/pending/; Claude writes the draft, andworkflows:applychecks it — the schema, its own workflow only, routes the project has, no scenario a human wrote — or refuses it whole. - A scenario written or modified by hand is never regenerated.
.devtools/scenarios/index.xmlrecords who wrote each one and its hash: a file that no longer matches is yours. - A rewritten workflow puts its scenarios to review, in the menu's « To check »: listed, never deleted.
A skeleton follows its facts on the next
scenarios:generate; Claude's are briefed again; yours stay as they are untilscenarios:generate --reviewed=<scenario>records that you looked. A scenario whose workflow is gone is listed too, and the inspection warns about it.
Routes and components only: a command or a message has no browser to replay.
Replaying the scenarios
A scenario is worth something only replayed exactly, without whoever wrote the code. DevTools replays them in Chrome through Panther, which it only suggests:
composer require --dev symfony/panther:^2.4
vendor/bin/bdi detect drivers # once: ChromeDriver matching your Chrome, in drivers/
Against a running application — any stack, Docker or not:
vendor/bin/devtools scenarios:run --base-url=https://app.localhost # every scenario vendor/bin/devtools scenarios:run route.order.new --base-url=https://app.localhost [--timeout=10] [--viewport=1280x800] [--headed]
As PHPUnit tests, in a Symfony project — Panther serves public/ itself:
use Jul6Art\DevTools\Bridge\Symfony\Test\ScenarioTestCase; final class ScenariosTest extends ScenarioTestCase { }
One test per scenario, named after it. Both modes run the same runner: one browser for the run, cleaned
between scenarios — no cookie, and nothing the application kept in localStorage or sessionStorage, so that a
scenario never opens on what the previous one left; every step waits for what it looks for — never a fixed
pause — up to the timeout. The first step that fails stops its scenario.
A scenario names a role and fixtures; the project resolves them. In the URL mode, .devtools/config.xml
says how — and never holds a credential, only the environment variables that do:
<scenarios base-url="https://app.localhost" timeout="10"> <login role="ROLE_USER" type="form" url="/login" username-env="APP_USER" password-env="APP_USER_PASSWORD"/> <login role="ROLE_ADMIN" type="http-basic" username-env="APP_ADMIN" password-env="APP_ADMIN_PASSWORD"/> <fixture name="catalog-loaded" command="bin/console app:fixtures:load catalog"/> </scenarios>
In PHPUnit, the services of the application that implement LoginProviderInterface and
FixtureProviderInterface win over the file. The application runs in the web server Panther starts, another
process: a fixture that resets what a scenario needs isolates it, a transaction of the test does not.
Each run writes .devtools/runs/<timestamp>/result.xml (run.xsd): a verdict per scenario and per step —
passed, failed when the application did not do what the step says, error when the run could not tell
(a login, a fixture, the browser), skipped after a failure — with its duration and its message. Every
<screenshot> step, and every step that did not pass, keeps the screen as a PNG and the DOM as HTML beside
it, with the URL the browser was at. The command exits 1 when a scenario did not pass. Credentials never
appear in what a run writes: they are masked in messages, URLs and DOMs alike.
Visual baselines
A green scenario can render a broken screen: a select2 that fell back to a native select, a filter that is
no longer shown. Every <screenshot> is compared with the baseline a human accepted, in
.devtools/baselines/<scenario>/<name>.png and .dom.html, versioned with the code:
vendor/bin/devtools scenarios:run --base-url=… # "3 captures have no baseline" vendor/bin/devtools baselines:accept 2026-10-20-153000 # after looking at them; or "last" vendor/bin/devtools baselines:accept last route.order.new # one workflow, or one scenario
| Verdict | When | The scenario |
|---|---|---|
identical |
not one pixel differs | passes |
within-threshold |
fewer differ than the threshold allows | passes |
different |
more differ, or the size changed | fails, the step says how much and what changed |
no-baseline |
nobody accepted one yet | passes, and the run says so |
The image says where: pixel by pixel, with ext-gd, and a diff image beside the capture — the baseline
faded, the differing pixels in red. The DOM says what: normalised — attributes sorted, scripts dropped,
tokens and generated identifiers masked — and compared line by line, so that the step reads
<select name="order[product]"> became <input name="order[product]">. Both land in result.xml.
<scenarios> <visual threshold="0.1%"> <mask selector=".timestamp"/> <mask selector="img.avatar"/> </visual> </scenarios>
A mask hides what changes on every run before the capture is taken — its place kept, so that nothing else moves — and empties it in the DOM. Masks are the only way to keep false positives down.
⚠️ A baseline exists only once a human accepted it. Nothing accepts one on its own: a baseline accepted
by whoever wrote the change would approve exactly what the comparison is there to question. Baselines weigh on
the repository: past 50 MB, Git LFS is worth it. Without ext-gd, the images are not compared — the run warns
— and the DOM alone decides.
⚠️ ChromeDriver must match Chrome, to the major version. bdi detect drivers downloads the right one into
drivers/, which scenarios:run uses first — keep it out of git. A CI image updates Chrome on its own
schedule: install the driver in the job, not once and for all. Without Panther, scenarios:run does not exist
and says how to install it; generating scenarios needs no browser.
Feedback
The filter does not work, this needs a select2: said in the chat, a remark is forgotten by the next task and said again. Recorded, it is data — what it is about, a category, one line — and the second time it comes back, it is counted rather than repeated.
vendor/bin/devtools feedback:add "The product field must be a select2, not a native select." \ --workflow=route.order.new --selector="#order_product" --category=ui-component vendor/bin/devtools feedback:add # the same, asked question by question vendor/bin/devtools feedback:list [--status=open|fixed|promoted|wont-fix] [--category=…] [--workflow=…] vendor/bin/devtools feedback:list --categories # the closed list vendor/bin/devtools feedback:resolve <id> --status=fixed|wont-fix
A feedback is about a workflow, an element of its page — a CSS selector — or a file, at least one of
them, each checked: a workflow the project has, a file inside it. Its summary is one line of 200 characters at
most. Its category comes from a closed list — ui-component, filter, validation, navigation,
permission, translation, layout, performance, data-display, error-handling — the one the verifier
files its findings under too; it groups, the summary describes. A project adds its own:
<!-- .devtools/config.xml --> <feedback> <category name="accessibility"/> </feedback>
Already said. Before recording, feedback:add looks for a feedback of the same category on a target close
to this one: the same selector, the same file, the same workflow — or two workflows of the same kind rendering
a component they share. Found, it asks whether this is the same remark: yes adds an occurrence to it, no
records a new one. --auto answers yes, --new answers no; without anyone to ask and without either, the
command stops and says which feedback it found. A feedback fixed and seen again is open again; one set aside
as wont-fix is not looked for. Seen twice, a feedback is worth a rule: the command says so, and the rule
is the next step.
Each feedback is a file, .devtools/feedback/<year>/<id>.xml, its identifier made of the day and of what it is
about — fb-2026-10-20-order-product — and index.xml lists them all: it is what list and the search for a
recurrence read, never the files one by one. Both are versioned with the project — a feedback is shared like the
code it is about.
The review page
Typing a selector is the moment prose wins. devtools review opens a page where the remark is made where it is
seen:
vendor/bin/devtools review [--run=2026-10-20-153000] [--port=8765] # [OK] The review page: http://127.0.0.1:8765/?token=3f9c…
- The run — the last one, or
--run— scenario by scenario: each screenshot next to its baseline and its difference, and where a scenario failed, the capture the run kept. A box keeps only the workflows the current change of the working tree reaches. - The page captured with it, shown in a frame that runs nothing — sandboxed without scripts, under a policy
that forbids them. Point at an element: it is outlined. Click it: its selector is computed — its
id, else itsname, else one of itsdata-*attributes, else the shortest path — and always checked to name it alone. - The feedback: the element and the workflow filled in, a category of the list, one line of 200 characters at
most — recorded as
feedback:addrecords it. Already said, the page asks: an occurrence, or apart. Seen twice, it proposes the rule. - A capture accepted as the baseline, once looked at — as
baselines:acceptdoes.
PHP's built-in server serves it on 127.0.0.1 only, and every request carries the random token of the address it
prints: another application of the machine cannot write feedback in your name. It writes in
.devtools/feedback/ and .devtools/baselines/, nothing else, replays nothing, and needs nothing from a CDN —
one page, one stylesheet, one script, in resources/review/. Ctrl+C stops it.
Rules
A remark said twice is worth more than a third time: a rule says it once, and is given to Claude right
before it edits a file the rule applies to — not in a CLAUDE.md read at every session, where it would weigh
on everything and apply to nothing in particular.
vendor/bin/devtools rules:promote fb-2026-10-18-order-product [fb-…] # a brief for Claude vendor/bin/devtools workflows:apply # its draft, checked: a proposed rule vendor/bin/devtools rules:list [--status=proposed|active|archived] vendor/bin/devtools rules:activate rule-product-fields-use-select2 # you agree: Claude is given it
rules:promote writes a brief: the feedback, the files it is about and those of its workflows, what a rule may
name. Claude answers with a draft — the devtools-inspect skill follows every brief — and workflows:apply
checks it before writing .devtools/rules/<id>.xml:
<rule xmlns="https://github.com/jul6art/devtools/schema/rule/1" schema-version="1" id="rule-product-fields-use-select2" status="proposed" created="2026-10-20"> <origin feedback="fb-2026-10-18-order-product fb-2026-10-20-invoice-product"/> <applies-to> <files glob="templates/**/_form.html.twig"/> <workflow-type>routes</workflow-type> <category>ui-component</category> </applies-to> <instruction>An entity choice field with more than 20 options uses Select2Type, never a native select.</instruction> </rule>
- Where it applies: globs of files, kinds of workflow, or both — of one kind any of them, of both kinds one
of each. Never everywhere: a rule for every file belongs to the project's
CLAUDE.md. Each glob must match a file of the project;*stays in a directory,**crosses them,{a,b}is one or the other. - What it says: one to three sentences. A rule that needs a paragraph is two rules.
- Proposed by Claude, active once a human runs
rules:activate— checked again then, the project may have moved — and only then given. The feedback it comes from ispromotedto it, and no longer proposed for a rule.
Before an edit, the PreToolUse hook adds the active rules that apply to the file — its path, or the kind of a
workflow it is part of — to what it says of the impact: the identifier and the instruction, nothing else, five
at most, the most specific first (the longest glob written out, then the rule that asks for both a file and a
kind of workflow). Each time a rule is given counts, in .devtools/stats/rules.xml: a count that moves on every
edit is never committed, and init adds stats/ to the .gitignore. rules:list shows it.
Assertions
A rule is still an instruction, and an instruction can be missed. An assertion cannot: the gate runs it on every change to the files it watches, and while it fails, the task is not finished. Feedback, rule, assertion, gate — a mistake promoted this far can no longer come back silently.
vendor/bin/devtools assertions:propose rule-product-fields-use-select2 [--before=<commit>] vendor/bin/devtools workflows:apply # runs the draft before writing .devtools/assertions/<id>.xml
<assertion xmlns="https://github.com/jul6art/devtools/schema/assertion/1" schema-version="1" id="assert-no-native-select" rule="rule-product-fields-use-select2"> <watch glob="templates/**/_form.html.twig"/> <pattern regex="/<select\b(?![^>]*data-controller="select2")/i" expect="must-not-match"/> <message>A select in a form template uses Select2.</message> </assertion>
| Family | Checks | Example |
|---|---|---|
pattern |
a regular expression on the text of each watched file — any stack | no <select without Select2 in a form template |
dom |
a CSS selector on the DOM the screenshots of a scenario captured — no browser started | #order_product.select2-hidden-accessible exists after route.order.new |
tool |
a command of the project, its report read as phpstan or eslint JSON or as an exit-code, filtered on an identifier |
vendor/bin/phpstan analyse --error-format=json {files}, identifier app.select2Required |
A PHPStan or ESLint rule lives in the project, in its own tooling: DevTools calls it and reads what it reports, it
writes no analyser. {files} in a command stands for the watched files of the change.
An assertion is run before it is written. On the project as it is — where the rule is followed — it must
pass; at the commit its feedback was reported at — where the defect was — a pattern must fail, read from git
without touching the working tree. One that passes in both cannot tell the defect from its fix, and is refused.
If the defect was not committed yet when it was reported, --before names a commit that has it. A dom or tool
assertion is checked on the project as it is only, and workflows:apply says so.
In the gate, the assertions step runs the assertions of the active rules that watch a file of the
change — the others are not even started — a tool once per command, whatever the number of assertions that
read it. A failure names the rule, the assertion, the file and the line, and counts as blocked for the rule
(rules:list). A dom assertion reads the captures of this very passage only: without scenarios replayed, it
cannot tell, which is a warning — never the captures of an older change.
Retrospective
Storing feedback is not the point: it must go down. The retrospective says whether it does.
vendor/bin/devtools retro [--since=2026-07-01] [--format=md|xml] [--inactive-days=30] # [OK] The retrospective: .devtools/reports/retro-2026-10-21.md # 2026-10: 0.50 human remarks per task, -70 % against the three months before.
It counts against tasks delivered: a task is a piece of work the Stop hook let end because the gate passed.
The hook writes it in .devtools/journal/<year-month>.xml, versioned — when it started, at which commits, the
workflows it reached, how many passages of the gate it took, the steps that failed and the rules that blocked on
the way. A remark goes to the last task delivered before it whose commits hold the one it was made at, else to the
last that reached its workflow.
| Section | What it says |
|---|---|
| Human feedback per delivered task | by month, and the last one against the three before — the target is -50 % in three months |
| Recurring categories | human remarks of the last month against the monthly mean of the three before: up, down |
| Rules that blocked the gate | the tasks each rule stopped: one that blocks regularly serves |
| Inactive rules | neither given to Claude nor blocking for 30 days — proposed for archiving |
| Feedback seen twice, not promoted | still open: a rule waiting to be asked for |
| Gate failures before success | the passages each task took to turn green |
| The verifier and the humans | by category, what the verifier found, and what humans still had to say |
Nothing is archived on its own: devtools rules:archive <rule> does it, and rules:activate brings a rule back.
The report is written in .devtools/reports/, never committed; reading it runs no scenario and asks git only to
place a remark in a task.
Writing with Claude Code
DevTools never calls Claude. It writes a brief for every page Claude has to write — created or rewritten by the run, or never written — and validates the draft Claude writes back:
vendor/bin/devtools claude:install # once: .claude/skills/devtools-inspect/SKILL.md, commit it
Then run /devtools-inspect in Claude Code — or simply ask it to document the workflows. The skill
runs the loop:
workflows:inspectwrites.devtools/pending/page.<id>.brief.xml— the workflow's model, the current page, why it changed, the language, and the versioned prompt to follow;- Claude reads the files the model lists and writes
.devtools/pending/page.<id>.draft.md: summary, preconditions, a sequence diagram, data, cross-cutting mechanisms, points of attention, and one line for the history; workflows:applychecks each draft, rebuilds the page — facts from the code, prose from the draft — records the model and the prompt version in the tracking file, and rewritesworkflows.mdso that the workflow is no longer listed as waiting for Claude — from what is on disk, without inspecting again;- the skill inspects again until no brief is left.
A draft is refused, with the rule and the line, when it adds or drops a section, when its journey is not
exactly one Mermaid diagram, when it quotes a file the workflow does not traverse, or when the code changed
after its brief was written. A refused draft changes nothing and stays in pending/ to be corrected.
--no-ai writes no brief: factual pages only, the mode for CI. What Claude wrote survives later factual
rewrites; a workflow whose files change gets a new brief.
⚠️ Briefs live in .devtools/pending/, ignored by git: they are recomputed on every run and quote
absolute paths of the machine.
tests/Fixtures/demo/symfony-minimal/ is the output of a real session on the fixture application.
Writing in CI
⚠️ Off by default — it spends API tokens. ai:run writes the pending briefs through the Claude API instead
of a Claude Code session, for a repository nobody opens on the day it changes. A project turns it on in its
config.xml, and pays for what it sends:
<ai enabled="true" model="claude-opus-5-5" max-tokens="16000" budget-tokens="200000" effort="medium" web-search="false"/>
ANTHROPIC_API_KEY=… vendor/bin/devtools ai:run [--tasks=page,discovery,scenario,rule,assertion] [--max-tasks=20]
Each brief is sent with the files it names — nothing else — then its answer is written as the draft and
workflows:apply judges it like any other: no validation is skipped. The prompt, the knowledge sheet and a
fixed preamble form a prefix every page shares, cached, so the second page costs a fraction of the first.
| Guard | What it does |
|---|---|
enabled="true" |
nothing is ever sent before a project says so |
ANTHROPIC_API_KEY |
the only place the key is read; it goes in the request header, never in a file, a report, a brief or a message |
--max-tasks (20) and budget-tokens |
the run stops cleanly at either, and the briefs left over are kept for the next |
| secrets | right before a task leaves, every file of it is checked: an .env* file, an excluded directory, a key, a token, a private key or a password = "…" refuses the task — nothing of it is sent |
web-search |
a knowledge sheet is researched on the web, with the server-side search tool when the key allows it; without it the task is skipped — never written from memory |
The report — in the console and .devtools/reports/ai-run-*.md — lists what was written, accepted, refused
and kept, the tokens in, out and through the cache, and what they probably cost. The default model is
Claude Opus 5.5, with Anthropic's server-side fallback when a safety classifier declines; a draft Claude
declines, or that does not fit in max-tokens, writes nothing.
In GitHub Actions, after a merge — a pull request with the documentation, never a push:
on: push: branches: [main] jobs: documentation: runs-on: ubuntu-latest permissions: { contents: write, pull-requests: write } steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: shivammathur/setup-php@v2 with: { php-version: '8.5' } - run: composer install --no-interaction - run: vendor/bin/devtools workflows:inspect - run: vendor/bin/devtools ai:run --max-tasks=20 env: { ANTHROPIC_API_KEY: '${{ secrets.ANTHROPIC_API_KEY }}' } - uses: peter-evans/create-pull-request@v7 with: { title: 'docs: workflows written by ai:run', branch: devtools/ai-run }
Freshness keeps a merge that changed nothing documented from sending anything: no brief, no request. Knowledge of the stack
A page is never written without knowing how its framework works. .devtools/knowledge/<stack>-<major>.md
answers that — the request cycle, the extension mechanisms, where each kind of entry point is declared,
the traps — following a fixed canvas. A sheet is generic per stack and major version, so it is written
once and read by every project after that. DevTools looks for it in this order:
.devtools/knowledge/<key>.mdof the project — always wins, versioned, never overwritten;- the shared library, which every project on this machine feeds;
- the knowledge DevTools ships (Symfony 7 and 8 today);
- failing all three, the run writes a knowledge brief, Claude consults the framework's official documentation and writes the file, and the page briefs of that stack follow on the next run.
Whichever answers is copied into the project, which commits what it actually used.
Where the library lives, in order: DEVTOOLS_KNOWLEDGE_HOME, then <knowledge library="…"/> of
config.xml, then resources/knowledge/ of DevTools itself when it runs from a source checkout — the
folder grows — and otherwise $XDG_DATA_HOME/devtools/knowledge or ~/.devtools/knowledge. A package
installed under vendor/ is never written to.
vendor/bin/devtools knowledge:list # where the library is, and what it holds vendor/bin/devtools knowledge:promote angular-18 # copy a sheet into what DevTools ships, for a PR vendor/bin/devtools knowledge:amend symfony-8 # ask Claude to complete a sheet this project outgrew
A sheet can be amended, on demand — an inspection never asks for it, or a sheet would change at every
scan. knowledge:amend writes .devtools/pending/knowledge-amend.<key>.brief.xml: the current sheet, and
what the last inspection met without understanding it — the files no workflow references, the stacks
whose console could not be asked, those no native adapter reads. Claude rewrites the whole sheet;
workflows:apply checks it against the canvas and refuses it when a section shrinks — an amendment adds.
The project's sheet is replaced, and the library receives it as a revision (symfony-8.r2.md): the
original stays, library.xml carries both, and the next project reads the latest.
A sheet is deposited only after it passed the canvas, and a sheet already in the library is never
overwritten — correct it where it lives. <knowledge share="false"/>, or workflows:apply --no-share,
turns the deposit off; a library nobody can write to is a warning, not a failure.
Symfony projects
For a Symfony application, its own console is the source of truth: DevTools asks it six questions —
routes, commands, message handlers, event listeners, state machines, access_control — whatever the
size of the project, and reads what the console does not expose from the attributes: scheduled tasks,
Twig and Live components, migrations. Standalone or through the bundle, the same questions are asked.
What each workflow gets from it: the route's methods and path; its security, from the first matching
access_control rule, then #[IsGranted] and denyAccessUnlessGranted() — a constant of the project
read as the attribute it holds; its navigation, from redirectToRoute() and the path()
links of the templates it renders (not of their layout, whose links belong to every page); the state
machine it drives, found by Symfony's naming convention (WorkflowInterface $orderStateMachine →
order); the tests that request its path literally; and a dependency on every project listener of a
kernel.* event.
⚠️ The project must boot in its dev environment. In a Docker-first project, tell DevTools how to
reach the console:
<symfony console="docker compose exec -T php bin/console" env="dev"/>
The command is split into arguments and run without a shell. If the console cannot answer — dependencies
not installed, a kernel that does not boot, a deprecation printed before the JSON — DevTools falls back to
reading attributes, marks every workflow medium confidence, and says why at the top of the report.
Routes declared in YAML and services wired in configuration are invisible to that fallback.
Laravel
A Laravel project (laravel/framework 11 or later in composer.json) is read natively: Artisan is the
source of truth, the code completes it and stands in for it — three Artisan processes per scan, whatever
the size of the project.
| Type | Source | Identifier |
|---|---|---|
| routes | php artisan route:list --json: name, URI, methods, action, middleware — a package's routes left out |
orders.show → route.orders.show; a route without a name → route.<method>.<uri>: GET /about → route.get.about |
| commands | php artisan list --format=json, kept to the commands the project declares: app/Console/Commands, the closures of routes/console.php |
orders:import → command.orders.import |
| async | jobs — a ShouldQueue class with a handle() — and the schedule of routes/console.php or a console kernel, which depends on the command it runs |
async.send-order-confirmation, async.orders-import |
| ui | Livewire components and Blade class components, with their view | ui.cart-summary |
| data | database/migrations, database/seeders |
data.migrations, data.seeders |
A route's security is its middleware, by the alias the project writes — auth, can:view,order,
verified, signed — whether Artisan resolved them to classes or the route file names them; the policy a
can: asks for (can:view,order → app/Policies/OrderPolicy.php) is a file of the route. The views a
controller renders with view() or View::make() are followed through @extends, @include, <x-…> and
<livewire:…>, and the links of route('…') in them, and the routes a method redirects to, draw its
navigation — not those of its layout, which every page shares. A listener Laravel discovers is no
workflow (ADR-0043): it is a mechanism of the workflows that dispatch its event, read with event:list.
<laravel artisan="vendor/bin/sail artisan"/> <!-- or docker compose exec -T app php artisan -->
⚠️ Artisan boots the application: a service provider that queries the database at boot makes it fail.
DevTools then reads the route files — groups, prefixes and middleware followed — and the classes of the
project, marks every workflow medium confidence, and says why at the top of the report. A route a package
or a macro registers is invisible to that fallback.
PHP without a framework
A PHP project whose composer.json names no framework is mapped natively, with high confidence and no
AI for its facts:
| Type | Entry point | Identifier |
|---|---|---|
| routes | every .php file of the web directory (public/, web/, www/, or <php web-root>) |
public/orders/new.php → route.orders.new |
| commands | with symfony/console: every #[AsCommand] class, or Command subclass naming itself literally; otherwise the scripts of composer.json running a PHP file, its bin entries, the PHP files of bin/ |
command.import-orders |
| data | migrations/ |
data.migrations |
A page's navigation comes from its literal href, action and header('Location: …') to another page
of the web directory. The binary of a console application is not a workflow of its own: its commands are.
⚠️ A home-made router is one route. A single front controller dispatching a table of routes shows as
route.index, the rest under Not covered. Document it through Claude instead, by pinning the adapter in
stack.xml: <adapter locked="true">claude</adapter>.
Angular and Express
JavaScript and TypeScript are read with the project's own TypeScript compiler, through Node: one
process per scan, whatever the number of files, running resources/node/devtools-analyze.mjs — which has no
dependency of its own and loads typescript from the project's node_modules. It reports facts — imports
resolved with the project's tsconfig.json (paths, baseUrl), decorators and their literal arguments,
calls, new expressions, top-level values — and DevTools' adapters decide what an entry point is. It reads
nothing outside the project: an import that climbs out of it resolves to nothing.
⚠️ Node and TypeScript are needed on the machine that inspects: npm install --save-dev typescript
makes a JavaScript project readable. Without them, the stack goes to Claude (see below), the report says
why, and the inspection exits 1 — pin <adapter locked="true">claude</adapter> in stack.xml to make
that the intended path. With Node in a container, <node binary="docker compose exec -T front node"/>
needs the project mounted at the same path.
Angular — @angular/core in package.json, Ionic included:
| Type | Source | Identifier |
|---|---|---|
| routes | the Routes given to provideRouter() or RouterModule.forRoot(), through children, loadChildren and loadComponent; a route with children is their layout, a file of each |
orders/:id → route.orders.id; the empty path → route.root |
| ui | the components no route renders that at least two screens import — one screen's component is part of it | app-order-status-badge → ui.order-status-badge |
| integrations | the services injecting HttpClient, with the endpoints they call — method and URL, environment.apiUrl resolved |
OrderService → integration.order-service |
A route's security is its guards — canActivate, canMatch, inherited from its parents — and their
files are the route's. Its navigation is the routerLinks of its template, inline or templateUrl, and
its router.navigate() calls, matched against the table: ['/orders', order.id] reaches /orders/:id. A
lazy route whose import is not literal is documented with a medium confidence and a warning. Ionic's tabs
are child routes of their TabsPage.
Express — express in package.json, JavaScript or TypeScript, CommonJS or ESM:
| Type | Source | Identifier |
|---|---|---|
| routes | app.<verb>() and router.<verb>() with a literal path, the prefixes of the app.use() mounting each router concatenated |
GET /orders/:id → route.get.orders.id |
| commands | the bin entries of package.json and its scripts running a file of the project with Node — not the server main starts |
command.import-orders |
| async | BullMQ new Worker('<queue>', …), node-cron cron.schedule('<expression>', …) |
async.emails, async.nightly-report |
A route's security is the middleware named before its handler, and those its router is mounted with:
app.use('/admin', requireAuth, adminRouter) then router.delete('/orders/:id', requireAdmin, remove) is
requireAuth, requireAdmin. A route is declared where its handler is written — orderController.list in
the controller's file. A listener of the project's emitter (bus.on('order.created', …)) is a mechanism of
the workflows that emit that event (ADR-0043). A path computed at run time gives a medium confidence,
a router no application mounts a warning.
The documentation as a site
devtools export:site writes the pages, the menu and what they link to in .devtools/ — the stack, the
knowledge sheets, the overview — as a static HTML site that works offline: open its index.html, no
server needed. The Mermaid diagrams are drawn by the script copied into the site, never fetched from a CDN,
and a search box finds a workflow by its title, identifier or summary.
vendor/bin/devtools export:site # build/devtools-site/ vendor/bin/devtools export:site --output=public/docs # anywhere — but never into a directory DevTools did not write
Nothing a page holds runs in the site: Claude's prose is escaped, a link the site does not hold keeps its
text only. The directory is DevTools' own — a .devtools-site marker says so — and the next export replaces it
whole; a non-empty directory without the marker is refused rather than emptied. About a second for three
hundred workflows.
Projects without a native adapter
Next.js, Vue, Django, a Node application without a framework… any stack DevTools has no adapter for is
mapped by Claude Code, from files — never through an API. The first workflows:inspect writes a knowledge brief (what the stack
is, how a request flows through it); once workflows:apply has accepted it, the next run writes a
discovery brief: read the sources, list the entry points and the files each one traverses, in the same
XML model a native adapter produces.
workflows:apply does not trust that draft: every file must exist inside the stack's root, identifiers are
derived again and a collision is refused by name, and every workflow is forced to medium confidence with
source="claude" — the menu says so. The accepted result is kept in .devtools/discovery/<stack>.xml
(versioned): later runs read it like an adapter's output, so freshness works as for Symfony. A new file
nobody covers asks for a discovery limited to that file; a change to a covered file does not.
A monorepo gets one .devtools/: each stack is detected apart (a Symfony api/ through its console, an
Angular front/ through Claude), and identifiers stay unique across both.
Switching to a native adapter
A stack Claude documented, that a later version of DevTools reads natively — Laravel, Angular and Express since ADR-0030 to 0033 — is not switched silently: its identifiers may differ, and a page whose identifier goes is orphaned. Every inspection keeps it on the Claude path and lists what the switch would do:
laravel (.) is documented through Claude, and this version reads it natively (adapter laravel):
10 identifiers stay, 2 go (route.orders-list, …), 3 come (route.get.about, …). Inspect with --switch-adapter to switch.
workflows:inspect --switch-adapter switches: stack.xml names the native adapter, the discovery is
deleted, and the pages of the identifiers that went are orphaned — --prune deletes them, an alias in
config.xml keeps one under its old name.
Workflow identifiers
Every workflow has a stable, readable identifier that names its page and its tracking file. It is derived from the entry point, never numbered, so that it survives re-scans:
| Type | Derived from | Example |
|---|---|---|
| routes | the route name, app_ prefix removed, _ → . |
app_order_new → route.order.new |
| commands | the command name, : → . |
app:import-catalog → command.app.import-catalog |
| async | the message class, short name in kebab-case | OrderCreated → async.order-created |
| events | the listener class | LocaleListener → event.locale-listener |
| ui | the component class | CartSummary → ui.cart-summary |
| integrations | the integration name | stripe → integration.stripe |
| data | the family | data.migrations |
The page of route.order.new is .devtools/workflows/routes/order.new.md.
⚠️ Renaming a route renames its workflow, and the old page becomes orphaned. Two entry points
that derive the same identifier stop the scan with both of them named: DevTools never adds a numeric
suffix, because a suffix would depend on discovery order and change from one scan to the next. The
collision is resolved by an <alias> in .devtools/config.xml.
Renaming a workflow
Renaming a route renames its workflow, and an identifier that changes orphans its page: a new one would start
with no history, no scenario, no feedback, and cost a writing. workflows:rename carries the workflow over:
vendor/bin/devtools workflows:rename route.order.new route.order.create [--dry-run]
The page and the tracking file move, the history is kept with one revision more (renamed from route.order.new), and every reference follows — the other workflows' dependencies, the links of the pages,
the index, the file → workflows map, the scenarios and their baselines, the feedback, the rules, the
assertions, the journal, the glossary. No page is asked of Claude. A rename onto an existing workflow, from
one that does not exist, or across types is refused, and writes nothing; config.xml, the human's, is never
rewritten — a rename it mentions is said.
workflows:inspect spots the probable ones itself: a workflow gone and a new one of the same type that
share at least 80 % of their files are proposed as a rename, with the command to run. Never applied on that
guess alone — two histories merged by a false positive cannot be split again — unless you ask:
workflows:inspect --apply-renames.
Structure
bin/devtools standalone entry point
src/
├── Console/ the console application every command is registered in
├── Command/ init, stack:detect, workflows:inspect, workflows:diff, workflows:check,
│ workflows:impact, workflows:review, workflows:accept, workflows:reject,
│ workflows:rename, workflows:apply, git:install-hooks, git:uninstall-hooks, claude:install,
│ claude:uninstall, claude:hook, mcp:serve, scenarios:generate,
│ scenarios:run, baselines:accept, verify:prepare, verify:collect, gate,
│ feedback:add, feedback:list, feedback:resolve, rules:promote,
│ rules:activate, rules:list, rules:archive, assertions:propose,
│ review, retro, knowledge:list, knowledge:promote, knowledge:amend,
│ export:site, self-update, ai:run, ask
├── Project/ the project root, last guard against a path leaving it; the inspection lock;
│ the files of a project
├── Config/ .devtools/config.xml
├── Xml/ hardened loading and schema validation of every document read
├── Review/ accepting a changed fact, or refusing it and undoing the code; the review page
├── Stack/ stack detection from manifests, .devtools/stack.xml, knowledge of a stack
├── Inspection/
│ ├── Model/ the intermediate model every adapter produces, and its XML form
│ ├── Adapter/ entry points of a stack: Symfony, Laravel, Angular, Express, PHP without a framework,
│ │ the Claude path
│ ├── Node/ the Node bridge: one process reading JavaScript and TypeScript with the project's compiler
│ ├── Graph/ files a PHP entry point traverses — Twig and Blade views included — grouping, tests, coverage
│ ├── Freshness/ what changed since the documentation was written: git, then hashes
│ ├── Diff/ what changed in a workflow, fact by fact, grouped across workflows
│ └── InspectionPipeline the whole run of workflows:inspect
├── Tracking/ .devtools/: tracking files, index, file → workflows graph, init; renaming a workflow
├── Impact/ the workflows a change reaches, read from the graph
├── Claude/ the hooks Claude Code runs, and the configuration files DevTools merges into
├── Mcp/ the MCP server — the only directory that knows the MCP SDK
├── Scenario/ scenarios: their format, skeletons, briefs, drafts, what to review — and the
│ runner that replays them in Chrome, one class per action
├── Verification/ the folder a verifier judges a change on, and its verdict, checked
├── Gate/ the gate: its steps, its last passage
├── Feedback/ feedback as data: its categories, its files and index, the search for a recurrence
├── Rule/ rules: their format, globs, the brief and draft of a promotion, what each did
├── Assertion/ the mechanical checks of the rules: pattern, DOM, tool — and how they are run
├── Retro/ the journal of the tasks delivered, and the retrospective read from it
├── Glossary/ the project's terms: collected from the model, defined by Claude, given to the pages
├── Link/ a front and its API: their calls matched, links.xml, the pages and the impact across both
├── Ask/ the search index of the pages, the pages a question selects, the answer from them
├── Rendering/ pages, workflows.md and the overview diagram; the exported site
├── Release/ self-update: the latest release, its checksum and provenance verified
├── Ai/ briefs for Claude, validation and application of its drafts; ai:run and its
│ client of the Claude API, off unless a project turns it on
└── Bridge/Symfony/ DevToolsBundle — the require-dev wrapper — and ScenarioTestCase for PHPUnit
resources/schemas/ the XSD of every format DevTools reads or writes
resources/templates/ the files init writes
resources/prompts/ the versioned prompts Claude follows
resources/knowledge/ the embedded knowledge of each stack, and its canvas
resources/node/ the script the Node bridge runs, without a dependency of its own
resources/site/ the exported site's stylesheet, search and Mermaid
resources/feedback/ the closed list of feedback categories
resources/review/ the review page: its HTML, stylesheet, script, and the router of its server
resources/claude/ the Claude Code skills claude:install copies
tests/
├── Fixtures/projects/ real projects: Symfony 8.1, Symfony 7.4 with YAML, Laravel 13, PHP, Express 5 in JavaScript
│ and in TypeScript, Express 4 without TypeScript, Angular 22, Ionic tabs, monorepos,
│ an API and its mobile application side by side
├── Node/ the Node bridge's own tests, run by PHPUnit through `node --test`
├── Fixtures/drafts/ what Claude wrote for them, replayed by the tests
├── Fixtures/matrix/ the .devtools/ each of them must produce, without AI and written
├── EndToEnd/ the binary in a separate process: the matrix, both installations, the bundle
└── Performance/ the 300-route re-scan
.devtools/ this repository documented by DevTools itself
docs/specs.md the functional and technical specification
docs/adr/ the decisions, lot by lot
Nothing outside src/Bridge/Symfony/ may depend on symfony/http-kernel. That is what keeps the
standalone mode real rather than nominal.
Quality assurance
composer qa # cs-check + rector-check + phpstan (level max) + phpunit composer coverage # needs pcov or Xdebug: > 90 % of src/, 100 % of Freshness/, Diff/, Review/ and Tracking/ composer infection # mutation testing of Freshness/ and Tracking/: 96 % MSI at least, measured on 4 threads
The suite includes the end-to-end group: every fixture project goes through the binary in a temporary git
repository — documented without AI, re-inspected without a single change, written from the recorded drafts
of tests/Fixtures/drafts/, then changed and re-inspected with exactly the expected decisions. When an output
changes on purpose, re-record the snapshots with DEVTOOLS_UPDATE_SNAPSHOTS=1 vendor/bin/phpunit and review
the diff before committing it.
Measured
What specs § 8 asks of the MVP, as the suite measures it:
| Metric | Target | Measured |
|---|---|---|
| Workflows detected / real entry points | 100 % | 100 % on the six fixture projects (expected lists of the matrix) |
| Re-scan of a 300-route project without change, without AI | < 10 s | 0.7 s (tests/Performance/) |
| Useless rewrites | 0 % | 0 file modified by a second run, on the six projects and on this repository |
| Pages conforming to the template | 100 % | 100 % — every page of every fixture, written or not |
Run composer qa, not the single tool you have in mind: the CI's "Coding standards" job runs Rector
too, and its lowest deps job installs the minimum of every constraint — which is where this ecosystem
has repeatedly found what a local run could not. For DevTools, that job is also the one that exercises
Symfony 7.4, while highest exercises 8.x.
Unlike the other bundles of this ecosystem, this repository does not use Symfony Flex: its recipes
write an application skeleton into src/, which here is the core's own namespace. Keep it out of
require-dev.
Whatever you do, keep the code free of classes that exist on only one of the declared branches. A tool
promising ^7.4 || ^8.0 has to hold both.
License
DevTools is open-sourced software licensed under the MIT license.
© 2026 jul6art
