Search by

jul6art / devtools

jul6art

Workflow cartography and feedback loop for projects built with Claude Code

Package info

github.com/jul6art/devtools

Type:symfony-bundle

pkg:composer/jul6art/devtools

Statistics

Installs: 344

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v3.4.2 2026-10-30 13:10 UTC

README

logo dev in the hood

Workflow cartography and feedback loop for projects built with Claude Code

License Version

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:

  1. 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;
  2. 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;
  3. 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 Stop hook 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 in docs/specs.md and the decisions in docs/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:serve
  • symfony/panther ^2.4 and Chrome, optional — only to replay the scenarios, scenarios:run; ext-gd to 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 of vendor/ becomes a package with the version of composer.lock. A project autoloading through classmap, psr-0 or files gets 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-&gt;customer || null === $order-&gt;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 smoke skeleton — 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, and workflows:apply checks 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.xml records 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 until scenarios: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 its name, else one of its data-* 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:add records 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:accept does.

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 is promoted to 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="/&lt;select\b(?![^>]*data-controller=&quot;select2&quot;)/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:

  1. workflows:inspect writes .devtools/pending/page.<id>.brief.xml — the workflow's model, the current page, why it changed, the language, and the versioned prompt to follow;
  2. 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;
  3. workflows:apply checks 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 rewrites workflows.md so that the workflow is no longer listed as waiting for Claude — from what is on disk, without inspecting again;
  4. 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:

  1. .devtools/knowledge/<key>.md of the project — always wins, versioned, never overwritten;
  2. the shared library, which every project on this machine feeds;
  3. the knowledge DevTools ships (Symfony 7 and 8 today);
  4. 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