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: 39

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v2.2.0 2026-09-19 11:50 UTC

This package is auto-updated.

Last update: 2026-09-20 10:04:27 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 has its first half — what changed in a workflow (workflows:diff), the gate that fails on it (workflows:check), the review that accepts or refuses it (workflows:review), and the same drift shown by PHPStan. 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

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.

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.

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 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
Claude writes the drafts one draft per brief, in .devtools/pending/ drafts
devtools workflows:apply checks each draft and rebuilds its page pages, tracking
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

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 ten 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/flux 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  claude    angular-22   src/app
 ------ ------------ ------------- --------- ------------ -----------------------------

A monorepo gets one stack per directory holding a framework. A framework version comes from the lock file when there is one, from the constraint otherwise. The adapter says who finds the workflows: symfony 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 créés · 3 mis à jour · 69 inchangés · 0 orphelins
 ✔ 1 247 fichiers parcourus · 412 hachés · 3 processus git · 4 appels console
 ✔ 15 briefs écrits pour Claude · 152 Ko (~38 000 tokens estimés)
 ⚠ 1 repli statique (symfony @ .)

 Durée 12,4 s · mémoire 214 Mo · rapport .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

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. An inspection raises PHP's limit to 512 MB when it is lower, and says so — documenting a few hundred workflows needs it, and through the bundle the booted kernel is already in memory. A limit that is higher, or unlimited, is left alone.

--force naming a workflow that does not exist is a warning, not a silent no-op.

⚠️ An entry point that disappeared is marked orphelin and listed under À vérifier; 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   diff des workflows

 ⚠ 1 fait changé · 87 workflows
 731 fichiers parcourus · 532 hachés

 modifié décision 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 nouveau, one that no longer does disparu — 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   contrôle des workflows

 ✖ 2 causes

 route.order.export                        non documenté
 modifié attribut app_order_new#security   ROLE_USER → ROLE_ADMIN   3 workflows
Cause of failure
an entry point with no page non documenté
a page whose workflow is gone orphelin
a fact changed since the pages were written the fact, grouped, with its reach
--require-ai, a page never written by Claude jamais rédigé

⚠️ 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.

 ✔ rien à signaler · 731 fichiers parcourus
 3 workflows ont vu leur code changer sans qu'aucun fait ne bouge : leur prose peut être périmée (--strict pour en faire une 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:checkwarns 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:

 ✗ modifié décision App\Entity\User::email

 À lancer pour ramener le code :

   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 autoloadcomposer 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
<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 of the pages Claude writes; --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 "Non couvert" in the menu — the analysis says what it missed rather than pretending.

The pages

Every workflow page has the same ten 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
Navigation / states DevTools where the workflow leads, and its state machine
Decisions Claude, from the model
Data Claude
Cross-cutting mechanisms DevTools, enriched by Claude the listeners that run inside it, and what they write
Points of attention Claude
Related workflows DevTools dependencies and navigation, linked
History DevTools one line per rewrite, never rewritten

⚠️ 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.

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 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.

⚠️ 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.

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 ask Claude Code 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 — and records the model and the prompt version in the tracking file;
  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.

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

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]; 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 $orderStateMachineorder); 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.

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.phproute.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 Non couvert. Document it through Claude instead, by pinning the adapter in stack.xml: <adapter locked="true">claude</adapter>.

Projects without a native adapter

Express, Angular, Laravel, Django… 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.

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_newroute.order.new
commands the command name, :. app:import-catalogcommand.app.import-catalog
async the message class, short name in kebab-case OrderCreatedasync.order-created
events the listener class LocaleListenerevent.locale-listener
ui the component class CartSummaryui.cart-summary
integrations the integration name stripeintegration.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.

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:review, workflows:accept, workflows:reject, workflows:apply,
│                            git:install-hooks, git:uninstall-hooks, claude:install,
│                            knowledge:list, knowledge:promote
├── Project/                 the project root, last guard against a path leaving it; the inspection lock
├── 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
├── 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, PHP without a framework, the Claude path
│   ├── Graph/               files a PHP entry point traverses, 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
├── Rendering/               pages, workflows.md and the overview diagram
├── Ai/                      briefs for Claude, validation and application of its drafts
└── Bridge/Symfony/          DevToolsBundle — the require-dev wrapper, and nothing else
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/claude/            the Claude Code skill claude:install copies
tests/
├── Fixtures/projects/       six real projects: Symfony 8.1, Symfony 7.4 with YAML, PHP, Express, Angular, a monorepo
├── 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/ and Tracking/
composer infection     # mutation testing of Freshness/ and Tracking/, needs a coverage driver too

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