Code-Archaeologist-LLM-Agent-Skill
AGENTS.md1 starsChanged 49 days agoMITView on GitHub
You are the skill creator of the Code Archaeologist LLM Agent Skill — the agent skill that lives in .agents/skills/code-archaeologist/. Your job in this repo is to build and maintain that skill, not to use it on this repo. A second skill sits beside it, .agents/skills/code-archaeologist-explorer/: a SKILL.md and nothing else, so a user can ask for /code-archaeologist-explorer when all they want is data/explorer.html (it runs the main skill's both then report). It owns no script. bin/cli.js installs it next…
- Reads credentials
- Installs packages
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository2 other files it gives its agents.
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
24 rules. About 0 are common, 17 are distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈17
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 56
A call link says where it is written: Every calls link carries line -- the first line its call is written on -- and loop / cond where …
- Distinctive: line 211
Every script therefore opens with the same two lines instead of re-deriving its own paths:
- Very specific: line 243
When neither an annotation nor the name says anything, the folder does (PATH_LAYERS / layer_from_path, r79): an exact folder name, nearest …
- Very specific: line 734
The toolbar compacts rather than clipping: When the toolbar is narrower than the full row (~884px since Freeze joined the row.
- Distinctive: line 781
Every one of those is optional at runtime and must fail the same way: warn by name, skip those files, still build the rest.
- Distinctive: line 808
Always run the full pipeline against the bundled sample, from the repo root:
- Very specific: line 842
call sites: all 29 calls links carry line.
- Very specific: line 906
Every grammar is optional and each one degrades the same way -- rename it out of vendor/, and the build must still succeed with one named …
- Very specific: line 1032
If you changed sample_src/, regenerate the committed example data (both maps + both reports) in the same commit — the repo ships it as the …
- Distinctive: line 1040
Don't assume. Don't hide confusion.
- Distinctive: line 1050
No features beyond what was asked.
- Distinctive: line 1051
No abstractions for single-use code.
- Distinctive: line 1053
No error handling for impossible scenarios.
- Distinctive: line 1054
If 200 lines could be 50, rewrite it.
- Distinctive: line 1061
When editing existing code:
- Distinctive: line 1062
Don't "improve" adjacent code, comments, or formatting.
- Distinctive: line 1063
Don't refactor things that aren't broken.
- Distinctive: line 1065
If you notice unrelated dead code, mention it — don't delete it.
- Distinctive: line 1067
When your changes create orphans:
- Very specific: line 1068
Remove imports/variables/functions that YOUR changes made unused.
- Distinctive: line 1069
Don't remove pre-existing dead code unless asked.
- Distinctive: line 1102
If you find yourself reading many files to answer one question, that question wants a script.
- Distinctive: line 1110
Every document in this repo describes the skill to some reader.
- Distinctive: line 1225
If a turn ends without a commit, the entry is still appended — the trigger is the end of the turn, not the commit.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
AGENTS.md
You are the skill creator of the Code Archaeologist LLM Agent Skill — the agent skill that
lives in .agents/skills/code-archaeologist/. Your job in this repo is to build and maintain that
skill, not to use it on this repo.
A second skill sits beside it, .agents/skills/code-archaeologist-explorer/: a SKILL.md and
nothing else, so a user can ask for /code-archaeologist-explorer when all they want is
data/explorer.html (it runs the main skill's both then report). It owns no script. bin/cli.js
installs it next to the main folder with the same path-prefix rewrite, and tools/check_docs.py
checks the paths it quotes. When a command it runs changes form, it changes in the same commit.
What the skill is
A deterministic, Zero-RAG codebase documentation engine. It builds two maps of a target codebase, reviews them, and renders one browsable page:
| Map | Nodes | Built by | Output |
| Structure | classes / React components / module groups | build_wiki.py → build_graph.py | data/structure/{graph.json, registry.json, vault/*.md} |
| Flow | methods/functions | build_flow.py | data/flow/{flow_graph.json, notes/*.md} |
Three producers feed both maps -- Python (py_extract.py), JS/TS (js_ts_extract.py) and
everything else (langs_extract.py: Java, Go, C#, and since phase 7 Kotlin, Rust, Swift, Scala,
Groovy, Dart, C, C++, Ruby, PHP and Elixir) -- and since phase 2 all three are tree-sitter.
There is one parser in the build, and seventeen languages have a graph.
Precision is stated once, globally, and named per node where it can be. Every language's call
edges are a lower bound -- a call is drawn only when the receiver's type can be read from the
source -- and that caveat lives in taxonomy.PRECISION_CAVEAT, printed by brief and the report
header. What a node carries is precision: a list of named losses
(interface-dispatch, overloads, name-matched), computed by taxonomy.precision_of()
from what the node calls and drops, never from its language alone: name-matched is earned by
untyped, the dropped calls through a receiver of unknown type whose names the graph defines. It
was on every JS/TS/Ruby/PHP/Elixir/Groovy node until finding #25 (r51). It replaced a per-node approx: true on
Java/Go/C#, which was honest while those three were read textually and became arbitrary once every
language moved to tree-sitter. Adding a producer means teaching build_wiki what its entities
reference and build_flow how to resolve its calls, nothing else. Python and the Java family
share one entity builder -- build_wiki.extract_backend_entities, called once per producer so
the collision order stays Python, then JS/TS, then the rest -- because what an entity is does
not differ between them; the one thing that does, what it references, is named in _refs_of.
Their flow analyzers stay two on purpose (_analyze_py, _analyze_lang): at least 67 lines
of the latter are overload sets, Spring beans, return types, getters and via chains that
Python has none of, and their node constructors differ in signature, hash and code by
construction (ROADMAP finding 39). JS/TS keeps its own entity pass, since a JS specifier names
a file rather than a name.
A dropped call keeps its name. ext counts the call sites that did not become edges, and
used to keep nothing else, so print(...) -- correctly dropped, nothing in the graph is called
that -- was indistinguishable from a call to a name the graph does define, which is a missing
edge. build_flow._record_dropped keeps the names and _split_dropped keeps those the graph
defines as unresolved (context.py prints them on the node). It is name-matched and therefore an
over-count -- on the skill's own code 207 of 2,782 dropped sites, and 11 of them the same real
gap. Nothing turns it into an edge.
A call link says where it is written. Every calls link carries line -- the first line its
call is written on -- and loop / cond where earned: a site inside a loop's body, or every
site inside a branch (an if/else, a switch/match arm, a ternary's result, a catch).
core/call_ctx.py reads it off the parse for all three producers, from node-type tables probed out
of all seventeen grammars; build_flow folds a call's sites (first line, a loop if any is, a branch
only if all are) and keeps them beside the edge set as each caller's call_sites, so the edge
tuples analyze() returns are exactly what they were. Only a body counts: a for header's
iterable runs once and an if condition always runs -- the design first pinned the sample's
range service.Events(...) as a loop, and the probe said otherwise. A node type the tables do not
name reports nothing, a lower bound like the links themselves: a Python ternary (unnamed fields),
&& / ??, a Go for cond {} condition, the second for of a nested comprehension (its
iterable runs once per outer item), Elixir's if/case/for macros, a callback handed to
.map(). The line is the order calls are written in, never the order they run, and the explorer
says so wherever it shows a rank. A call on one side of an either/or also carries arms:
outermost first, each such branch as "<line>:<col>/<arm>", so two calls on different sides of one
if / else are known to be alternatives -- exactly one runs -- and the explorer numbers them 3a
/ 3b. Only where the sides exclude each other: an else if chain (however the grammar nests it)
and a ternary chain are one branch, match / when / select and a switch whose cases cannot fall
through (Java case X ->, C#, Go and Swift without fallthrough, Dart, Ruby case) count; a
case X: switch in C, C++, JS/TS, PHP, Java or Groovy, and try / catch, set cond but no arm.
A call made on two sides keeps only the sides every site shares. check_graph c24 and
check_regressions r81 / r82 pin it.
Node ids are bare unless a name is defined in more than one file -- in both maps.
core/ids.py's SharedNames, used by build_flow and build_wiki alike,
pre-scans every definition from all three producers, then qualifies only the ids two or more files
share -- by file stem, else by path -- compared case-insensitively, because each node's note is
<id>.md and Widgets.X / widgets.X are one file on Windows and macOS. That includes two
different names that differ only by case (login in one file, Login in another): grouping by
exact name missed them until a real Next.js repository put the two notes in one file. A call to a shared name
resolves to the caller's own file's definition, or is dropped (FlowIds.target). Before this,
every main() was one node carrying every definition's calls: 72 false edges on the skill's own
code. sample_src has no shared names, so none of its ids changed.
A node's range starts at its first decorator, annotation or attribute -- in every language,
both maps. @PostMapping, [HttpPost], #[post(...)] and @app.route are part of what a
handler is, so a finding on one of those lines belongs to the handler (finding #6). It used to
be the name line in Python/Java/C# and the decorator line in JS/TS; tests/fixtures/langs'
lines column pins it now.
Every method is its own node, overloads included -- in the languages that have them (Java,
C#, Kotlin, Scala, Swift, C++, and Groovy through Java's tree; langs_extract.OVERLOADING). A name a
class defines twice gets its parameter types in its id -- InvoiceService.Total(InvoiceRequest),
InvoiceService.Total(int,int) -- and every other id stays bare (build_flow._local_names). A
call picks its overload by argument count, then by every argument type the source states -- a
literal, new X(...), or a name with a declared type (build_flow._pick_overload); what that
leaves ambiguous is dropped, and the caller records the set in ambiguous and carries
precision: overloads. Until finding #8 an overload set was one node holding every overload's
calls but one overload's range; signatures now appears only when two definitions still cannot
be told apart. Anything that looks a node's name up in source asks ids.bare(id).
An implementation is linked to the method it implements. A call through an interface stops at
the declaration, and which class runs is decided outside the source (a constructor argument, a
Spring bean). What the source does state is class FlatRate implements PricingRule, so
build_flow._implements_links joins each method to the same-named method of its class's nearest
base in the graph -- walking past a base that defines none, pairing overloads by parameter types --
as an implements link stored FlatRate.price -> PricingRule.price. Every walker reads it
backwards through taxonomy.call_direction (traces, --impact-of, orphans, search.py,
context.py, the explorer's blast radius), and nothing counts it as a call: not calls /
callers, not precision, not coupling, cycles or layer violations. It says which classes can
run a call, never which one does (r50, check_graph c21). Go states no bases, so its interfaces
get none. In the structure map a stated base is an implements link too, walked the same way, so
a class implementing a referenced interface is not an orphan (finding #29, r55).
The object model is stated, and named (r61). A class node's kind is interface, abstract
or class -- an interface/trait/protocol node, Kotlin's interface keyword, an abstract
modifier token (langs_extract._class_kind), TypeScript's abstract_class_declaration (which was
not read at all before), a Python Protocol / ABC / @abstractmethod, a Go interface type.
The explorer draws it as a shape, never a colour: an interface hollow, an abstract class ringed
dashed. Inheritance is three links, all in taxonomy.INHERITANCE_LINKS (= REVERSED_LINKS):
implements (a class fills in an interface; a method fills in a declaration), extends (a class
inherits a class, an interface an interface -- structure map, decided in build_graph once both
ends' kinds are known) and overrides (a method replaces a base method that has a body -- flow
map). Every walker, check and exclusion that named implements reads the set instead. A method
with a body that every subclass in the graph replaces carries overridden: true
(build_flow._overridden_everywhere); uncalled, it is listed by analyze.find_overridden in its
own report section and not as an orphan -- its body never runs today, but deleting it changes
what a new subclass inherits, which dead code never does. It is not graded.
A class name two files define is settled by the source (r57). Two applications in one
repository each with a ConfigService used to resolve only from the caller's own file, so every
call from anywhere else was dropped. langs_extract.class_locator picks the file: the caller's
own, the only one, the one an exact import names, one a wildcard import or C# using covers,
or the one in the caller's package (Java, Groovy, C#, Kotlin record package and imports).
build_flow._analyze_lang and build_wiki both ask it, and so does _base_resolver. A call with
no receiver, this. or a typed receiver whose class does not define the name walks to the nearest
base that does (ancestor_defining, r58), and a local's type is read from its declaration --
R3RequestDto request = mapper.read(), a for-each variable -- not only from = new X( (r59).
What the source settles, the resolver follows (findings #27 and #28, and Spring injection):
- A call through a global resolves: a Python module-level
store = Store(), or one imported with an unaliasedfrom m import store(py_extract._imported_globals); a Kotlin top-levelval; a Go packagevar; andRegistry.STORE.save()through the static field's declared type (via.fields). A parameter or local of the same name hides the global. Python'sClassName.method()resolves as every other language's does (r52). - A call made outside any method node -- a Java/C# field initializer,
static {}/{}block or constructor, a Kotlin property initializer,init {}, secondary constructor or top-level property, a Go packagevar-- is collected asinit_calls, and each callee carriesentry: init, as module-load code carriesmodule; a Gofunc init()does too. No link is drawn from anything (r53). - Spring: a call through a type Spring injects into a class it manages (a stereotype
annotation) links straight to the bean's method when the scanned source settles which bean --
a
@Qualifiernaming one, one@Primary, the only bean (never a lone@Profile/@Conditional*one), or a@Beanmethod that builds exactly one class -- and otherwise stays at the declaration. Java, Groovy and Kotlin: a Kotlin@Qualifieron a constructor property or alateinitfield, and a@Beanfunction that builds one class (= SmtpMailer()), are read into the samebean/qualifierdata (r54, r56 -- finding #30).
An agent answers architecture questions by querying the graph, then reading only the notes on the returned path — never by scanning source.
Pipeline (what calls what)
archaeologist.py project | flow | both | check | report | brief <- the only entrypoint
project -> build_wiki -> build_graph ------------------\
flow -> build_flow ---------------------------------+--> render_explorer()
both extract through: py_extract.py (Python, tree-sitter)
js_ts_extract.py (JS/TS/JSX/TSX, tree-sitter)
langs_extract.py (14 languages, tree-sitter)
extraction utils: call_ctx.py (CFG call sites: line, loop, cond, arms)
doc_text.py (universal docstring cleaner & merger)
flow also reads: route_tables.py (Django/Rails/Laravel/Phoenix tables -> handler nodes)
report -> report.py (scan_security + git_insights + analyze + metrics + debt + tests_map
+ duplicates)
-> data/report/<map>/
brief -> brief.py (reads the artifacts above, computes nothing)
check -> manifest.py (source hashes vs last build; --src optional, roots recorded)
\-> build_html.py -> data/explorer.htmlWhere the scripts live
scripts/ is grouped by role, and archaeologist.py is the only file at its root because it is
the only entrypoint:
scripts/
archaeologist.py the entrypoint
paths.py SKILL_ROOT / DATA_DIR / TEMPLATES_DIR, the sys.path bootstrap, and
long_path() -- every per-node file goes through it (MAX_PATH, phase 6d),
and skill_rel() -- every report's `graph` field (cross-drive, phase 9)
core/ taxonomy.py manifest.py console.py grammars.py ids.py doc_text.py call_ctx.py
extract/ build_wiki.py build_graph.py build_flow.py py_extract.py
js_ts_extract.py langs_extract.py route_tables.py apply_descriptions.py
review/ analyze.py scan_security.py git_insights.py metrics.py debt.py
tests_map.py duplicates.py report.py brief.py
query/ trace_path.py context.py search.py build_html.pyDependencies point one way: core/ imports nothing of the skill's, everything else imports
core/, and no two categories import each other in a cycle. Keep it that way — a new script goes
in the category it depends on, not the one it reads like.
Every script therefore opens with the same two lines instead of re-deriving its own paths:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from paths import DATA_DIR # noqa: E402 (also puts sibling script dirs on sys.path)Importing paths puts scripts/, all four category dirs and <skill>/vendor on sys.path,
which is why sibling imports stay bare (import taxonomy), why import tree_sitter finds the
skill's own copy before the user's, and why every script still runs directly from any working
directory (constraint 3). console.py and taxonomy.py need no preamble at all because they
touch neither data/ nor a sibling.
paths is the one exception to "core/ imports nothing of the skill's": grammars.py imports it
for VENDOR_DIR. That is deliberate — paths sits below the categories, imports nothing itself,
and re-deriving the skill root inside core/ is exactly the duplication paths was created to
delete. Nothing else in core/ may import a skill module.
taxonomy.pyowns everykind/layervalue (mirrored intemplates/TAXONOMY.md). Add values there, never inline. It also owns which way a link is walked (REVERSED_LINKS/call_direction()): animplementslink is stored implementation -> declaration and every walker reads it backwards. It also ownsSKIP_DIRS-- the one definition of which directories are not source (dependencies, build output, framework caches such as.next/); five hand-kept copies had drifted, and a Next.js dev server's output became 9% of a real repository's flow graph (phase 9).target,out,coverage,vendoranddataare deliberately absent: each is a real source directory somewhere (datawas listed for this skill's own output and hid a Next.js route segment on a real repository, r69; an installed skill copy is skipped bysource_dirs()instead). It also ownsLANG_BY_EXT/lang_of()-- one answer to "what language is this file", read bymetrics.pyandlangs_extract.py. And it owns what counts as a test file (is_test_path/is_test_file): path and filename conventions plus framework markers (@Test,@SpringBootTest,[Fact],#[test],func TestX(t *testing.T)). Nodes in test files getlayer: test, which is whyanalyze.pynever calls them dead code andscan_security.pyskips them. Every pass must ask taxonomy, never re-implement the check. When neither an annotation nor the name says anything, the folder does (PATH_LAYERS/layer_from_path, r79): an exact folder name, nearest first --service/pnodata/is a service,response/andrequest/hold models,properties/holds config. A fallback only, never an override, and matched exactly rather than as a substring:api/is a folder half a repository lives under, not six hundred clients. Words that name no layer here (util,exception,constant) are absent on purpose --unknownis the honest answer, and on a real repository it stayed that for ~90 of 189. A layer word must end where the word ends ((?![a-z]), r78):repomatchedPnoDataReportMailandstorematchedStoredFileDto, putting 7 classes of a real repository inrepository-- and since that rule runs beforemodel, a*Dtolost its layer, which fabricated the report's only layer violation.repository/mapper/clientand the other long words still match anywhere, because no longer word contains them. It also owns which annotation declares a layer (ANNOTATION_LAYERS--@RestController,@Service,@Repository,@Entity,@Configuration-- checked before any name rule, with@Componentcounting for nothing;handleris no longer a controller word, which on a real Spring repository made 24 service-to-handler calls "layer violations"), what a framework calls (FRAMEWORK_ENTRY/framework_entry(), and the Next.js file conventions innext_entry(): a matching node carriesentry--@Scheduled,@Override,override,main,next:page,next:route,modulefor a JS/TS function top-level code calls, orinitfor one a field initializer, initializer block, constructor or Go packagevarcalls -- andanalyze.find_orphansnever calls it dead;build_flow.write_graphmust copy it, and for one retest it did not, while r40/r41 passed on the in-memory dicts: assert on the written file). A class gets one too, throughcontainer_entry(CLASS_ENTRY_ANNOTATIONS:@SpringBootApplication,@Configuration,@Aspect,@ControllerAdvice, else the firstentryone of its own methods carries) -- written bybuild_wikiinto the page's front matter and read back bybuild_graph, since structure nodes carried noentryat all whilefind_orphanswas already checking for one (r75).@Component/@Service/@Repositoryare deliberately not in that set: Spring builds them too, and sparing them all would end dead-class detection for a whole application. And what a language calls a decoration (DECORATION_TERMS/decoration_term()): the graph field is one list (decorators) because every producer reads the same thing, but a vault note's heading is the language's own word --Annotationsfor Java/Kotlin/Groovy/Scala/Dart,Attributesfor C#/Rust/Swift/PHP/Elixir,Decoratorsotherwise; Lombok's@Dataunder a## Decoratorsheading named it in a language that has no decorators. Andsource_dirs(), the one walk filter every walker calls:SKIP_DIRSplus any installed copy of this skill (a child directory holdingSKILL.mdandscripts/archaeologist.py), which a project that installed it under.claude/skills/used to graph as its own code.grammars.pyowns "can this machine parse language X" -- the wheel table, lazy cached parsers, the exactpip installfor anything missing, and the installed versions for the manifest. It owns the pins too (PINS: exact per grammar, a range for the runtime, since phase 6c), and is itself the installer:grammars.py --install [langs]runs pip with the interpreter that will run the skill.bin/cli.jscalls it rather than keeping a wheel list, and every doc points at it rather than spelling out package names -- a second list is how the pins went missing before.drift()names an installed grammar that is not its pin;briefprints it asUNPINNED. It lives incore/becausemanifest.pyneeds it, andcore/may not importextract/. It also owns the two questions the vendor directory creates:origins()(did this resolve fromvendor/or from site-packages — both can be installed, andsys.pathorder decides silently) andruntime()/runtime_error(), which turn an unloadable runtime into one sentence instead of a traceback.runtime_available()is afind_spec,runtime()is the real import; they disagree exactly when the wheels were built for another Python, so a caller reporting a skip must askruntime_error()first — telling someone a grammar is missing when it is sitting right there sends them to reinstall what they already have.ids.pyis the one id rule for both maps:SharedNamesis given every definition's (name, file) before any node is built, qualifies only the names two or more files define (stem, else path, compared case-insensitively), and resolves a reference to a shared name to the referencing file's own definition or to nothing. Pure logic -- it imports nothing -- so it sits incore/below bothextract/builders. The structure map used to keep the first entity of a shared name and drop the rest; the flow map used to merge them. Neither does now.doc_text.pyis the one rule for turning a doc comment into a node'sdoc, and every producer asks it. The rule islangs_extract's: the comment block above the declaration, blank lines invisible, stopping at the first non-comment (a comment separated by code is not the doc); consecutive blocks all count, which is how a run of//becomes one doc; markers are stripped and every non-empty line is joined with a space, so adocis always exactly one line. Finding the block stays in each producer -- only it knows its grammar's comment node types -- and the cleaning isclean(), orjoin()for a Python docstring, which has no markers.clean(xml=True)is for the languages whose doc convention is XML or HTML (C#, and JS/TS, since JSDoc borrowed Javadoc's); it is off for the rest, because the tag sweep cannot tell a tag from a generic andVec<String>in a Rust doc comment is not markup. Before this each producer answered differently: JS/TS refused a comment that was not on the line directly above and kept only one line of the nearest block, so a doc written as a run of//was published as its last line -- three of the sample's five JS/TS docs were sentence fragments (r80); Python was truncated at its first line by each call site separately. Python is the one deliberate asymmetry left: a#comment above adefis not documentation in that language, so only a docstring counts, and it goes throughjoin(). Pure string work -- it imports nothing -- so it sits incore/below every producer.call_ctx.pyis the one rule for where a call is written, and every producer asks it at each call node it reads:site(call, stop)returnsline, plusloop/condwhere earned, walking from the call up to its definition and never past it. A loop node counts a call that arrived through a repeating field (body,condition,increment,update); a branch node, one that arrived through a result field (consequence,alternative,body, ...); an arm node (else_clause,catch_clause,when_entry, ...) makes everything under it conditional. Kotlin and Swift leave bodies unnamed, so for their loop andiftypes -- and only those -- an unnamed block child counts too: Swift'sdo { try }shares the namedo_statementwith a do-while elsewhere, and a global fallback made it a loop.merge()folds a second site of one call (first line, loop if any, branch only if all), order-independent so a build stays byte-identical. The tables name node types explicitly, never by substring (formal_parameterscontains "for"). Pure -- it imports nothing and duck-types the tree-sitter node -- so it sits incore/besidedoc_text.py. The generic walker's_gcallsused to return(receiver, name)and drop the node; it hands the node through now, and a MapStructjava(...)call takes the annotation's line, since the snippet it was parsed from has rows of its own (and dropsarms, whose positions name the snippet).armscomes from the same walk:_if_armfinds a side of an if-family node (aconsequence/alternativefield, Python's and PHP's severalalternativechildren, Kotlin and Swift's unnamed blocks by position) and follows_is_else_ifup to the chain's firstif, soifis side 0, itselse if1, itselse2, and anelse iftest belongs to its own side;_switch_armfinds an arm node inEXCLUSIVE_ARMSunder a switch-family node, counted among named children only -- Kotlin'swhenkeyword token shares a type name with Ruby'swhenarm and shifted every arm by one -- and refuses a Go or Swift switch that saysfallthrough. Probed on all thirteen grammars with either/or constructs before it was wired in.py_extract.pyis the Python producer, and since phase 10 it really is one:find_py_files/extract_py_files, the contract the other two already kept. It was a helper library until then -- ~25 tree helpers, with the Python extraction itself spread over 120px.*call sites in the builders -- because phase 2's port swappedastcalls for tree-sitter calls in place, to keep the diff that proved it. Fixing a Python resolution rule meant editing the graph builder. 595 lines moved here, and the graphs came out byte-identical. A call site is emitted as{name, type}wheretypeis what the source states --""a bare call,"self"aself.method(),"Cls"a receiver the source types,"?"one it does not (withrecvwhen that receiver is a bare name the graph may know as a class) -- andbuild_flow._py_targetsdecides the target, the same split every other language has. Python states no field types, so_attr_typesbuildsself.<attr> -> Classout of what__init__was handed, andextract_py_filesparses every file before reading any, because a call through a global resolves across files (_imported_globals, finding #27). Theasthelpers it still exposes are theasthelpers ofbuild_flow.py,build_wiki.pyandmetrics.pytranslated node-for-node, plusread_source(), which normalises newlines becauseastwas handed universal-newline text and tree-sitter is handed raw bytes -- without it a CRLF checkout hashes every node differently and silently misses the description cache.astis not gone: it is the oracle, andtools/check_py_oracle.pyparses every file both ways and fails on any disagreement about what was declared. That is the one reason to keep a stdlib parser around, and the only thing in the repo that still importsast.load_time_calls()collects what a module calls as it is imported -- top-level statements and theif __name__ == "__main__":guard, never a def, class or lambda body -- andbuild_flowmarks each calleeentry: module, as it does for JS/TS; a sharedmainresolves to the caller's own file (r48).js_ts_extract.pyreads JS/JSX/TS/TSX from a tree-sitter parse: classes, functions, imports, Express and Nest routes,fetch/axios calls (including an axios instance imported from the one file that creates it, directly or through a factory function --extract_js_filesparses every file before reading any, and an import that resolves to exactly one such file binds the name -- and URLs built on a same-fileconst BASE = "/x"), and the JSX rule that makes a function akind: component. A call carries the class of its receiver where the source states it --new X(),this,this.<typed field>, or a typed parameter or local -- and"?"where it does not, the same shapelangs_extractemits, sobuild_flowresolves JS/TS the way it resolves Java. Until then the receiver was discarded and class methods were never registered as candidates, so no call could land on a JS/TS class method at all (the TypeScript fixture: 0 edges of 3). It replaced the Node extractor behind that extractor's exact output contract (find_js_files/extract_js_files/frontend_degraded), which is why the port could be proved by diffing JSON rather than by reading code: the diff reported identical output on all six sample files and the graphs came out byte-identical. That reference (js_bridge.py,js_extract.js,@babel/parser7.29.8) and the diff tool were deleted at step 4, so the comparison cannot be re-run --fd9c7d8is its record. Four extensions, three grammars:.tsxwill not parse under the TypeScript language and needstsx. Imports carrybindings, and once every file is parsed, thepaththey resolve to -- relative, through the nearest tsconfig/jsconfigpaths(@/utils/x), else the one suffix match; when two apps in one repository both have that suffix, the one inside the importer's nearest package.json/tsconfig/jsconfig folder (_package_root, r62) -- sobuild_flowfollowslabel()orerrors.toMessage()to the imported file's definition even when another file defines the same name. Only a binding under the exported name itself:import { a as b }would draw an edge to a name the caller never spells (check_graphc13). An anonymousexport default function () {}is a node named by its file stem, and inside a Next.js app (next.config.*, or a package.json depending onnext) each exported function the framework calls carriesentry.tests/fixtures/ts_imports/pins all of it (check_regressions.pyr42). Top-level code is read too: what a module calls as it loads (export const api = createApiInstance(), never a function body passed at top level) goes inmodule_calls, resolved like any call, and the callee getsentry: module(r45).new X()is kept asconstructs, which the structure map counts as a use ofX. In the structure map a resolved import also records the file it named (import_sources), sorender_entitylinks to that file's definition withSharedNames.ideven when two files define the name. A name the file defines itself is a reference too (build_wiki._provided_names, r77): JS/TS and Python resolved references from the import list alone, so a component calling a function of its own file's module group pointed at nothing -- not one of 385 references into a module node on a real repository was same-file. A module group is its functions, so a use of one names the group; a node never references itself. The Java family already resolved this throughclass_locator. A call through a variable resolves in every shape the source states exactly (r47): a module-level or importednew ProjectApi()instance types the receiver (_module_instances/_imported_types, the axios-instance idea generalised);export * from,export { x } fromandexport * as ns fromarereexports, followed bybuild_flow'sexported_node/exported_memberwhen exactly one re-exported file has the name and never through a rename; and an object literal's inline members (export const api = { approve: async () => ... }) are flow nodesapi.approve(kindmethod,cls: api, with their own HTTP calls), while a member that only names a function of the same name is an alias for it. A hook's untyped return value stays dropped. A call on another call's result carriesvia(below), typed from(): Xreturn annotations. And<Name />in a component is a flow edge of its own type,renders, resolved like a call --<Ctx.Item />names an object's member and draws nothing (r49). It is followed by traces and--impact-of, excluded fromnode["calls"]/callers,precisionandanalyze.app_edges(coupling), and drawn lavender and dotted in the explorer. Three shapes of indirection resolve, all from what one function's source states (_scope_bindings): aconstholding a name or a?:/||/??of names is a call to every branch (const save = id ? updateUser : createUser, r66); a function handed over -- call argument, object value inside one, JSX attribute -- is apasseslink, handled exactly likerendersand resolved only through an import or to the passing file's own function, never a parameter or local (passed_node, r67; at top level it setsentry: module); and a call on an object resolves when the object is a renamed or default import (member calls only), aconstchoosing between objects, or the result of a function whose everyreturnis one object literal naming the functions (returns_members, r68). A field of a hook's result --const { api } = useSetdatVariant(); api.fetchX()-- follows only written types (r72): the hook's(): Tor thecreateContext<T>everyreturn useContext(Ctx)reads (returns_type/returns_context, file-levelcontexts), theninterface T { api: X }/type T = {...}(types, found through imports bybuild_flow.declared_in), thentype X = typeof service, thenservice's member as r68 does. A prop is still untyped.const Button = forwardRef(...)/memo(...)is the function it wraps (_wrapped_function, r70), so it is a node and a component. An object handed over whole is not unpacked --t.rich(key, { ...TAGS })passesTAGS, and its members keep nopasseslink. That is deliberate, and the rule behind it is stated in SKILL.md's Command 10: an orphan means "no code references this", the standard an IDE's unused hint uses, so a function a framework finds by a name held in data (a next-intl tag named only insideen.json, a string-keyed bean, reflection) is an orphan and correctly so. Following the spread, or reading message files for tag names, would invent links; do not add either.langs_extract.pyreads Java/Go/C# from a real parse tree, and keeps the samefind_lang_files/extract_lang_filescontract the textual extractor before it had -- which is what let the port be verified by diffing the graph instead of by reading code. That extractor (lang_extract.py, 788 lines) was deleted at step 2 once the diff was clean; it is in git history if the comparison is ever wanted again. It was calledts_extract.pyuntil 2026-09-12:tsmeant tree-sitter, but next tojs_ts_extract.py-- which is the one that reads TypeScript -- it read as the opposite of what it does. The name now matches the contract it exports (find_lang_files/extract_lang_files). One shared consumer works in tree-sitter field names (name,body,parameters,type); only theSPECtable knows node-type spellings. Adding a language is a row there plus its receiver rule. tree-sitter gives declarations, bodies, param types and doc attachment; it does not give resolution, so_callsstill answers""/"Type"/"?"exactly as before. Since phase 7 it also reads eleven more languages, through a second, shared walker rather than more inline branches:SHAPESnames each grammar's container / method / comment node types, and a handful of small readers cover the trees that are genuinely shaped differently (a Rust method lives in animpl, a Dart method is a signature beside its body, a C function's name sits inside nested declarators, an Elixirdefis a macro call). Groovy's tree is Java's, so it takes Java's branch (JAVA_LIKE). The walker adds one resolution rule these languages lean on: a receiver that is itself a type name (WidgetStore.savein Elixir,Widget::newin Rust) resolves to that type. Java, C# and Groovy use the same rule (_type_name, soDateUtil.now()resolves), and so does JS/TS; Go does not, because a capitalised Go head is as often an exported value as a type.build_flowstill requires the class to be in the graph and to define the method, soMath.max()drops as before. Java-family method references (this::clearBin,Store::save,store::flush) are read as calls the same way; they carry no argument list, so an overloaded target is dropped as ambiguous rather than picked (r44). A JavaString... partsis read as a parameter (_param_pairs; its id segment isString[]) and its kind as...String, which_pick_overloadtries only when no fixed-arity overload fits -- Java's own order (r63). A cast receiver,((UserSecurity) u).getPlantIds(), is typed by the cast in Java, Groovy and C# (r65), and so is a pattern variable --instanceof T t,case T t ->, C#'sis T t(_tree_locals, r71). And the Java inside MapStruct's@Mapping(expression | defaultExpression | conditionExpression = "java(...)")is parsed as Java and read like a body, with the method's parameters in scope (_expression_calls, r64) -- so a body-less mapper declaration carries call links, the one exceptioncheck_graphc08 allows. A call whose receiver is itself a call --resolveHandler(type).downloadFile(x)-- is read from the receiver node, never its text (resolve(a.b)holds a dot inside its parentheses), and carriesvia: the innermost call's receiver type and the calls outward. Methods recordreturns(Java/Groovytype, C#returns, Kotlin's unnamed type after the parameters), a Java class with Lombok@Data/@Getter/@Value(or a field's@Getter) recordsgetterstyped by their fields, andbuild_flow._analyze_langwalksviathrough them. A step whose type is unknown, or whose definitions disagree on a return type, drops the call (r46). For the structure map it also recordsrefs-- every type name a class or method's own source states: return types, every generic argument (_base_typekeeps only the head, soGlobalResponse<PaginationResponse<UserResponse>>used to yield one name of three), local declarations,X.class, and the class a static constant is read from (capitalised, and never in Go). That is what an IDE counts as a usage; it draws references, never call edges (_type_refs, r73). Java, Go and C# keep their own branches untouched -- the sample's graphs were byte-identical before and after.route_tables.pyreads routes declared away from their handlers (phase 8): Djangourlpatterns(withinclude()prefixes, regex paths and class-based views -> one route per HTTP method the class defines), Railsroutes.rb(resources,namespace,scope,member), Laravelroutes/*.php(both handler forms,prefix()->group,resource), Phoenix routers (nestedscopealiases,resources). It only reads;build_flow._attach_table_routesattaches each route to the one node its(class, method)reference names -- a Django function view is also pinned to the file its import points at -- before the cross-stack pass. A reference naming no node, or two, is dropped and counted in one stderr line, never guessed.trace_path.pyis the query tool:--from/--to(BFS path),--impact-of(blast radius),--impact-of-diff(map a git diff to nodes, union their impact). Works on either graph. Animplementslink is walked declaration -> implementation, so a trace through an interface reaches every implementation and--impact-ofon one reaches the interface's callers.analyze.pyis graph-only: cycles, orphans, layer violations, hubs, god objects, name-based idioms, and the 0–100 / A–Fhealth()score (accepts security counts). Degree-based checks run onapp_edges(), which drops edges touching alayer: testnode — test calls are coverage, not coupling — andrenders/passes/implementslinks. Coupling is a direction, not a count (r83).instability()is Robert Martin'sI = fan_out / (fan_in + fan_out), andcoupling()carries it on every node, which splits what a total-degree hub count folded into one:find_hubsis now fan-in and fan-out >= 5 -- changes arrive from every caller and spread to every callee, the only shape graded -- whilefind_shared_helpers(fan-in >= 10, I <= 0.1:GlobalResponse.success,DateUtil.now) andfind_coordinators(fan-out >= 10, fan-in <= 2: a registration method) are reported and never deducted for. A well-used utility used to score exactly as badly as a tangle: on a real repository 42 of 46 hubs were one or the other, and the -12 was a false alarm; on the skill's own scripts the old rule found 41 hubs where the new one finds 1 (_annotations, 5 in / 5 out) plus 14 helpers and 19 coordinators. Coordinators are left out of the score on purpose --god_objectsalready penalises the class-level version, and counting both is counting twice.find_wrong_way_depsis the fourth shape and is graded (-2each, capped at 8): a stable node calling an unstable one (caller I < 0.3 with fan-in >= 5, callee I > 0.7), which is the mistakefind_layer_violationscatches only when both ends carry a ranked layer.check_graph'sd03asserts all three classes andd10the direction; the sample has none of them, socheck_regressionsr83 is where the split is pinned. Cycles and layer violations ignoreimplementstoo: a decorator delegating to its own interface is not a cycle.find_orphansnever lists an overload of a set some node names inambiguous(this::values): one of them is called, the graph cannot say which, so the whole set is spared rather than one member guessed (r63). Nor a base:implements/extendsmark both ends used, becauseclass Audited extends Auditablenames the base in its own source -- the backwards walk alone only ever marked the child, so a base with twelve subclasses was dead (r74). Notoverrides: replacing a method does not reference the base body, which isfind_overridden's case.scan_security.pyis line-regex over source; every finding is attributed to the innermost node whosesource..endrange contains that line, or to none (owner_of, shared withdebt.py). It used to take "the last node starting at or before the line" and never looked atend, which pinned module-level findings on the preceding function — 99 of 152 on the skill's own code. A credential-named key is not reported when its value only names a credential -- the key's own name (ACCESS_TOKEN: "accessToken"), a path, prose, or lowercase words joined by-/:(space-y-4,auth:token-refreshed; a UUID mixes letters and digits in a segment and is still reported) (_names_not_holds); all ten "hardcoded secrets" on a real repository were one of those, at 10 grade points each.git_insights.pyis onegit log --numstatpass → churn, owners, hotspot risk.metrics.pyis line counts per file plus LOC / cyclomatic complexity / nesting depth / parameter count per node in every graphed language, measured on the graph's own nodes: a node'ssource+endpick its definition out of the file's parse (locate), and oneTABLESrow per grammar names its decisions, blocks and definitions. So metrics are keyed by graph id by construction -- until phase 6a they were keyed by bare name, first wins, which left 66 of 294 nodes on the skill's own code unmeasurable, and every non-Python node had none. Structure classes and components carrysource: file:line+endfor this since 6a; a module group is a whole file and has no range, so it is listed inunmeasured_graph_idswith the declarations.report.pyderivesfile_censusfrom it, so line counts have one definition.search.pyis the "which nodes are these" filter over one graph (name/doc/layer/kind/lang/file plus--calls/--called-by/--orphans). It exists so neither the agent nor a human greps source to find a starting node.context.pyis the per-node pack: graph facts + metrics/security/insights for one node and its neighbors, rendered under a hard--max-charsbudget. Likebrief.pyit only reads artifacts.debt.py(markers in comments + orphan nodes/files) andtests_map.py(which nodes a test file names) are the two "what is rotting / what is untested" passes. Both are heuristics on purpose and neither feeds the health grade -- they report, they do not judge.duplicates.pyis the third such pass: it reduces each node's body to a token shape (identifiers ->ID, literals ->LIT, comments gone) and clusters equal hashes, so a renamed copy still matches. It reads ranges from the graph (source+end), never re-parsing -- which is whybuild_flow.pyrecordsendon every node it builds. Nodes markeddeclaration: trueare skipped: a signature has a readable range but no body to compare. Since phase 9 it also finds blocks: a run of 30+ tokens copied into two otherwise different nodes, which no whole-body hash can see. Winnowed k-gram fingerprints (K=10, W=21, so every shared run of 30 is guaranteed a common fingerprint) with a stable rolling hash -- Python'shash()is salted per process and would break constraint 2. Matches grow to maximal runs and are trimmed to whole lines in both copies, so half a statement never counts; that keeps the "never re-parses" property, where statement boundaries would need a parse. A pair already in a cluster is never repeated as a block; overlapping ranges are skipped; a fingerprint in 50+ places is boilerplate. Blocks are grouped by shape -- one entry per copied block with everyplacesit occurs (finding #7); as pairs, one block in N places was N*(N-1)/2 entries.brief.pyis the fixed-size digest an agent should open a session with — it only reads what the other scripts wrote. Anything expensive belongs upstream of it, never inside it.report.pyjoins all of it intodata/report/<map>/architecture_report.{md,json}plussecurity.json/insights.json. One report per map — node ids differ between maps, so a report from the other map must never be embedded (build_html.report_for()enforces this).build_html.pyis thin: it loadstemplates/viewer.html, substitutes__TITLE__and__MAPS_DATA__, and writes onedata/explorer.htmlholding both maps (header switch).archaeologist.pyrenders it once, after every map and the manifest, and a missingtemplates/vendor/force-graph.min.jsis one named error and a non-zero exit -- never a lost map. On a real install it stoppedbothbefore the flow map: npm packs the repo fornpxand reads the skill's.gitignore, whose unanchoredvendor/(meant for the pip wheels) also droppedtemplates/vendor/. The rule is/vendor/, andbin/cli.jschecks the file is there.
Where the front-end lives
The explorer filters test nodes at load (loadMap -> HAS_TESTS / EDGES) and starts with them
hidden (#showTests has no checked), so the Tests
checkbox re-renders through applyMap; anything reading edges must use EDGES, not GRAPH.edges.
templates/viewer.html is a normal HTML/CSS/JS file (three-pane explorer: health ring + tiles +
LOC/language mix + file tree | seven views, the Flowchart first and open by default:
Flowchart/Graph/Tree/Cluster/Bundle/Treemap/Matrix |
FILE/PATTERNS/SECURITY tabs). Edit it directly; don't move markup back into Python. Its per-map
state is rebuilt by loadMap() / applyMap() — anything derived from a graph belongs in there,
not in a top-level const.
The notation has a key (#keyOverlay, opened by the ? in the header or the ? key). A
dashed line could be any of four relations and int / ext mean nothing until something says
so, so one overlay names every link type, layer colour, class shape, badge, precision marker,
view and toggle. Every swatch is drawn from the same accessors the canvas uses
(Graph.linkColor() / linkLineDash(), LAYER_COLORS), and the counts beside each row come
from EDGES / nodes, so the key cannot drift from the screen and also answers "which of these
does this map have". The one thing that is by hand is the list: a new link type needs its
LINK_KEY row and a new layer its LAYER_KEY line, in the same commit that adds it -- exactly
as a new layer needs its LAYER_COLORS colour. legible() raises a swatch's alpha to a 0.6
floor, because the canvas alphas are tuned for hundreds of overlapping lines and calls at .16
is invisible as a single rule; the hue and the dash, the two things that identify a link, are
the canvas's own. PRECISION_CAVEAT is mirrored from taxonomy.py the way PRECISION_NOTES
already is, reworded edge -> link because the explorer says link everywhere else. Escape
closes the key and stops there -- it must not also throw away the selection the reader
opened it to understand.
It lives in the header, not the toolbar: it explains the whole page rather than the current
view, and the toolbar's own rule is that a new control goes in the ⋯ menu. #mapSub takes
flex: 1 so the grade pill and the button both sit hard right even when the pill is empty.
A node is drawn and listed by labelOf(n) -- its id without the file qualifier ids.py adds
to a shared name -- and its path lives in the right panel, never on the canvas. A file/folder
filter and a canvas view selection (Flowchart, Graph, Tree, Cluster, Bundle) hide what they
leave out (visibleIds, through force-graph's nodeVisibility / linkVisibility) rather than
dimming it: a file keeps its own nodes plus their direct links, a canvas selection its whole
flow -- every caller back to the start and every callee to the end, whatever Blast radius says
(it only picks the highlighted links). The flow kept is the focus's (focus), which is the
selection unless Freeze is ticked: then a click still selects (panel, highlight) but neither
re-narrows the view nor moves the canvas, and unticking catches the view up with the selection.
Clicking empty canvas clears nothing -- only the toolbar reset (or Escape) drops the selection
and focus. On a 1,500-node repository a faded node was still in the way. The Flowchart replaced the old Flow
view (a dagMode("lr") force layout) and keeps its id flow: layoutFlowchart() gives each
node the column of its longest call path, orders rows by folder and then by neighbours'
rows, puts unlinked nodes in a grid underneath, and pins the result like Cluster and Bundle;
drawFlowBox / drawFlowLink draw boxes and elbow links from the same colour and dash
accessors as every other view. A box is FC 180x34 on a 44px row and holds two lines of
name: fitLines breaks after the last . that fits (VersionControlService. /
uploadFileToRepository), else after _ / -, else before a capital, and only the second line
can end in … -- a one-line box used to throw the method name away. Every call link in view
carries a badge just behind the arrowhead (drawCallBadge, 19px before the callee's box): its step in the order the caller's calls are written -- 1, 2, 3a, 3b, 4: calls on different
sides of one either/or branch share a step and take a letter per side, with a digit when a side
holds several (3a1, 3a2), letters going to the outermost branch with calls on two sides, and a
label longer than two characters stretching the badge leftwards (badgePath, loopRing) -- a diamond when every site is
inside a branch, a ring when a site is inside a loop. That spot is the one piece of a link no
sibling shares -- a caller's links leave from one pixel and bend on one vertical trunk per column,
and the first placement, midway along that bend, put adjacent rows' badges 22px apart, exactly a
ring's width: on a real 21-call fan-out 19 pairs touched or overlapped, and 0 do at the arrowhead
(closest 44px, a full row). But every link into a callee ends on that same arrowhead, so where two
or more visible callers badge one callee (BADGE_CALLERS, rebuilt in computeVisible), only one
badge is drawn there: the hovered caller's (onNodeHover), else the selected node's, else
none -- two would stack and mean two different orders at once. On a real repository 45% of the
badges inside a selected flow met another at an arrowhead (38 of its 40 busiest flows had at least
one). For one turn ([161]) badges were drawn on the selection's calls only; that read as the
feature being gone everywhere else in the flow. Ranks are CALL_SITES / CALL_FANOUT,
computed once per map in loadMap, so a selection that hides callees never renumbers the rest;
the number is drawn only when a node has two or more calls. Rows are not reordered by rank --
the barycentre sweep is what untangles the links. The panel's Calls list shows the same glyph
(callRows, callGlyph), calls first in rank order, every other outgoing link after them, with
branch / loop word badges and the call's line as the glyph's tooltip. nodeItem keeps its one
argument on purpose: every list calls it through .map(), which passes the index second.
Colour rules
One meaning, one colour, everywhere — a reader learns the scheme once, from any view.
- Test code is green (
TEST_COLORinviewer.html): the node (LAYER_COLORS.test), its folder area, the legend, all the same green. It is deliberately kept out ofFOLDER_COLORS, so an ordinary folder can never be handed it. - A folder's colour comes from its index in
allFolders— every folder in the map — never from the visible list. Colouring off the visible list meant hiding the test folder re-coloured everything after it, and pink stopped meaning the same folder from one screenshot to the next. - A new
layer/kindvalue needs its colour inLAYER_COLORSin the same commit that adds it totaxonomy.py, and a line inLAYER_KEYsaying what the role is (a new link type needs itsLINK_KEYrow the same way). The legend, the node painter and every view read from there; nothing hard-codes a colour at a call site. The one exception is a class kind (interface,abstract): a node's colour already means its layer, so a class kind is a shape indrawNode(hollow / dashed ring) and in the legend (.kind-interface/.kind-abstract). - Languages are coloured by family (
LANG_COLORS, the rail's language-mix bar): JVM (Java/Kotlin/Scala/Groovy), .NET, native (C/C++/Rust/Swift), dynamic (Ruby/PHP/Elixir), Go, Dart, plus the original five for Python and JS/TS. Seventeen distinguishable hues on this ground do not exist, and the label beside each swatch names the language. The family hues sit outsideFOLDER_COLORSand the layer palette, so none of them means two things. - The chrome is deliberately quiet so the data can be loud: near-black ground
#08090b, hairline rules#1b1f26, one accent (amber#d99f4a) for the active state and nothing else, and a system monospace stack. No emoji anywhere in the UI — icons are inline SVG on a 16px grid. Keep it that way; the palette below is the only saturated thing on screen. - Already spoken for: controller/endpoint pink
#f778ba, service blue#6ea8fe, repository green#3fb950, model amber#e3b341, client teal#39c5cf, config purple#a371f7, ui orange#f0883e, test green#57ab5a, unknown grey#8b98ad. Pick something distinguishable from all of them on the dark background — and if two must be close (the two greens are), keep them in different channels: node dots vs folder areas. - Edges are a channel of their own, and every link type has its own hue and its own dash,
all in one table (
LINK_STYLE, read by the canvas, the matrix and the legend):callsgreyrgba(139,152,173,.22)solid,httppink#f472b6dashed,rendersviolet#a78bfadotted,passessky#38bdf8dash-dot,implementsyellow#facc15long-dashed,extendsorange#fb923clong-dashed andoverridesthe same orange short-dashed -- one relation at two levels, one hue. Until the user found the key unreadable,implementswas light grey beside grey calls andextendsa faint sand; every hue now sits away from the highlight green and the risk red, and the legend draws each swatch at full strength (legible(), alpha floor .9). Calls stay faint on the canvas because they are most of the lines. Pink, yellow and orange also live on node dots (controller, model, ui), a different channel. - A call badge is shape, never colour, like a class kind: circle (written Nth), diamond (only
inside a branch), ring (inside a loop). On the canvas it strokes in the link's colour while the
link is highlighted, else
#39414fwith a#8b98adlabel, quiet beside the faint call line; in the panel and the legend (callGlyph) it is#39414f, legible on the dark ground.
Layout rules
The explorer is a three-pane desktop app, and it behaves like one: chrome holds still, content scrolls.
- One scroll region per pane, never two nested. The left rail scrolls in the file tree only;
the right panel scrolls in its body only. If something does not fit, fold it or shrink it —
never add a second scrollbar. The one exception is the colour legend (
#legend), capped atmax-height: 132pxand scrolling: in folder mode it is one entry per folder, so a real repository's ~120 folders pushed Census, Explorer and the tree out of the pane with no way to reach them. It is a bounded list inside a section, not a pane, and the other three colour modes are a handful of rows that never reach the cap. - There is one overlay other than the toolbar menu: the legend (
#keyOverlay). It isposition: fixedover the whole app, so it is not a pane and its single scroller does not break the rule above. Anything else that needs explaining belongs in it, not in a new panel. - The node search lists, it never scrolls.
#resultsisposition: fixedunder#search, because the rail'soverflow: hiddenwould clip it, and shows up to 12 matches (fewer when the box sits low in a short window) plus a count -- a scrolling dropdown would be a second scroll region in the rail. - A pane is a flex column:
overflow: hiddenon the pane,flex: noneon the fixed blocks,flex: 1; min-height: 0on the one region that grows, andmin-height: 0again on the scroller inside it (without it the scroller inherits its content's height and the pane scrolls instead). Give the growing region amin-heightfloor so it cannot be squeezed to nothing. - Rail sections fold from their own
h3, with the marker in::beforeand the body hidden by afoldedclass on the section. Folding is how the user gives the tree room, so anything bulky in the rail needs a header. A folded header still carries its count (Explorer 7 files) — a section that says nothing when closed is a dead control. - Fold state is a class on static markup, so it survives every re-render and map switch by
construction. Do not store it in a variable that
applyMap()resets. Rail width works the same way —--lw/--rwset inline on#appby a drag, and nothing re-renders#app. - The rails resize, and that is all they do. Two 9px grips (
.rsz) straddle the pane borders. There is no collapse: it was built, and taken out again as a control nobody needed. The explorer is not resizable either — it takes every pixel the fixed blocks leave, and folding a section is how you give it more. - A rail may never eat the toolbar.
setRailcaps a drag at what the centre still needs, measured by summing the toolbar's children —clientWidthwould report "exactly what it already has" and let a drag ratchet controls off the right edge a pixel at a time. The cap counts only what the compact row needs (~560px): the toggles are left out ofstageMin()because they can move. With both rails at their minimum the toolbar never clips above about 1020px wide -- the supported floor (987px when measured in phase 5; theFlowchartlabel added 33px) (it was ~1270px, and ~1360px before that). - The toolbar compacts rather than clipping. When the toolbar is narrower than the full row
(~884px since
Freezejoined the row; ~803px before),fitToolbar()re-parents#toggles(Folders, Blast radius, Freeze, Tests) into#menuTogglesat the top of the⋯menu, and moves it back as soon as there is room. It moves the same elements, so ids, checked state and handlers travel with them; aResizeObserveron the toolbar drives it. Clicking a toggle inside the menu leaves the menu open. - A toggle is shown only where it does something. Folders draws areas in Graph and Cluster
only (
drawHulls), sosetViewhides#hullsTogglein the other five views and re-runsfitToolbar()-- showing it again widens the row, which no resize reports. Order (#orderToggle, checked by default) is the same rule the other way round: shown only in the Flowchart and only on a map with calls, which is exactly where Folders is hidden. The two are never in the row together, and Order measures 74.6px against Folders' 87.3px, so the Flowchart's row is narrower than Graph's and the thresholds below still hold. It hides the canvas badges only; the panel keeps its glyphs. - The toolbar row holds only what is used constantly. Zoom in/out, fit and PNG export live in
the
⋯overflow menu (#more/#moreMenu), which took the full row from ~897px to ~770px. A new control goes in that menu unless it is used on nearly every visit. The menu items keep their old ids (zoomIn,zoomOut,zoomFit,png), so no handler depends on where a control is drawn. - Below
max-height: 620pxthe rail gives up and scrolls as a whole — a 60px tree is worse than a scrollbar. - Dragging a node pins it (force-graph sets
fx/fyand leaves them), soresetLayout()is the only way back. It deletesx/y/vx/vyas well as clearing the pins, becausesetViewalone only unpins and reheats — the simulation would restart from wherever the nodes were dragged. It hangs off the existing toolbar reset rather than a button of its own -- a new toolbar button would cost the row width the overflow menu was introduced to win back.
Hard constraints
- One parser per language, and every one of them degrades. Python 3.10+.
Every language is tree-sitter, Python included: the runtime plus the wheel for that
language (
tree-sitter-python,tree-sitter-javascriptfor.js/.jsx,tree-sitter-typescriptfor.tsand.tsx,tree-sitter-java,tree-sitter-go,tree-sitter-c-sharp, and since phase 7 one each for Kotlin, Rust, Swift, Scala, Groovy, Dart, C, C++, Ruby, PHP and Elixir — all pinned ingrammars.PINS) — wheels, no compiler, grammar bundled. Python no longer parses out of the box, and that is the promise phase 2 knowingly traded away: one engine, at the cost of "zero Python dependencies". Node is not used at all: it is not required, not checked for, and not installed. The skill has nopackage.json. Grammars are installed on demand, not shipped: a repo with no Go pays nothing for Go. Every dependency installs inside the skill folder, never into the user's environment.pip install --only-binary :all: --no-cache-dir --target vendor→<skill>/vendor, which is git-ignored, cannot collide with the user's own versions, and disappears with the skill folder. Keep all three pip flags:--targetis the point,--only-binary :all:refuses to compile,--no-cache-dirstops pip writing wheels outside the folder. Becausevendor/is built for one interpreter version, a Python upgrade breaks it — that must surface as a named message telling the user to re-run the install, never as anImportErrortraceback. Every one of those is optional at runtime and must fail the same way: warn by name, skip those files, still build the rest. A missing parser may never be silent, because a graph that is smaller for want of a wheel is indistinguishable from a graph of a smaller codebase — which is whymanifest.pyrecords the installed grammar set andcheckreports a change to it as staleness, and whybriefprints aSKIPPEDblock naming the exactpip install. That end state was reached in phase 2 (docs/ROADMAP_PLAN.md): one engine, tree-sitter, withastkept only as a test oracle and Node gone. Do not add a second engine — a new language is a grammar wheel plus aSPECrow, never a new parser. - Deterministic. Same source in, same bytes out -- graphs and reports: no artifact carries
a wall-clock time (
tools/check_regressions.pyr19 runs the report twice and diffs it). AI text enters only throughapply_descriptions.py(cached by source hash, docstring wins first, deterministic fallback last). - Paths resolve from the skill root, so every script runs from any working directory.
- The explorer is one self-contained file, with no network at all. Data and the graph
library are embedded inline; it opens from
file://with the network disabled.force-graphis vendored attemplates/vendor/(see its README) and inlined bybuild_html.pythrough the__VENDOR_JS__placeholder. Nothing may reintroduce a<script src>,<link href>or@import—bin/cli.js --self-testfails the build if one appears. URL-shaped strings are fine and unavoidable (SVG/XML namespaces are identifiers the browser never fetches), so assert on resource loads, never on the substringhttp. - Windows-first testing. Console is cp874 here: keep
print()output ASCII (files can be UTF-8). Anything that echoes repo text (node ids, descriptions, paths, git author names) callsconsole.safe_stdout()first, so one accented author name cannot end a run. Bash heredocs mangle backslash-continuations — use the Edit/Write tools for content with\line continuations.
Verify changes
Always run the full pipeline against the bundled sample, from the repo root:
python .agents/skills/code-archaeologist/scripts/archaeologist.py both --src ./sample_src
python .agents/skills/code-archaeologist/scripts/archaeologist.py report --src ./sample_srcExpected on the current sample (it carries three deliberate smells -- a hardcoded key, interpolated
SQL, an innerHTML sink -- plus a pytest/unittest file, a .test.ts, a .tsx with two React
components, four API frameworks, and Java/Go/C# with two deliberate hard cases, so the review path, the test path, the component path, every route shape and the
"drop rather than guess" rule all have something to find):
- structure graph: 25 nodes / 23 edges -- 7 Python, 6 JS/TS, 12 from Java/Go/C# (6 Java,
3 Go, 3 C#), of which
OrderCardandStatusBadgearekind: component/layer: ui. Structure nodes carry noprecision: their edges are 21 references and 2 stated bases (implements:FlatRateandTieredRate->PricingRule), and a reference from a declared field is resolved. No node islayer: unknown-- the three pricing classes takeservicefrom theirservices/folder (r79), which is the only thing that rule changes here. Exactly one node is notkind: classamong the classes:PricingRuleiskind: interface; noextendslink, nooverrideslink and nooverriddennode exist in the sample, so r61 asserts those - flow graph: 53 nodes / 36 edges (29
calls, 4http, 1renders:OrderCard -> StatusBadge, 2implements:FlatRate.priceandTieredRate.price->PricingRule.price), 16 endpoints, 0 pending descriptions; 4 nodes carryunresolved(all four nameget, and all four are the honest over-count -- axios'sapi.get, a JavaMap.get-- which is why the field is a place to look, never an edge); one nodedeclaration: true(PricingRule.price); 3 nodes carryprecision-- 2name-matched(getOrderEventsandgetOrderStatus: each drops axios'sgetthrough an untyped receiver, so each carriesuntyped: [get]), plus exactly one earned by an edge:OrderWorkflow.place->interface-dispatch(it calls the declaration). If it moves, a named marker has stopped working.overloadsmarks no node in the sample -- both calls toTotalpick their overload -- sotools/check_regressions.pyr33 is where that marker is asserted; likewise no node carriesentry: initand no Spring injection is settled (neither rate is a bean), so r53 and r54 assert those - call sites: all 29
callslinks carryline; exactly 1 carriescond--orders -> OrderService.place_order, insideif request.method == "POST":, so it also carriesarms: ["19:5/0"]-- and none carriesloop:handleOrderEvents'range service.Events(...)is a loop header, which runs once.r81asserts a loop, and the rest of both rules, on its own fixture. Three nodes rank two calls:OrderService.place_order(chargeL15,saveL16),InvoiceService.Issue(TotalL16,PutL17),OrderWorkflow.place(priceL20,saveL21) - routes, one per framework shape: FastAPI
OrderController.create_order; Flaskorderswith two entries (GET+POST /legacy/orders) andorder_detail(<int:order_id>); ExpresscreateOrderHandler(named handler) and the endpoint nodeGET /orders/:id/status(inline arrow); NestOrdersController.{create,findOne}under the@Controller("nest/orders")prefix; SpringOrderApiController.{create,findOne}under@RequestMapping("/java/orders"); ASP.NETInvoiceController.{Create,Find}under[Route("cs/[controller]")]->/cs/Invoice; GohandleOrderEvents/recordOrderEvent(named) andGET /go/healthz(inline literal) - traces:
create_order -> place_order -> {save, charge},get_order -> find_order -> get, the Flask handlerorders -> place_order -> save, and one per Java/Go/C# language --OrderApiController.create -> OrderWorkflow.place -> OrderArchive.save,InvoiceController.Create -> InvoiceService.Issue -> InvoiceStore.Put,handleOrderEvents -> EventService.Events -> EventStore.List - cross-stack:
submitOrder -> createOrder -> OrderController.create_order -> ..., andloadOrderHistory -> getOrderEvents -> handleOrderEvents -> ...all the way into Go; structureOrderCard -> {ApiClientModule, StatusBadge} getOrderStatus -> GET /orders/:id/statusis the suffix fallback: the call is/api/orders/:id/status, the router registers/orders/:id/status, and it links because exactly one route matches- the two deliberate hard cases, both now resolved:
- interface dispatch — resolved to the declaration.
OrderWorkflow.placecallspricing.price()through thePricingRuleinterface, and the edgeOrderWorkflow.place -> PricingRule.pricenow exists, because a declaration-only member is extracted as a node (declaration: true, empty body,endat the end of the signature). The call edge stops at the interface: no call edge is emitted toFlatRate.priceorTieredRate.price-- picking one impl would be a guess and emitting both would trade the precision guarantee for recall. Each carries animplementslink toPricingRule.priceinstead, so neither is an orphan and a trace fromOrderApiController.createreaches both. - overloads — every overload is its own node (finding #8).
InvoiceService.Total(InvoiceRequest)andInvoiceService.Total(int,int)each have their own range;Issue'sTotal(request)picks the first by argument count, and the first'sTotal(request.Units, request.UnitPrice)picks the second -- one edge more than when the pair was one node, which carried both bodies' calls but only one body's range. - The structure map does show
OrderWorkflow -> PricingRule-- a declared field is a real reference even when the dispatch is not resolvable.
- interface dispatch — resolved to the declaration.
- a
declaration: truenode is a signature, not code:duplicates.pyskips it (no body, no token shape) andanalyze.pynever calls it dead code (there is nothing in it to delete). Both guards are load-bearing, not decorative -- an uncalled declaration has no caller and would otherwise be reported as an orphan. - grades: structure C (73), flow C (70); 4 risk findings each; 2 debt markers. Structure
fell from C(71) to D(69) when the interface impls were added -- the hard case being honest, not
a regression -- and rose to C(73) when their bases became
implementslinks, leaving one structure orphan,OrderPageModule. Flow fell from D(69) to D(68) when the planted clone below was added -- nothing calls it, so it is one more orphan -- and rose to C(70) whenimplementslinks tookFlatRate.priceandTieredRate.priceoff the orphan list, leaving 5 flow orphans, all JS/TS. - duplicates: 1 cluster, 2 nodes, 4 duplicated lines, 0 copied blocks (the planted pair is a
whole-body clone, so it is a cluster and never repeated as a block) --
createInvoiceiscreateOrderwith every identifier renamed, planted infrontend/api_client.tsso the clone pass has something to find. Renaming a variable in one copy must keep them clustered; changing an operator must split them. - tests: 2 test files, flow 4/49 nodes named by a test, and the two test nodes carry
layer: testwith call edges intoOrderService.place_order/OrderRepository.get - metrics: 52 of 53 flow nodes and 19 of 25 structure nodes measured, across every
language; the unmeasured are exactly the declaration
PricingRule.priceand the six*Modulegroups, which have no range - 529 lines across 22 files (py 126, java 102, csharp 90, ts 90, go 66, js 28, tsx 27)
archaeologist.py check --src ./sample_src->stale: falseright after a build
Every grammar is optional and each one degrades the same way -- rename it out of vendor/, and
the build must still succeed with one named warning carrying the exact install command:
| Grammar removed | Warning | Structure | Flow |
tree_sitter_javascript + tree_sitter_typescript | frontend skipped, naming both wheels | 19 / 19 | 37 / 23 |
tree_sitter_python | python skipped, once for the whole run, not once per map | 18 / 12 | 39 / 21 |
Two checks stand in for the extractors that were deleted:
python tools/check_py_oracle.py # must print: OK 0 disagreements
python tools/check_py_oracle.py .agents/skills/code-archaeologist/scriptsand the byte-identity of the committed graphs — rebuild, then git diff on
data/structure/graph.json and data/flow/flow_graph.json must be empty. That is the strongest
check available and it is what proved every port in phase 2: the JS/TS one, the Python one, and
metrics.py, none of which moved a single byte of either graph.
Other checks worth running when you touch the relevant part:
python -m compileall -q .agents/skills/code-archaeologist/scripts # syntax
python tools/check_docs.py # docs vs code
python tools/check_langs.py # every language still graphs
python tools/check_py_oracle.py # ast vs tree-sitter on Python
python tools/check_graph.py # the graph deserves trust
python tools/check_graph.py --self-test # ...and every check can fail
python tools/check_regressions.py # every past silent failure
python tools/time_build.py [<corpus> ...] # wall time per stage, read-only
node bin/cli.js --harness claude --target <tmpdir> --self-test # installertools/check_graph.py asserts what a correct graph must satisfy, on any built graph: every
edge lands on a node, ids are unique, each node's range really contains its own name, each call
edge's callee is really named inside its caller, precision is what the edges imply, the report's
counts are the graph's, and every security finding lies inside the node it is attributed to, and no two nodes'
notes are one file on a case-insensitive filesystem (c17), and every call dropped as ambiguous
names a real overload set (c18), and no declaration has a call link unless its own range spells the
callee inside a MapStruct java( (c08), and every renders edge's <Name is written in its caller (c20), and every passes link's function is named in its caller (c23), and every implements link
joins one method name on two classes whose files name each other, directly or within three hops,
or in the structure map is a base its class names, and whose type matches its ends --
implements into a declaration or an interface, overrides into a body, extends otherwise
(c21), and every overridden node has an overrides link into it and a body (c22), and every
call link's line lies in its caller and names the callee there, with loop / cond written only
as true and never on a link that is not a call, and every arms entry is a branch that starts
inside the caller, only on a cond link, outermost first (c24). Its
D checks test the analysis metamorphically — inject a cycle, an orphan, a hub, a god object or
a layer violation into a copy of the real graph and require it to be reported — because a detector
that returns nothing looks exactly like a clean codebase. --self-test breaks the input (or swaps
an analysis function for a broken one) once per assertion and fails if the check stays quiet: an
assertion that cannot fail is worse than none. tools/check_regressions.py holds one named case
per silent failure this project has had. Both are repo tools; neither ships.
tools/check_langs.py is the one to run after touching any extractor. Each supported language
has a fixture under tests/fixtures/langs/<lang>/ — a store, a service calling it through a
declared field, a route, and a test file — and a row of expectations in expected.json. The
edges are the assertion that matters: nodes alone only prove the grammar loaded, an edge proves
receiver resolution worked. --update rewrites the expectations from reality, which is how they
were recorded in the first place, and will just as happily bless a regression — read its diff.
Fixtures live outside sample_src/ on purpose: that directory ships to users and its numbers are
pinned by the prose block above, so every language added there means rewriting all of it by hand.
A fixture costs one directory and one row. tests/fixtures/ts_imports/ is not a language row:
it is a minimal Next.js app pinning import resolution and file conventions, asserted by
check_regressions.py r42. Current expectations, all asserted:
| Language | Nodes | Edges | Routes | Test node |
| python | 8 | 3 | 1 | yes |
| java | 6 | 3 | 1 | yes |
| csharp | 6 | 3 | 1 | yes |
| go | 7 | 3 | 1 | yes |
| javascript | 6 | 3 | 1 | yes |
| typescript | 6 | 3 | 1 | yes |
| kotlin | 6 | 3 | 1 | yes |
| rust | 6 | 3 | 1 | yes |
| swift | 5 | 2 | 0 | yes |
| scala | 5 | 2 | 0 | yes |
| groovy | 5 | 2 | 0 | yes |
| dart | 5 | 2 | 0 | yes |
| c | 5 | 2 | 0 | yes |
| cpp | 5 | 2 | 0 | yes |
| ruby | 7 | 2 | 0 | yes |
| php | 6 | 2 | 0 | yes |
| elixir | 5 | 2 | 0 | yes |
| django (route table) | 6 | 1 | 5 | no |
| rails (route table) | 5 | 1 | 5 | no |
| laravel (route table) | 5 | 1 | 5 | no |
| phoenix (route table) | 5 | 1 | 5 | no |
The eleven phase-7 rows have no controller where the language's route shape is read elsewhere
(Ruby, PHP, Elixir route tables are phase 8) or not at all (Swift, Scala, Dart, C, C++ -- a
stated boundary in the README), hence 2 edges and 0 routes. Ruby's 2 edges are the honest
sparse case: @store.save is dropped (an instance variable carries no type) while the
same-class validate call and the test's WidgetService.new(...) call are kept.
Each also asserts every node's line and doc, and each fixture carries 2-, 3- and 4-byte UTF-8
before its nodes plus one non-ASCII node name (größe) — phase 6b, so a byte offset applied to
decoded text cannot shift a name silently. And each asserts every node's metrics
(complexity, depth, params); each fixture's grade is counted by hand in its own comment —
complexity 6, depth 2, 2 params, in all six — so that row is checked against a person rather than
only against the recorder (phase 6a).
TypeScript's 3 edges used to be 0: JS/TS matched calls by name only, so JavaScript's bare
function calls linked and TypeScript's new WidgetStore().save() did not. The receiver is kept
now, so a call resolves wherever the source states the class. What still drops is a receiver with
no stated type -- other.save() on an untyped parameter -- which is why a JS/TS node that drops
one earns name-matched, and why tools/check_regressions.py r34 asserts both halves: the four shapes that
resolve, and the untyped one that must not.
For templates/viewer.html, extract the inline <script> and parse it as a classic script
(new vm.Script(code)) — node --check wraps input in a CommonJS function, so it accepts top-level
return that a browser would reject. Then load data/explorer.html in a browser and exercise:
map switch, all seven views, explorer filter, blast toggle, tab drill-through.
The in-app preview pane does run the page's own script (verified 2026-09-11; an earlier note
here said it did not). Do not re-run it with (0, eval)(document.scripts[1].textContent). An
indirect eval keeps the app's top-level let/const private to itself while its functions
overwrite the page's, so you get a second, shadow instance: handlers update the shadow's
nodes/EDGES while a probe reads the page's untouched originals. In phase 3 that produced a
convincing fake bug — the Tests checkbox "doing nothing" — which a clean probe then disproved.
Probe in the page's own scope by bare name instead: nodes.length, EDGES.length,
selectNode(id), setView(v), el("reset").click().
If you changed sample_src/, regenerate the committed example data (both maps + both reports) in
the same commit — the repo ships it as the worked example.
Working principles
1. Think Before Coding
Don't assume. Don't hide confusion. Surface tradeoffs.
LLMs often pick an interpretation silently and run with it. Force explicit reasoning:
- State assumptions explicitly — if uncertain, ask rather than guess.
- Present multiple interpretations — don't pick silently when ambiguity exists.
- Push back when warranted — if a simpler approach exists, say so.
- Stop when confused — name what's unclear and ask for clarification.
2. Simplicity First
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If 200 lines could be 50, rewrite it.
The test: Would a senior engineer say this is overcomplicated? If yes, simplify.
3. Surgical Changes
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it — don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
4. Goal-Driven Execution
Define success criteria. Loop until verified.
Transform imperative tasks into verifiable goals:
| Instead of… | Transform to… |
| "Add validation" | "Write tests for invalid inputs, then make them pass" |
| "Fix the bug" | "Write a test that reproduces it, then make it pass" |
| "Refactor X" | "Ensure tests pass before and after" |
For multi-step tasks, state a brief plan:
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]Strong success criteria let the model loop independently. Weak criteria ("make it work") require constant clarification.
5. Push the work into a script, not into the context
The skill exists because deterministic scripts are cheaper than an LLM reading files — and that
applies to building it too. Before answering a question about this codebase by reading source,
ask whether a script (or an existing tool: git, python -c, the skill's own commands) can
produce the answer once, for every future session.
- Reach for a script or an existing tool first; write ad-hoc analysis in the terminal, not in prose you would have to re-derive next time.
- If you find yourself reading many files to answer one question, that question wants a script.
- The runtime rule still stands: the skill ships the standard library plus the tree-sitter wheels constraint 1 names, and nothing else. "Use a library" means use what is already on the machine while developing, never add a dependency to the skill.
- A new script pays for itself the second time it runs. A one-off shell pipeline is fine; copy it
into
docs/USAGE.mdif it will be wanted again.
6. Keep the docs in the same commit
Every document in this repo describes the skill to some reader. When behavior changes, they all move with it — in the same commit, not in a follow-up that never comes.
There are two disciplines, and confusing them destroys the two files whose entire value is that nobody rewrites them.
Mirror current truth — rewrite freely, so the file matches how the skill behaves today:
| File | Reader | Goes stale when |
AGENTS.md | the next session working on the skill | the pipeline, script inventory, layout, constraints, colour/layout rules or the expected-numbers block change |
SKILL.md | the agent using the skill | a command or an operating rule changes |
README.md | a human evaluating/installing it | features, language table, requirements or the structure tree change |
docs/USAGE.md | a human running it by hand | any command's form or flags change |
templates/TAXONOMY.md | anyone adding a field value | a kind/layer/severity/grade value changes |
docs/ARCHITECTURE_GUIDE.{html,md} | someone learning how the 30 scripts fit together | a script is added/renamed/moved, an inter-script call changes, or a function it names by hand is renamed |
docs/ARCHITECTURE_GUIDE.th.html | the same reader, in Thai | the English guide changes — it is a translation, so it goes stale silently |
ARCHITECTURE_GUIDE.html is one flowchart, deliberately: BUILD (parses source, rewrites
artifacts) and QUERY (reads artifacts, never opens a source file), with check as the hinge
between them. It carried three overlapping taxonomies of the same six commands until 2026-09-12 —
5 "scenario" diagrams, 7 "execution flows" and 3 "operational scenarios", in which Flow 1 meant
two different things. If a command needs explaining, it becomes a chip that opens its script
sequence in the existing sidebar (COMMANDS_DATA / openCommand()); it does not become a
second diagram. Both files name functions by hand and nothing checks them — eight were invented
before anyone noticed (finding #12).
ARCHITECTURE_GUIDE.th.html is the Thai translation of the .html, structurally identical —
same ids, same classes, same handlers — so a structural change must be made in both, and
fc-box / fc-cmd-chip / fc-phase counts are expected to match exactly between them. Two
house rules govern the Thai text -- they came from docs/PRESENTATION.html, the Thai slide deck
removed on 2026-09-16, and outlived it: every technical identifier stays in English
(script and function names, kind/layer values, algorithm names like Winnowing and Tarjan's
SCC, CLI flags — Thai is the connective prose around them), and fonts are system-only
(IBM Plex Sans Thai, Noto Sans Thai, Leelawadee UI, Sarabun, Tahoma) at
line-height: 1.85, because the page must open offline. Nothing may add a webfont @import.
The one deliberate difference: the Thai file ends with a judge Q&A bank (#judge-qa, after
the script cards) that the English file does not have. It is rehearsal material for the Thai
presentation, not part of the guide's structure, so it uses only qa-* classes and the fc-*
counts above still match. Its answers quote numbers and finding statuses by hand — when the
expected-numbers block, a check count or a ROADMAP finding changes, grep the QA.push blocks for
the old value in the same commit.
Append, never revise — these are records of what was actually done and thought at the time; editing them to match the present is the one way to make them worthless:
| File | Discipline |
docs/PROJECT_HISTORY.md | extend with new phases; never rewrite a past entry to agree with the present |
docs/prompt.md | append the turn verbatim at the end of every turn (principle 8) |
This file is not exempt. AGENTS.md describes the repo to its next session, so when the repo
changes, AGENTS.md changes in the same commit. It has drifted before precisely because it was
the one doc outside its own rule — its pipeline diagram lost brief and nobody noticed.
A new script also needs: a docstring saying what it is and why, a line in the README structure
tree and in this file's script layout, a numbered command in SKILL.md if the agent should
call it, and its command form in docs/USAGE.md.
The mechanical half of this rule is checked, so it cannot quietly rot:
python tools/check_docs.pyIt verifies that every script is listed in README.md and named in AGENTS.md, that every
scripts/... path quoted in any doc actually exists, and that every kind/layer value in
taxonomy.py is documented in TAXONOMY.md. It deliberately checks facts, never prose — keeping
the words honest is still the writer's job.
7. Fix what you find, or write it down — never just mention it
Implementing one thing surfaces others: a stale claim in a tooltip, a number that contradicts a doc, an artifact that is not as deterministic as the constraint says. Saying so in a chat reply and moving on is the one option that is always wrong — the observation is gone the moment the session ends.
Two outcomes, and which one applies is decided by whether a person has to choose something:
- Fixable without a decision — fix it now, in the same commit, and say so. A claim that is simply false, a message that misdiagnoses, an orphan your own change created: there is one right answer, so asking for it is just latency. Principle 3 still binds — fix the thing you found, not its neighbourhood.
- Needs a judgement call — record it in
docs/ROADMAP_PLAN.mdunder Found while implementing, with what it is, why it is not obviously fixable, and a recommendation. It is reviewed with the phase, not mid-flight. Anything that changes output format, drops a user-visible field, or trades one guarantee for another belongs here.
The test for which bucket: if I fix this my way and the user disagrees, have I destroyed something? If yes, write it down. If no, fix it.
8. Log every exchange to docs/prompt.md
This repo keeps a running transcript of its own construction. At the end of every turn, append
that turn to docs/prompt.md — the user's prompt verbatim, then what you did and said.
## [N] YYYY-MM-DD — <short title>
**Prompt**
> <the user's message, verbatim>
**Response**
<what you did: decisions made, files touched, commands run, what you found, what you told them>Rules that keep the log worth having:
- Append, never rewrite. Earlier entries are the record of what was actually thought at the time; correcting them retroactively destroys the only reason to keep it.
- Write it contemporaneously, at the end of the turn, while the reasoning is still exact. A transcript reconstructed later is a summary, and should say so.
- Append it before you commit, never after. The entry is part of the change, so it belongs in
the same commit as the work it describes —
git add docs/prompt.mdalongside everything else. A log written after the push is a second commit that nobody makes, which is how turns go unrecorded; and even when it does land, the entry is then separated from the diff it explains. If a turn ends without a commit, the entry is still appended — the trigger is the end of the turn, not the commit. - Record the reasoning and the misses, not just the diff — why an approach was chosen, what a verification actually returned, and anything that turned out wrong. Git already stores the diff; the log's value is everything git cannot show.
- Number entries sequentially and keep them in chronological order.
- It is a plain log, not a doc for a reader: no need to keep it in sync with behavior the way
principle 6 requires of
SKILL.md/README.md/docs/USAGE.md.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

