Changes per released version of the Architecture MCP server, newest first. Starts at 26.3.1; earlier versions shipped without release notes.
The prevention ledger is now meant to be committed, and says so to git itself. 26.5.10 suggested keeping .prevention.jsonl out of version control because an append-only file conflicts on every merge. That was solving the wrong half: the number worth quoting is the project's rather than one checkout's, and being tracked is the only thing that makes an edit to the ledger visible — ignoring it protects nothing, since the file is on disk either way. The merge problem has its own answer, and the server now writes it: the first time it creates the ledger it adds a .prevention.jsonl merge=union line to the .gitattributes beside it, so merging two branches that each recorded checks keeps both sides' entries. If you already added the ledger to your .gitignore on 26.5.10's advice, remove it. An existing attributes entry for the file is left alone.
.baselines/ now keeps itself out of git. The analysis state directory is machine-local and regenerates from your sources — megabytes of it on a real project — so when the server creates it inside a git working tree it writes a .gitignore containing * inside the directory. Your project's own .gitignore is never touched, and the marker travels correctly when --config_dir moves the state elsewhere. It is written once: if you delete it, it stays deleted. Existing projects already have the directory, so nothing changes for them — remove your manual .baselines/ entry only if you want to.
prevention_report tool to read it. Prevention has no evidence by construction — nobody notices the cycle that did not happen, and a satisfied user has nothing to point at when the renewal question comes. The server now keeps an append-only ledger at <configDir>/.prevention.jsonl of what it was asked and what happened next.You cannot write to it, and neither can your agent. Every entry is a side effect of a tool called for another reason. There is no record_prevention; a number the measured party can influence is worth nothing to the person being shown it.
It records outcomes, not just intentions. A warning on its own only says the agent asked. But the server rescans, so it can close the loop: a flagged pair that never appears in a later rescan's added dependencies was held; one where the component then checked a different target, got a clean answer and wrote that instead is redirected — the strongest evidence here; one that shows up anyway is overridden, reported as prominently as the rest, because a report that counts only its wins is an advertisement.
Read the activity figures first. dependenciesAdded, violationsAdded and the two cyclicity totals are the denominator: "41 proposals flagged" is unfalsifiable alone, while "3,412 dependencies added, no violation ever added, component cyclicity down 1,020, and 41 proposals flagged along the way" is a claim every term of which was measured.
The reply carries a caveat field stating the limit, and it should be passed on with the numbers: none of this is a counterfactual. held means a flagged proposal was not written — not that it would have been written without the warning.
The ledger starts empty, so a project that upgrades today reports zeroes until it is used, and it is per-checkout rather than committed (an append-only file written from several machines conflicts on every line).
check_proposed_dependency now also tells you what the dependency would do to the cycles. The tool answered one question — do the .arc rules permit this edge? — while the server asks two things of you, the second being that cyclicity may only fall. So you could ask permission, be told legal: true, and close a cycle with the next line you wrote. On a project with no .arc file it was worse than silent: every verdict there is UNCONSTRAINED, so the guardrail returned an unbroken series of green lights in exactly the configuration where it is your only protection.The reply now carries cycleImpact, computed from the dependency graph and independent of any rules. It reports whether the edge leaves the endpoints in a cycle together — newly formed, or an existing one grown to take them in — at component and at package granularity, separately, because two files in one package can close a component cycle without touching the package graph — and for each, how many nodes the new cycle would hold, how far the cyclicity metric would move, whether your tolerance policy excuses it, and which components or packages would be caught in it.
Two numbers are worth reading carefully. size is usually much larger than two: the new cycle absorbs everything on a path from the target back to the source, so a single import can fuse a whole subsystem. cyclicityDelta is discounted by the cycles the new one swallows — a 2-cycle scoring 4 taken into a 3-cycle scoring 9 moves the metric by 5, not 9 — and it is produced by assessing the graph with the edge through the same machinery that assesses it without, so it is precisely what rescan_sources will report if you write the dependency.
absorbs says what kind of mistake you are looking at. It lists the cycles that already exist and would be swallowed, largest first. A size of 43 whose absorbs names a 42 means you are adding one component to a blob that was there before you arrived; the same 43 with no absorbs at all means the code was acyclic and this edge is what tangles it. Those deserve different answers, and size alone cannot tell them apart — nor can size and cyclicityDelta together distinguish joining one tangle from fusing two. Absorbing a tolerated cycle discounts nothing, because it scores 0 today, so each entry carries its own tolerated flag.
A cycle your tolerance policy excuses is still reported, with cyclicityDelta: 0 and tolerated: true. Tolerance decides what the metric counts, not what is structurally true.
When no successful scan has happened yet, assessed is false and a reason says so. That is "cannot tell", not a clearance.
suggest_relocations now tells you when the thing blocking it is a cycle your own policy excuses. A component cycle spanning two packages welds them: no file move can separate them, so the whole group returns CUTS_REQUIRED with nothing evaluated. If that cycle is small enough for your tolerance threshold, it contributes nothing to componentCyclicity and list_cycles hides it unless you pass includeTolerated — while blocking every relocation in the group regardless. Tolerance decides what is counted; welding is what is structurally possible.The weld object now carries tolerated, and its reason names the metric that scores it zero. This matters because such a blocker is invisible to both ratchets, so driving the metrics down — which is what the product asks of you — will never lead you to it. On one project a tolerated two-component cycle withheld 224 points of package cyclicity reachable by file moves alone and took four sessions to find; on gradle, 16 of 26 welds are tolerated cycles. Cutting one is worth doing and you should expect the metrics not to move.
A proposed relation now says how much of it is a type-hierarchy change. inheritanceSites counts how many of a relation's dependency sites are an EXTENDS or IMPLEMENTS, present only when some are. A package relation aggregates, so "32 sites, one of them an extends" is the fact — and summed over a cut it answers the question sites alone cannot: how much of this work is retyping call sites and how much is reshaping types. On a real 11-relation, 248-site cut, six relations contain inheritance and ten sites actually are one. A flag would have overstated the hierarchy work sixfold, which is why this is a count, exactly like violatingSites.
A proposed relation now says when your rules cannot judge it. unconstrainedByRules is true when .arc rules are loaded and still have nothing to say about the relation, because every dependency behind it runs between components of the same artifact.
Loaded rules imply direction was considered, and violationEdges reinforces the impression. On the same real cut that was false comfort: all eleven relations lay inside one artifact, so not one of the project's 22 violations touched the cut — and seven of the eleven pointed the architecturally natural way, meaning a caller who applied the list would have improved cyclicity and worsened the design. Read the flag as: this is a cyclicity answer, not an architectural one. Judge direction yourself, or give your rules something to say about that region.
It is suppressed entirely when no .arc is loaded, where every relation would carry it and a caveat that is always true is not a signal.
analyze_cycle now enumerates the route it did not take. alternative.relations was an integer — how many relations the other route would cut — which made the better answer the one you could not inspect. On a real 18-package cycle the alternative reached cyclicity 24 against the chosen route's 121, with a contained residue against an uncontained 11-package cluster, and the reply said only "12 relations, 250 sites". Those 250 sites could be twelve mechanical import changes or two type-hierarchy rewrites, and nothing distinguished them; the recommendation could be passed to a user but not evaluated for them.It is now a list, each relation carrying from, to, sites, kinds and violatingSites — the same treatment inheritanceFreeCut already gave its edges, and for the same reason: cost is not effort. The nested components breakdown is deliberately omitted, since the route you are taking already provides it and the question about a road not taken is only what you would be touching. alternative also gained a projection, so a route that will not finish in one step says so.
Note the field changed type, from a number to an array of objects under the same name.
The file '<project>.csproj.nuget.dgspec.json' already exists. MSBuild derives some project paths from the argument it is handed and others from the working directory — and a POSIX getcwd() always reports the real path — so a solution under a symlink is seen under two names at once. NuGet treats one project as two, both write the same file, and whichever loses the race fails the restore. dotnet restore is now given a fully resolved path.On macOS this is not an exotic setup: /var is a symlink to /private/var, so every temporary directory has two names. A symlinked home or work directory is equally ordinary. Being a race it fires intermittently, which is what made it look like a network problem for a while.
analyze_cycle now offers the parent-package route for every package cycle, not only large ones. It was gated on size — above 18 packages — and that was wrong twice over.It made improving a project downgrade its advice. A cycle that shrank from 19 packages to 18 lost the condensed answer entirely, and the flat answer replacing it was worse in absolute terms than the flat answer for the bigger group. The threshold had been set to the quotient solver's reach (18) rather than to the flat solver's break/split point (16), so 17- and 18-package cycles were too big to break flat and too small to condense.
Fixing the boundary would not have been enough. The gate's premise — that a cycle the solver can break exactly needs no second option — confuses optimal with achievable. An exact break is optimal on cyclicity and says nothing about whether anyone will do it: on gradle, a 14-package cycle the solver breaks perfectly costs 80 dependency sites, while one condensed relation costs 7. Across gradle's flagged package cycles the condensed route was cheaper in 15 cases, the same in 7 and dearer in 3 — and most of the cheap ones were small enough for the flat answer to be exact.
So the step is now offered whenever there is a coarser grouping to climb to, and you compare its cost against the flat cut the way you already compare inheritanceFreeCut or the shear-versus-break alternative. Shaving a subsystem off the outside is frequently the step that gets landed; the perfect cut is the one that gets postponed.
analyze_cycle was omitting inheritanceFreeCut and cheapestSeam for any component cycle that spans packages — which is most large ones, and included the exact case inheritanceFreeCut was built for. A cycle spanning packages is answered through its package quotient by a separate code path, and that path built its reply through a convenience constructor that quietly defaulted both fields to null. Nothing was wrong in the reply, but two fields were missing from it.Both are now present there. The inheritance-free alternative is computed over the quotient, where that path actually makes its decision — an alternative reached some other way would be an alternative to a cut you were never offered. On jPOS's util ↔ space cycle the reply now offers NameRegistrar → TSpace at 11 sites (extraSites 6, same resulting cyclicity) against a proposed cut that severs JESpace extends Log. That is the architecturally right direction, and it was previously reachable only by noticing equivalentToChosen on the alternative.
interface was meant. A nested artifact is a region, and declaring one partitions its parent: the components you carve into it stop being part of the parent, so the API's own use of the subsystem's internals — which an API usually has — becomes a boundary crossing and needs its own route. An interface is a view over what the artifact already holds; the internals stay put and nothing is stranded.Both constructs answer "X has an API and internals" and only one of them survives the API depending on X. Getting it backwards is expensive precisely because the error message is a pile of ordinary-looking violations: on jPOS it turned 3 violations into 7, all shaped Container.QBeanApi → Container, and took a full authoring cycle to diagnose. The violations are genuine, so nothing else in the server had anything to say — and what the author needs to hear is not "these are illegal" but "you probably wanted the other construct". architectureWarnings now says it, naming the artifact and the dependencies.
explain_architecture_dsl carries the same distinction where the choice is actually made: the intent table now recommends interface for that intent and states why the nested form strands the parent, instead of offering both as if they were interchangeable.
explain_architecture_dsl answers "the base classes must not depend on their subclasses" with "you don't need a rule". It is not expressible in .arc — subclasses are spread across the whole framework, which the base classes legitimately use — but it is already guarded: a subclass EXTENDS its superclass, so any superclass → subclass edge closes a component cycle by construction and the cyclicity ratchet catches it. Previously an agent asked for this would have concluded the DSL was limited, when the invariant was covered all along by the other half of the product.
A cut step now says how many steps it is the FIRST of, and what they cost in total. Each packageStep / parentPackageStep carries projection — {steps, sites, complete} — obtained by following the tool's own recommendation forward until the quotient is acyclic.
This exists because a cheap step was unreadable without it. "1 relation, 2 sites, cyclicity 6400 → 6241, residue uncontained" reads as a nibble beside an alternative that finishes in one 657-site move — and is in fact step one of five totalling 670, a 1% premium for having the work in five reviewable pieces instead of one. Measured across jPOS and gradle the premium was 251 vs 248 sites, 498 vs 492, and 670 vs 657: the small steps converge, and nothing in the reply said so. Compare projection.sites against alternative.sites to make that trade with both numbers in hand — several small changes are usually easier to land than one big one, and this is what the small path really costs. complete: false means the projection was cut short, so both numbers are lower bounds and the path is not known to converge.
analyze_cycle already condenses a package-spanning component cycle to packages and cuts that instead — the step becomes a handful of package relations rather than dozens of unrelated edges. A package cycle got no such treatment, on the reasoning that it was "already in package terms". 84 package terms is not an answer either, and the package hierarchy offers another rung.For a package cycle above 18 packages the reply now carries parentPackageStep: the packages grouped by their first groupDepth path segments, and a cut of that graph. Measured, this is not marginal. jPOS's 19-package cycle went from a 22-relation, 424-site shear to a 16-relation, 260-site break reaching cyclicity 28 with a contained residue; gradle's 84-package cycle went from 100 relations and 1057 sites to 6 relations and 492. Every oversized package cycle in both calibration projects condensed into exact-solver range, and every condensed break left a contained residue — meaning what remains are self-contained knots inside single groups, each solvable on its own, rather than the same tangle one size smaller. Read residueContained; it is the property the cyclicity numbers hide.
analyze_cycle reports the cheapestSeam: the thinnest single relation whose removal decomposes the group at all. This is a different question from the proposed cut, not a rival answer to it. The cut optimises cyclicity gain per dependency site and discards seams below a 20% improvement bar — so a one-site relation that peels one package off an 84-package group, a one-file fix and often the only place a human can start, is precisely what it throws away. On gradle, 54 of 56 flagged package cycles have such a seam, and in 14 the proposed cut costs five times more or worse.It reads usefully in the negative too: when the seam is somewhere else entirely, the relation you were about to refactor was never what held the group together.
analyze_cycle now offers an inheritance-free alternative when its proposed cut severs one. Severing an EXTENDS or IMPLEMENTS relation means changing a type hierarchy, which is a different kind of work from deleting call sites — and the reply used to present the cheapest cut as if the only question were how many dependency sites it touched. When the proposed cut severs inheritance, the reply now carries inheritanceFreeCut with a second cut that does not, its sites, its cyclicityAfter, and extraSites — what avoiding the hierarchy costs.It offers rather than decides because nothing else honestly can. We measured it: across gradle's 38 flagged component cycles the cheapest cut severed inheritance in 10, an inheritance-free cut existed in all 10, and the extra cost was −36, 0, 0, 2, 2, 4, 10, 33, 90 and 430 sites. Six were nearly free and three were prohibitive, two orders of magnitude apart, so no weighting could have been right for both. Read extraSites and decide against a codebase only you know. A negative value means the alternative is also cheaper — take it, there is no trade-off; that happens because a large group is split heuristically rather than solved, so constraining the search can find a better answer. The field is absent when the proposed cut touches no inheritance.
Where no inheritance-free cut can exist — the inheritance edges close a loop by themselves, so breaking it must cut one of them — nothing is offered rather than something impossible.
analyze_cycle says when its two package-step routes are equivalent. The alternative object gained equivalentToChosen: true when both routes reach the same cyclicity, the same package quotient and the same residue shape, and differ only in which dependencies they cut. On jPOS exactly that happened and the caller had to notice it by comparing six numbers, which it did not. A choice between equals is not a recommendation, and it should not read like one.
The server now checks your .arc files for cycles in the RULES. An architecture whose own artifact graph is cyclic used to compile without a word, and check_proposed_dependency would answer ALLOWED for the very edge that closed the loop — the one blind spot least forgivable in a tool whose central claim is that dependency cycles are debt, since a ruleset that is itself tangled cannot make an honest statement about tangles in your code. Ruleset cycles are now reported in architectureWarnings, naming each artifact with the file and line it is declared at.
The cause is almost always a connect to aimed at an artifact declared above it. That is not illegal by itself — two plain artifacts with a single upward edge are acyclic and nothing is reported. It becomes a cycle once something grants access back down, which the implicit grants usually already do: strict reaches the next sibling beneath, relaxed every sibling beneath, public is granted to the siblings above it. All three point downward, so one upward connect is all it takes. unrestricted is the only modifier that reaches upward, and it is exempt: it is a declared quarantine, and a cycle whose closing edge nobody wrote is that hatch doing its job rather than a finding. Do not silence a real one by reaching for it.
apply clones a slice into the host file, so a cyclic slice applied in ten domains would otherwise be ten copies of one problem with one fix. Those are reported once, in the slice's own vocabulary, listing the apply sites that brought them in.
explain_architecture_dsl says this too, where an author is standing when they write the offending line: sibling order is now stated as a direction rather than a layout convention, with the consequence of pointing against it spelled out; connect to Parent.Inner carries the same caveat, since narrowing the target does not change its direction; and a new rule 10 says which grants point which way.
list_baseline_changes — the full diff between a saved baseline and the current model. It serves the entries behind the sinceSessionBaseline counts (see below), and it takes an optional name, so you can ask "how does this compare to the baseline our CI gate uses?" against any saved baseline without activating it. Until now the only way to compare against a different reference point was switch_baseline, which answers the question by moving the ratchet's anchor — a read with a side effect.A package relation is no longer treated as an architecture violation just because it CONTAINS one. At package granularity a relation aggregates many component pairs, and the flag was set if any of them broke a rule. Violation edges are removed first and for free, so q2 → util — 121 dependency sites, 24 of them violations — was reported as costing nothing to cut. That is not a labelling quirk: it told the caller that tearing out 97 sites of legal traffic was free. A relation is now flagged only when all of it breaks a rule, and a mixture is an ordinary cut carrying violatingSites (present only when it really is a mixture). Cut costs price the legal share, since the violating share was going to be removed anyway. Component granularity is unaffected — a violation is keyed on (from, to, arcFile), so a component pair is wholly one or the other.
analyze_cycle's description now says what its weight does and does not measure. The cut minimises dependency SITES, which is not the same thing as architectural wrongness: the cheapest cut may be the wrong direction to cut, leaving a lower-level package depending on a higher-level one, and an agent following it literally improves the metric while making the design worse. The description now says so, and says the thing that fixes it — with .arc rules loaded the server can judge direction for you, because violationEdges are then known inversions and are removed first. Authoring rules improves the cut proposals, not just the reporting; that was true before and invisible.
rescan_sources and reload_all now report sinceSessionBaseline as counts and deltas, not lists. The two cyclicity objects are unchanged ({baseline, current, delta} — they are the ratchet); everything else is a count: addedViolationsCount, removedViolationsCount, problematicAddedDependenciesCount and the eight cycle-membership counts. The reason is that this block only ever grows: it measures net change since a fixed point, so the longer a session runs the larger it gets, and it was re-sent in full on every scan. On a measured external session it was ~22 KB per call — a third of everything the server said across the run — and never read. sinceLastRescan, the view that attributes a change to your last edit and the one to watch in a fix loop, is unchanged and still complete. Call list_baseline_changes when one of the new counts turns out to be worth reading.
removedViolationsByArcFile is capped at 50, with removedViolationsTotal alongside, the same treatment problematicAddedDependencies already had (the cap counts violation rows, not .arc-file groups). Reverting one experimental .arc layer produced 70 entries and 19 KB in a single reply. Neither cap can hide a regression: addedViolationsByArcFile is computed separately and is never capped.
Violations are now grouped by the .arc file whose rules they break. list_violations returns violationsByArcFile — [{arcFile, violations[]}], sorted by file — and the rescan diff uses the same shape for addedViolationsByArcFile / removedViolationsByArcFile. The .arc path is part of a violation's identity rather than a property of it: files are evaluated independently, so the same dependency denied by two files is genuinely two violations. Repeating that path on every row restated a grouping the data already had: on a measured session with one .arc loaded, rows ran ~280 bytes of which the repeated path was ~56 — a fifth of the one payload that has to be read in full, spent restating a constant. verdict stays on the row on purpose: it varies within a file (an ordinary DENIED_BY_RULES next to a DENIED_BY_TO_DEPRECATED is the normal state mid-migration) and it changes what you should do.
Two reply-shape changes, both deliberate breaks rather than additions.
Violation lists are grouped. list_violations no longer returns violations; it returns violationsByArcFile, whose entries are {arcFile, violations[]}. The rows inside no longer carry arcFile — it is on the group. The rescan diff's addedViolations / removedViolations became addedViolationsByArcFile / removedViolationsByArcFile with the same shape. The keys were renamed rather than reused so that a reader written against the old shape finds nothing at the old key, instead of silently reading undefined off a row that changed underneath it.
diff.sinceSessionBaseline is now counts. Anything reading violation, dependency or cycle-membership arrays under that key will not find them — the fields are addedViolationsCount, removedViolationsCount, problematicAddedDependenciesCount and …CyclesJoinedCount/…CyclesLeftCount, and the entries come from list_baseline_changes. The Count suffix is deliberate for the same reason: it keeps one field name from meaning an array under sinceLastRescan and an integer under sinceSessionBaseline. Nothing else under sinceLastRescan changed, beyond the added removedViolationsTotal.
If your CLAUDE.md quotes the rescan obligation, the field to check is now addedViolationsByArcFile; generate_config's agentInstructions paragraph says so already.
list_violations, list_cycles, query_dependencies and check_proposed_dependency now tell you when their answer is out of date. Each reply carries sourcesStale, true when a source file has been added, removed or edited since the model was last parsed. Before this, an agent that edited code and then asked for the violation queue was told "nothing wrong" about the code as it stood before its own edits, with nothing in the reply indicating that. arcRulesStale already did this for edited .arc files; this is the same signal for the other input. Run rescan_sources to clear it.
generate_config hands back a paragraph for your project's CLAUDE.md. Its reply has a new agentInstructions field: a short markdown block stating the rescan obligation, the check_proposed_dependency habit, and the rule against grepping for dependencies. Some MCP clients truncate a server's instructions or fetch tool descriptions only on demand, and then nothing in an agent's context says this server exists at all — your own instructions file is the one channel that always reaches it. The tool offers the text and never writes into your repository.
explain_architecture_dsl now documents the C# and Python attribute retrievers. The reference covered the six Java ones and mentioned Python's two only in passing, and said nothing at all about C#'s CSharpTypeOf, CSharpExtendsClass, CSharpImplementsInterface, CSharpIsClass, CSharpIsInterface and CSharpIsEnum — so there was no way to discover them from the server. It also now states, where component names are introduced, that a component's package is its DIRECTORY: in C# that need not match the namespace, which is the single thing most likely to surprise a C# reader writing rules.
.arc file quietly meant two different things depending on which tool read it. The parser is still ADDRESSED by project name (project in the configuration); only the label changed, which costs nothing because the projection has always joined on project and never on name.The two names differ only where a .csproj sets <AssemblyName> — nhibernate-core's NHibernate.Tool.HbmXsd builds plain HbmXsd, and that module is now called HbmXsd.
Breaking for such solutions. Regenerating the configuration renames those modules and re-keys every componentId under them: existing baselines stop matching, and .arc rules that name the old module name go inert until updated. Solutions where every project builds an assembly of its own name — the common case — are unaffected. Where two projects build the SAME assembly there is now one module name for both; the later project is dropped with a warning, which is what Sonargraph does with that solution too.
rescan_sources said to call it after you "create, rename, or delete" a file, and never mentioned editing one. Its rationale was framed entirely around new componentIds being indexed, which reads as "this is for new files". Editing an existing file changes what it depends on, and therefore which violations and cycles exist, so the description now leads with the obligation to rescan after ANY source change and says plainly that a passing build and green tests do not establish architectural correctness. list_violations and list_cycles now say that their answers are as of the last scan and to rescan first if sources have changed since.
The operating rules were being truncated out of the server instructions before an agent read them. The instructions ran 17,920 characters with the LANGUAGES and C# primers first and the three RULES — the binding ones about the violation and cyclicity ratchets — starting at character 4,662. A client that truncates the instruction block therefore delivered the language detail and dropped the rules, which is precisely backwards; one was observed cutting at roughly character 2,442, mid-sentence in the C# primer.
The instructions are now 4,683 characters and the rules start at character 615. What went was reference material, which had no business in the one channel clients truncate: the tool catalogue became a compact index naming every tool, and the per-language primers moved to where they are read on demand — the retrievers and the C# directories-not-namespaces rule into explain_architecture_dsl, and the prerequisites (.NET 10 SDK; Python 3.10 or newer and the interpreter field) into generate_config's description, which had never mentioned C# at all. Nothing binding was dropped.
list_cycles no longer tells your agent to break cycles by hand. Its description claimed the server "does not compute a minimum feedback set, so use judgement" — which was wrong, and was read at the exact moment an agent decides what to do about a cycle. It now routes instead: suggest_relocations first for a package cycle (a file move costs an import update, not a refactoring), analyze_cycle for a component cycle or when relocation reports CUTS_REQUIRED. It also states what analyze_cycle actually returns — an exact minimum-weight cut on a small group, a shrinking split on a large one, iterated until groups are small enough to solve exactly — and that the goal is to drive cyclicity down rather than reach zero.In practice agents were reaching for analyze_cycle on package cycles and never calling suggest_relocations at all, so the cheapest repair was routinely skipped.
locate_fqn now reports EVERY component declaring a name, and its reply shape changed. The top-level filterName and perFile moved down one level into a new components array:// before // now
{ "fqn": "...", { "fqn": "...",
"filterName": "Mod/A/Foo", "components": [
"perFile": [ ... ] } { "filterName": "Mod/A/Foo", "perFile": [ ... ] } ] }
One name can legitimately belong to several components: a C# partial type is one type written across several files, and the halves can be assigned to different artifacts — one under Domain/ and another under Generated/ is normal. Answering with one of them was answering a different question. Expect a single entry for most types and do not assume exactly one.
query_dependencies replies with components (a list) instead of component, for the same reason, and it no longer refuses a name owned by several. It answers for all of them as one: the results are the union of what the parts reach, and the parts are not reported as depending on each other. trace_dependency is unchanged in shape — it now searches every pair and reports the endpoints of the shortest path it found.
duplicateFqns no longer reports a C# partial type. If you were filtering those out yourself, you can stop. On nhibernate-core this was 401 of 2429 top-level names, so for a C# project the signal was almost entirely noise. Java and Python are unchanged: two modules shipping one name really is a collision there, and is still reported.
"language": "csharp" in zugel.json, or just call generate_config — it detects a solution and writes that configuration itself. Violations, cycles, the baseline ratchet and the dependency queries then run on a C# model exactly as they do for Java.What you need on the machine is the .NET 10 SDK — the SDK, not just the runtime, because solutions are opened through MSBuild. The parser itself ships inside the jar, so nothing is downloaded and it works offline. If the solution's NuGet packages have never been restored, the first scan restores them for you rather than telling you to.
A solution file is required, and generate_config records which one it chose. Where a repository holds several — nhibernate-core has three — the choice is written to project.solution and reported, so pointing it at a different one is an edit rather than a mystery.
The one thing that surprises C# developers: .arc rules address directories, not namespaces. A component is a source file and its package is the folder it sits in, relative to the project directory — the same rule as Java. C# does not require namespaces to follow folders, and where a codebase's don't, the rules follow the folders. So an artifact is written include "MyApp/Services/**", not include "MyApp.Services.**".
An external component is External/<assembly>/<namespace>/<type> — for example External/System.Collections/System/Collections/Generic/List<T>. The assembly is part of the name because it is the unit of external identity in .NET and a namespace does not imply one: NHibernate's Antlr.Runtime.* types come from Antlr3.Runtime.dll. It is a single segment, so External/System.Collections/** names everything from one assembly. Generic parameters are part of the name — arity is part of a .NET type's identity, so Task and Task<TResult> are different components — and a nested type has none of its own: a dependency on HashSet<T>.Enumerator lands on HashSet<T>.
Six attribute retrievers, the same ones Sonargraph offers and with the same semantics: CSharpTypeOf, CSharpExtendsClass, CSharpImplementsInterface, CSharpIsClass, CSharpIsInterface and CSharpIsEnum. They assign components by what a type is rather than by where it sits — include "CSharpImplementsInterface: **.IRepository" gathers every repository however each one is named. They match dotted type names, so a single * stops at a dot.
Two more things worth knowing before you write rules. A project that targets several frameworks is analysed under exactly one of them — the newest, recorded in the configuration — and componentIds never mention it, so adding a target framework cannot invalidate a baseline. And a project that declares IsTestProject is not part of the model at all: its cycles and violations would be noise against what the production code owes, so no rule can govern the test-to-production direction.
"language": "python" in zugel.json, or just call generate_config — it detects a Python project and writes that configuration itself. Everything else works as it does for Java: violations, cycles, the baseline ratchet, and the dependency queries all run on a Python model.There is no build system to ask, so the structure comes from the layout. A virtualenv tells you where packages are installed and never where the sources are, so poetry, uv, pdm and conda have nothing to contribute here. Every pyproject.toml is one module — named after the distribution it declares — with its source roots taken from setuptools, hatch or poetry metadata, or from src-vs-flat convention when the metadata says nothing. A project with no pyproject.toml at all is still configured from its directories.
Nothing has to be installed first. An import names its own target, so a freshly cloned repository with no environment set up yields a complete, governable model. What you do need is a Python 3 interpreter the server can find — it looks for python3, then python, and on Windows the py launcher first. Prefer 3.10 or newer: Python's own parser only understands the syntax of its own release, so an older interpreter reports a project's modern syntax as errors in your source. To pin a specific one, add "interpreter" to the project section.
Writing .arc rules for Python. A componentId is module/path/to/file — the source file's location under its source root, with no extension. locate_fqn works as it does for Java, and resolves three kinds of name to a component: a class, a module-level function, and the module itself — pkg.archive names the file, which is what an import writes.
Python has two attribute retrievers of its own. PythonTypeOf matches any direct or indirect base class, so include "PythonTypeOf: pydantic.BaseModel" gathers a DTO layer, and PythonTypeOf: airflow.sdk.bases.operator.BaseOperator an operator layer — roles no naming convention identifies. Where Java needs four hierarchy retrievers, Python needs one: it has no class/interface split, so an ABC or a Protocol is simply another base. PythonHasDecorator matches any decorator on a class or a function — function decorators being where the signal usually is — and matches either spelling: the written app.get, whose head is an instance and resolves to nothing, or the resolved airflow.decorators.task, which keeps matching after import task as t. The Java retrievers do not apply, and naming one in a Python project fails at rule-compile time rather than silently matching nothing.
One limitation: the hierarchy walk stops at the project's edge, since installed packages are not parsed. Deriving from BaseModel is seen; deriving from a third-party class that itself derives from BaseModel is not.
Generated code is declared by pattern rather than by root. Java points generatedSourceRoots at a directory; Python has no such directory, because a module's dotted name is its path from the source root — anything importable as pkg.api.models has to live at pkg/api/models.py, beside the hand-written code. So declare "generatedPatterns": ["**/*_pb2.py"] in the project section instead. Matching files get the same treatment generated Java gets: cycles among them, and violations originating in them, are excused. generate_config seeds the protobuf globs when your project actually has such files.
One configuration covers one language. A repository that is genuinely both needs a server per language, each with its own --config_dir.
server_version reporting "no update staged or pending" when what it really meant was "we have not looked since before that version was published". The startup check is now obligatory, and the interval does what it was wanted for: keeping a session that stays open for days from missing a release. Updating is otherwise unchanged: the download runs in the background and the new version activates on the next restart, and --launcher.no_update_check still switches the whole thing off.Getting it needs the new launcher, which does not update itself — re-download zugel-launcher.jar if you want this.
generate_config bootstraps Bazel workspaces. Maven, Gradle and Bazel are all detected now, and each build system present is tried in turn until one answers. Bazel is tried last on purpose: where a repository carries both a MODULE.bazel and a Gradle build, the Gradle build is usually the authoritative one, and a Bazel-only workspace has nothing ahead of it in the list anyway.A Bazel module is one SOURCE ROOT, not one target. Bazel has no unit corresponding to a Maven module — its unit is the target, at whatever granularity the build author found convenient — so a workspace compiling 325 targets out of three directories would otherwise be modelled as 325 modules nobody on that project would recognise. Source roots are derived from each file's declared package rather than from directory names, because Bazel workspaces do not follow the src/main/java convention and often have no src anywhere.
Generated java comes along: the source jars produced by genrule, java_proto_library, java_grpc_library and the like are unpacked under <config dir>/.zugel-bazel/generated-sources/, which wants a .gitignore entry — the tool says so. Those components are flagged generated, so they are already excused from cycles and violations.
One caveat, and generate_config warns about it: a Bazel-generated zugel.json is machine-specific. Every jar Bazel reports lives under bazel-out/<platform>-<mode>/…, so a configuration generated on macOS does not resolve on Linux, and bazel clean deletes the entire tree it points at. Regenerate it rather than committing it — that is cheap once Bazel's cache is warm. If you do scan against a stale one, the classpath warning below now tells you instead of letting the model quietly shrink.
Verified on Windows as well as macOS, on every route Bazel installs by there — Bazelisk via npm, winget, Chocolatey and Scoop. One Windows prerequisite is not ours to fix and worth knowing: a workspace with Maven dependencies needs BAZEL_SH pointing at a bash (Git Bash will do), or rules_jvm_external's fetch fails before we ever see it.
unrestricted did nothing when the artifact was declared last — the position it is usually written in. An unrestricted artifact may depend on any of its siblings, above it or below it. The connectors that grant this were generated only for artifacts that had another sibling after them, so the last artifact in a body got none at all and every dependency it had was reported as a violation. That is the catch-all shape the modifier exists for: a Spring-style unrestricted public artifact Other { include "**" } closing the file reported violations on everything it touched. The guard belonged to strict alone, which reaches only the next sibling down; relaxed and unrestricted are unaffected in every other position, and neither strict nor relaxed changes behaviour at all.These were false violations, so your next scan may show fewer. If you changed code, or loosened an .arc file, to satisfy one of them, that change can be reverted.
A classpath entry that no longer exists is now reported, instead of quietly shrinking your model. Every scan (rescan_sources, reload_all) now names in configWarnings any module whose configured classpath entries are not on disk, with how many are missing and what to do about it. This used to be completely silent: the sources still parse, so there was no warning and no error — and the only casualty was every dependency into those libraries, which means violations and cycles running through them could not be seen either. A mvn clean over a sibling module's jar, a checked-in libs/ directory that moved, or a hand-edited configuration are the ways in. Re-running generate_config restores them. A project whose classpath is intact is unaffected.
query_dependencies now tells the truth about how complete its answer is. completeness was wrong in both directions. It counted the language's own constructs as unresolved — a type variable (T, RespT) and the supertype of every new Something() { … }, though both resolve fine and neither names a file — so answers were reported as incomplete when nothing was missing. And it missed references it had nothing to report them by: a static call on a type whose jar is gone (StringUtils.capitalize(x)) or an unresolvable import left no trace at all, so answers that really were missing dependencies claimed to be complete. Both are fixed, and an unresolvable import is reported by its whole name rather than the package prefix the compiler gives.
The same gap dropped dependency edges: new SomeProjectClass() { … } recorded no NEW edge, so that coupling was invisible to your architecture rules. It is visible now, so a violation this was hiding may appear on your next scan — the rule catching something real, not a new restriction.
server_version no longer announces an update that was never downloaded. It counted a version as staged when the launcher's cache merely held a directory for it — which the launcher creates as soon as it fetches that version's release notes, or begins staging the download. A restart was therefore advised for a build that did not exist, and the launcher, which checks for the jar itself, went on correctly running the previous version. A version now counts only when a verified jar is present. Release notes are unaffected: they are deliberately cached for versions whose jar will never be fetched, so that a pinned or out-of-maintenance user can still find out what an update contains.
A failed update check is now reported instead of vanishing. The launcher's explanation goes to stderr, and MCP clients stop capturing stderr once the handshake completes — so the reason a download never landed reached nobody, and the symptom was a version that silently never changed. Launcher 1.0.2 records the failure to a file; server_version reports it as update.lastCheckFailed. Older launchers do not write it, which reads as nothing to report.
.arc patterns. An include/exclude can now match something other than a component's name by naming a retriever first — include "JavaImplementsInterface: **.Controller" gathers every class whose ancestry reaches that interface, wherever it is declared and whatever it is called. This is the case naming conventions cannot express: generated or framework-driven code that carries its role in an annotation or a contract rather than in its name. Seven are available for Java:| Retriever | Matches |
|---|---|
JavaIsClass | any type that is a class |
JavaIsInterface | any type that is an interface |
JavaExtendsClass | any direct or indirect base class of a non-interface type |
JavaImplementsInterface | any interface implemented by a non-interface type, transitively |
JavaExtendsImplementsInterface | as above, but interfaces extending interfaces count too |
JavaTypeOf | any direct or indirect base type, class or interface |
JavaHasAnnotation | any annotation on the type |
Wildcards work on fully qualified names, so * stops at a dot and ** does not: **.Controller matches the interface in any package, *.Controller in one package level only. A type may carry several values (a class implements many interfaces) and matching any one of them matches.
As in Sonargraph, only the component's main type is considered — the one named like the file — and external components match nothing. The walk up a hierarchy stops at the first type outside the analysed sources: its name still counts, but nothing above it does. A retriever name the configured language does not provide is a rule-compile error rather than a pattern that silently matches nothing.
Two Sonargraph retrievers are deliberately absent: JavaHasAnnotationValue, which needs annotation property values, and JavaBelongsToAggregateRoot, which needs generic type arguments. JavaHasAnnotation differs from Sonargraph's in one way on purpose: it sees annotations on the type itself, not on its fields and methods, so a detail of one member cannot decide the whole component's artifact.
explain_architecture_dsl covers them, so an agent asked to express a grouping that no name pattern can capture will find them without being told they exist.
locate_fqn, query_dependencies and trace_dependency. Passing com.example.Outer.Inner previously missed the FQN index and fell through to reporting the type as External/com/example/Outer/Inner with status UNASSIGNED_UNKNOWN — an internal, assigned type described as an unassigned external one, which an agent could reasonably read as "no artifact governs this target". It now resolves to the component declaring the enclosing type, since a component is a file. Pass nested names source-level with dots; the binary Outer$Inner form is still not recognised.A component's contains list consequently holds every declared type rather than only the top-level ones. Duplicate-FQN reporting is unaffected on purpose: nested types are excluded from it, because no dependency edge ever resolves to a nested name (edge targets are normalized to the enclosing top-level type), and a nested FQN can only collide when its enclosing type already has.
generate_config no longer gives up when the first build system fails. On a project carrying both a Maven and a Gradle build — a Tycho pom.xml beside a Gradle build, for instance — a Maven failure now falls back to Gradle instead of aborting. Maven is still preferred when both are present and healthy. The fallback is reported in warnings, so a Gradle-derived module structure never appears without an explanation, and if nothing can configure the project the error names every build system tried rather than only the last.locate_fqn answers differently for a nested type. It used to report com.example.Outer.Inner as External/com/example/Outer/Inner with status UNASSIGNED_UNKNOWN; it now resolves to the component declaring Outer. If you built anything on the old answer — stripping the nested part before asking, or treating that status as "not ours" — it can go.zugel.json; the server jar is Zugel-<version>.jar; the MCP server reports itself as zugel.architecture-mcp.json if no zugel.json is present, and the launcher renames the file for you the first time it starts a 26.3.4 or newer server. It never renames when a zugel.json already exists, and never when the server it is about to start is older than 26.3.4 and could not read the new name..mcp.json to zugel if you want the tools to appear as mcp__zugel__*; the old key keeps working.release_notes — the server's own release notes, per version, read from the copy inside the running jar. It pairs with server_version: that answers which build you are talking to, this answers what is in it. Call it with no arguments for the running version, since for everything released after a version you were on, or version for one in particular.upgrading field when that release has one: what it does TO you — a forced re-parse, a changed reply shape, a moved configuration key — as opposed to what it offers. It stays in the section text as well, so nothing is lost by reading only notes.generate_config writes JSON arrays with one entry per line, instead of putting a module's entire classpath on one line. Existing files keep working; re-run generate_config to reflow the current one.generate_config to pick up moduleRoot. Older configurations keep working without it.remove_baseline returns removed as an array of names in both modes, previously a bare string. Everything else is additive.moduleFilter in zugel.json — includes/excludes patterns selecting which modules generate_config writes into the project section, replacing the flat exclusion list. Patterns use the product's wildcard syntax (**, * within one dot-separated segment, ?) against the whole module name. generate_config gains includeModules, excludeModules and removeModulePatterns, all additive against what is persisted.moduleRoot per module — the module's own directory. Lets the loader settle a source root two modules claim, by the same rule the generators use.configWarnings on every scan: source root that is not a directory, dependsOn naming no configured module, source root claimed by two modules, module that indexed nothing. Warnings only — no scan fails and no configuration is rewritten.architectureWarnings on every scan: non-optional artifacts that matched no component, and deprecated artifacts that still hold components.baselineDrift on scans, at startup, and on all four baseline tools — baselines that have outlived the configuration they were captured under. While the active baseline has drifted, diff.sinceSessionBaseline is withheld. Adding a module does not drift a baseline; removing one or moving its source roots does.status: UNVERIFIABLE — no recorded scope to check. Their diffs are still served, and they are not deleted by the bulk cleanup.remove_baseline {"drifted": true} deletes every drifted baseline in one call. The active baseline is skipped rather than failing the call.server_version — reports the running build. The same version is now used for the startup log line and for serverInfo.version in the handshake (previously a hardcoded 1.0.0).generate_config reply carries a rescan object with the same payload rescan_sources returns, plus an arcFilesLoaded count. generate_config was always a reload; a following reload_all is unnecessary.diff.addedDependencies is now diff.problematicAddedDependencies and lists only new couplings that break a rule, sit inside a flagged cycle, or sit inside a newly created tolerated cycle. Ordered violations first, then cycle members, capped at 50, with problematicAddedDependenciesTotal giving the real count. Edges of pre-existing tolerated cycles and ordinary new dependencies are no longer listed. addedViolations is never capped.list_artifact_components returns a declared-but-empty artifact instead of Artifact not found; "matched nothing" and "never declared" are now separate states.list_reachable_components still excludes externals.moduleRoot is recorded, instead of by scan order. Without that evidence the collision is reported, as before.generate_config no longer reports "no .arc files configured (cycles-only mode)" when the project has .arc files.ecj does not close the classpath jars it opens. The open set does not accumulate across rescans (macOS and Windows). On Windows those jars stay locked while the server idles until a garbage collection releases them. Generated configurations put only immutable cache jars under ~/.m2 and ~/.gradle on the parse classpath, and class directories hold no handle, so mvn clean is unaffected. Restarting the server releases everything.