Document stack
Turn what a project is built on into documentation that serves its two readers: the agents that will work in the repo (via AGENTS.md, which points at docs/stack.md, which points at docs/stack-notes.md) and the humans who need to get it running (via the README). Document from the stack-it lockfile when the pipeline produced one; infer the stack from the codebase when it didn't. Either way, derive every document from one resolved picture of the stack so they can't drift apart, and write them so a later run refreshes them cleanly.
Step 1: Check the layout format
Do this before reading the stack. Writing format 2 files over a format 1 project leaves it with two layouts at once, and the fix costs more than the check.
Detect the format
Ownership comes first, before any signal is read. docs/stack.md and docs/stack-notes.md are stack-it's only if each is a regular file whose first line starts with > Generated by stack-it document-stack, format N. Match on that prefix alone: the sentence after it changed between versions, so a whole-line comparison would read every older file as somebody else's. A symlink at either path, or a first line that doesn't start that way, is not ours: stop there, show the user what is in it, and ask before touching anything. The order matters. A lockfile with no format key sitting beside a hand-written docs/stack.md reads as a format 1 project, and acting on that signal would start a migration across the very file the ownership rule exists to protect.
Once both docs files are ours or absent, read every signal, then take the highest. Don't stop at the first one you find.
docs/stack.mdanddocs/stack-notes.md: theNon the header line of each owned file..claude/stack-it/stack.yaml: its top-levelformatkey. A lockfile with no key reads as 1.CLAUDE.md: a<!-- stack-it:stack start -->marker reads as 1. ReadCLAUDE.mdfrom disk, since Claude Code strips HTML comments from loaded context and the marker won't be visible in aCLAUDE.mdyou already have in context. The start marker alone is enough to read the signal; removing the block still needs the full pair (see Marker integrity). A marker inside a fenced code block is not a signal, the same as in Marker integrity.
No signal at all means nothing has been written yet, so there's nothing to migrate.
The current format is 2. If any signal reads higher than 2, stop and write nothing: a newer stack-it wrote this project, so tell the user to upgrade the plugin rather than downgrade their files. A newer decide-stack can stamp format: 3 on the lockfile while docs/stack.md still says 2, which is why the highest signal wins and the first hit doesn't. Otherwise the project's format is the highest signal you read.
Marker integrity. Before removing or replacing anything between markers, confirm the file holds exactly one <!-- stack-it:stack start --> followed by exactly one <!-- stack-it:stack end -->. Markers inside a fenced code block don't count; those are an example of a block, not a block. If a marker of either kind is there without its partner, the two are out of order, or there's more than one pair, remove and replace nothing: show the user the offending lines and stop. In an owned docs/stack.md or docs/stack-notes.md, no markers at all fails the same way: format 2 always writes a pair, so a file carrying our header and no markers is malformed for this layout rather than empty of a block. Show it to the user and stop. In CLAUDE.md and the README, no markers means there is simply no block yet, which is normal: skip the removal step and continue. This governs every file stack-it replaces content in: docs/stack.md, docs/stack-notes.md, CLAUDE.md, and the README.
This detection is shared: document-stack Step 1 and migrate Step 1 must carry the same rules. Change them in both places or in neither.
If the project is behind
Run Marker integrity on the README, and on both owned docs files, before you hand off. Those are the files Step 4 replaces content in, and a bad pair in any of them, or no pair at all in an owned docs file, stops this run. Finding that out after migrate has restructured the project leaves the layout moved and the content unwritten, which is the state this ordering exists to avoid.
Then invoke the stack-it:migrate skill, let it restructure the files and pointers, and carry on here once it returns. Don't restructure an old layout yourself. migrate ends its report with either migrated to N or stopped: <reason>. If it stopped, stop too and relay its reason; a half-migrated project is not one to write into.
Verify the layout at format 2
A format number says which layout the project uses, not that every piece of it landed. When the format is 2, check all of it. Run Marker integrity on every owned docs file that is present FIRST, before deciding any piece is missing: a marker of either kind without its partner, a reversed pair, a second pair, or no markers at all stops the run with the file shown. Doing that check first is what stops a malformed docs/stack.md from getting a freshly stubbed sibling written beside it. Then check the rest: no <!-- stack-it:stack start --> block in CLAUDE.md, docs/stack.md present, docs/stack-notes.md present, the plain-path pointer sentence to docs/stack-notes.md inside docs/stack.md's markers, the pointer present in AGENTS.md, the bridge present (or a recorded decline, or a symlink exception), and the format stamp present when stack.yaml exists. A docs/stack.md that went missing while AGENTS.md still points at it is migrate's to restore as a stub; a docs/stack-notes.md missing beside an owned docs/stack.md is not a stop either, since migrate stubs it and document-stack writes it on the next pass. A docs/stack.md whose section has lost the pointer sentence is migrate's to fix only when that section is one of its own stubs, which it rewrites whole; a real section missing the sentence is a piece for document-stack to put back on its next render, and the report names it rather than editing it. Two things are reported layout exceptions rather than missing pieces, and neither is ever written through: an AGENTS.md symlinked outside the project, whose target carries no pointer, and a CLAUDE.md symlinked outside the project that still holds the old block. Reading through such a symlink is safe, so a block at the target is still carried; only the cut is skipped, and the report says the block is still sitting there. Content missing from inside either docs file is not part of this check; regenerating that is document-stack's job, never migrate's. A project with no pointer, no bridge, and neither docs file has never been documented: nothing is broken, so a standalone migrate is a no-op that points the user at document-stack, and document-stack carries on and writes them for the first time.
If any piece is genuinely missing, invoke stack-it:migrate, which re-runs only the step that produced it, and carry on once it returns; if it reports stopped:, stop too and relay the reason. A leftover old block at format 2 is migrate's to carry below the end marker of docs/stack-notes.md and then cut, never yours to remove and never anything to fold back into a managed section. Don't re-run a migration step yourself: restructuring is not this skill's job, and the lockfile stamp is not this skill's to write.
Step 2: Get the stack (lockfile first, else infer)
If .claude/stack-it/stack.yaml exists, that's the source of truth. Validate it with ${CLAUDE_PLUGIN_ROOT}/scripts/validate_yaml.py --stage stack .claude/stack-it/stack.yaml. If validation fails, stop and show the user the errors; never fall back to inferring from the codebase when a lockfile exists, because a broken lockfile is a thing to fix, not to route around. On a pass, read the project, each slot's choice/version/caveats/notes, and the install order. It's already pinned and vetted, so trust it. A quick sanity glance at the codebase is still worth it: if the lockfile and the actual code obviously disagree (a tool the YAML lists is nowhere in the manifests, or vice versa), flag the discrepancy to the user rather than documenting a fiction. But don't re-derive what the lockfile already states.
If there's no lockfile, the user skipped the pipeline and just wants their existing stack documented, so infer it from the codebase. This is the harder path and the inference can be wrong, so gather evidence before concluding:
- Manifests and lockfiles are the spine:
package.json+ the lockfile,pyproject.toml/requirements.txt+uv.lock/poetry.lock,go.mod,Cargo.toml,Gemfile.lock, etc. Take exact versions from the lockfiles, not the loose ranges in the manifest. - Config files reveal the tools and how they're wired: bundler/build config, test config, linter/formatter config,
tsconfig, CSS framework config, container/CI files, ORM/migration config. - The code itself confirms what's actually used (imports, framework entry points) versus merely installed.
From that evidence, name the stack the way the pipeline would: language/runtime, framework(s), key libraries, test/lint/build tooling, database/ORM, and anything else load-bearing, each with the version you found. Present the inferred stack to the user and confirm it before writing, calling out anything you're unsure about; inference is a best guess, and the user can correct a wrong call faster than they can un-publish a wrong doc.
When a lockfile exists it does not displace the codebase; the two divide the work. The lockfile owns which tools are in the stack and which version each one is pinned to, and you never re-derive either from the manifests. The code supplies what the lockfile has no field for: the commands, read off the package scripts and the tooling; the wiring, read off the config files; and the rules, read off what the code and config actually do. With no lockfile the codebase supplies all of it, versions included. Between them that is the whole input. Whatever the text already in the docs files says beyond it is Step 4's to carry into the notes tail, not Step 3's to fold back into the render.
Step 3: Write for two audiences, across three files
The agent-facing reference is split in two by what each file costs to read. Write each one for what its reader pays.
docs/stack.md, the tier every agent loads. It is reached through the AGENTS.md pointer, so it is in context from the first token of every session, whether or not anyone needed it. Keep it to what an agent must have in front of it to act correctly:
- One or two sentences saying what the project is and that
docs/stack-notes.mdholds the why and the wiring. - A Commands table: build, test, lint, format, dev, run, install, and how to add a dependency, one row each. Write a row only for the tasks this project actually has a command for, taken from its scripts or its tooling. Skip the rest rather than inventing one or writing "not configured", and say in the notes tier which of the eight are absent. An agent shouldn't have to guess the test command, and it shouldn't be handed a build command that doesn't exist.
- A Pins table, two columns, tool and version, under the same group headings the notes file uses. A drifted install gets
x installed, lockfile says yin the version cell. No third column: the reason a pin is what it is belongs in the notes. - Rules, one line each, and only things an agent gets wrong without being told: the package manager, a pinned major that must not be bumped, a config file that must keep its extension, a recorded security decision, a runtime trap. At most 15 lines, and only the ones the lockfile and the code actually support. A project with four real rules gets four; padding to reach a count puts guesses in the file every session loads.
docs/stack-notes.md, the tier read on demand. No agent loads this at launch, so it can be as long as the project needs. Per group and per tool: the role, how it's wired (config files, plugins, entry points), the conventions the tool implies, caveats from the lockfile, drift between the lockfile and the installed version, and install provenance where it helps. One tool per row or per bullet, tables or bullets as you prefer. This is also where the notes carried forward from earlier docs end up.
Why the reference is two files. Claude Code loads every @ import in full at session start and re-injects it after each /compact; the official docs say imported files "still load and enter the context window at launch" and that splitting a file into @path imports "helps organization but doesn't reduce context". Codex caps the combined AGENTS.md chain at 32 KiB. So the tier behind the pointer is charged to every session, and on the first real project it came out at 20 KB, roughly 5,000 tokens, most of it a third column of prose nobody reads until they need it. Splitting that file moves the prose to docs/stack-notes.md and leaves about 6 KB always loaded. The pointer to the notes file is a plain path, never @ and never a markdown link with @ in it, because a plain path is not an import: Claude and every other agent read it with file tools when they need it, and nothing loads at launch. AGENTS.md is the file the ecosystem already reads (Codex, Cursor, Copilot, Gemini CLI, Zed, Amp and Windsurf pick it up natively), and one pointer line survives its budget. Claude Code is the exception: it loads CLAUDE.md, not AGENTS.md, at session start, so a thin bridge gives it the same content. CLAUDE.md imports @AGENTS.md, which imports @docs/stack.md, and the small tier is loaded at launch.
README (human-facing). A person here wants to understand and run the project. Give them:
- A Tech stack section: the major choices at a glance, each with a few words on what it's for.
- A Getting started section: prerequisites (runtime version, package manager), install, run the dev server, run tests, build. Use the real commands for this stack.
If there's no README, create one with these sections.
Step 4: Write the files
Run Marker integrity on every file whose content you replace, both owned docs files and the README, before the first byte of any of them is written, if Step 1 didn't already run it on the way past migrate. A bad pair in the README stops the run just as a bad pair in docs/stack.md does, and checking it late would leave a rewritten docs file next to a README nobody touched.
(a) Write the two docs files
Write here only when Step 1 established the file is stack-it's: it doesn't exist yet, or it's a regular file whose first line starts with the generated header prefix. A docs file you don't own is never overwritten.
Each file has three parts, and only the middle one is yours. docs/stack.md:
> Generated by stack-it document-stack, format 2. The section between the markers is regenerated on every run; anything below the end marker is yours and survives. Rationale, wiring, and carried notes live in docs/stack-notes.md.
<!-- stack-it:stack start -->
# Stack
...the one or two sentences, the Commands table, the Pins table, the Rules...
Detail for every pin, the wiring, and the notes carried forward from earlier docs: docs/stack-notes.md. Read it before changing configuration or adding a tool.
<!-- stack-it:stack end -->
...anything the user put here stays here...
docs/stack-notes.md:
> Generated by stack-it document-stack, format 2, notes tier. The section between the markers is regenerated on every run; anything below the end marker is yours and survives.
<!-- stack-it:stack start -->
# Stack notes
...per group, per tool: role, wiring, conventions, caveats, drift, provenance...
<!-- stack-it:stack end -->
...anything the user put here stays here, including ## Notes carried forward...
The closing pointer sentence in docs/stack.md is the last line inside its markers, and the path in it is plain text. Never write @docs/stack-notes.md, never make it a markdown link, and never put it in AGENTS.md: the whole point of the split is that this file is not imported.
Detection reads format N off each header line and matches on that prefix alone, so keep both prefixes word for word and write the current sentence after each. Then, for each file, take the first case that matches:
- No file yet. Create
docs/if it isn't there and write the header, a blank line, and the generated section inside one marker pair. There's no tail yet. - Ours, with one clean marker pair. Replace only what's between the markers and rewrite the header line. Everything after the end marker is copied through byte for byte: same text, same blank lines, same trailing newline. So is anything sitting between the header and the start marker.
- Ours, with malformed markers, or with no markers at all. Marker integrity stops the run: show the file and write nothing, in either docs file or the README. Format 2 has always written a pair into both files, so an owned file with no markers is a layout this version does not know how to read, not an older one to upgrade in place.
One header can end up on the wrong file, usually after somebody copied one across. A docs/stack.md whose header says notes tier, or a docs/stack-notes.md whose header doesn't, keeps its content and its tail; say so in the report and rewrite the header line to the tier the filename means. The filename decides which tier a file is, never the header.
Keep the loaded tier small
The budget is on docs/stack.md, since that is the file every session pays for. Aim to keep the managed section under 6 KB. If the render comes out over 8 KB, move content to the notes tier in this order until it fits: rules past the fifteenth line, then any prose beyond the opening two sentences, then any pin row carrying more than a tool and a version. Say in the report what you moved and why.
Those three moves are the whole toolkit. If the section is still over 8 KB once they are done, write it as it stands and warn: pin rows and command rows are what this file exists for, and a project with 60 real dependencies produces a large file even with nothing padding it. Never get under budget by dropping a fact, a pin, or a command. Everything that has somewhere else to go has already gone there, and the notes file has no budget.
Carry forward what the lockfile cannot say
The generated sections are a render of the lockfile, or of what you inferred. The lockfile's fields are structured: a tool, a version, install steps, caveats, and a free-form notes string. Plenty of what a human wrote into an earlier section fits none of them, or fits only as prose nobody put there: that a package manager doesn't hoist what you never declared, that the dev server serves stale CSS after a rename, a built-and-pending ledger, a convention someone learned the hard way. Replacing a section takes all of that with it, which is the bug this step exists to prevent. What decides whether a line is carried is only whether the new render states it. Which field it might have come from, caveats or notes or nothing at all, never enters into it.
So once both new sections are rendered and before you write either, compare them against the old text and sort every line of it:
- Read the previous generated text: the union of the old managed sections of
docs/stack.mdanddocs/stack-notes.md. Right after amigraterun that is the carried block sitting indocs/stack-notes.md's section. - Set aside everything either new section already says, however differently it words it: the same tool at the same or a newer version, the same command, the same convention, the same caveat. Set aside the scaffolding too, the headings, the table header rows, the blank lines, the list bullets that carry nothing of their own. None of it moves; the new render is already saying it, and a tail that accumulates
# Stackand## Commandson every run is the failure this step guards against. - Sort by sentence, not by line, and split only at sentence boundaries. A line holding two sentences, one the render states and one it doesn't, contributes only the second. A single sentence that mixes both is carried whole, and the report names its stated part as a prune candidate; splitting inside a sentence produces a fragment nobody can read a year later. Never carry a fragment away from its subject: a table row carries its note cell as a bullet that names the row's subject, so
| Vite | 7.1.0 | Plugin order matters: Tailwind before React. |carries as- Vite: plugin order matters, Tailwind before React. - Drop three things, and only these three: a version of a tool the lockfile lists, which the new render supersedes; a command the new sections restate in this project's own package-manager form (
pytestagainstuv run pytest,vitest runagainstpnpm test); and either linemigratewrites,Moved from CLAUDE.md by migrate; run document-stack to regenerate.andPlaceholder written by migrate; run document-stack to fill this in., which are markers rather than content and are neither carried nor kept. - Carry what's left. When a line fits none of the categories above, carry it: that is the default, and the whole point of this step. A tool the lockfile has no slot for is carried and tagged
(not in the lockfile). A note that mentions a version the lockfile has since moved past is carried word for word and flagged in the report, never rewritten to match the new pin, because the note may be about the old version on purpose. - Put each carried item in
docs/stack-notes.md, below its end marker, under a## Notes carried forwardheading, with one provenance line under the heading and the notes below that. Create the two together when they aren't there. When the heading exists, the first non-blank line under it that starts withCarriedis the provenance line, whatever its wording (earlier versions wrote different ones, some dated): reuse it as it is, never rewrite it, and add only the new notes, as the last lines of that section, before the next heading. Never write a second heading or a second provenance line. Nothing is ever carried intodocs/stack.md's tail; the loaded tier stays small.- Never add a note either tail already carries. Read both tails before appending,
docs/stack-notes.md's anddocs/stack.md's. A note a user moved intodocs/stack.md's tail is already written down, so carrying a copy into the notes tail duplicates it. Leave it where it is, and say in the report that it sits in the file that loads at every session start and can be moved to the notes tail if they want the bytes back. Never move it yourself: that tail is theirs. - Stop on an ambiguous tail rather than guessing. Count the
## Notes carried forwardheadings, and theCarriedlines under them, across the union of both tails:docs/stack-notes.md's anddocs/stack.md's. More than one heading across the two files, or a heading with noCarriedline under it, leaves no single place the new notes belong and no line to reuse. Write nothing to either file, show the user both tails, and ask them to settle it. Guessing puts the provenance line in the wrong position and every later run inherits the mistake.
- Never add a note either tail already carries. Read both tails before appending,
- In the report, give one line per dropped version or command ("old section said FastAPI 0.110.0 and
pytest; lockfile says 0.115.0 anduv run pytest") and one line per carried note, so nothing leaves the files in silence. Say "nothing needed carrying" only when both lists are empty. - Also in the report, name the notes already in the notes tail that the new sections now cover, as prune candidates. The tail is the user's: say what has gone redundant and leave the editing to them.
The provenance line is fixed text shared with migrate, which writes the same line when it appends a leftover block, so whichever skill created the heading, the other reuses it. Reuse is by position, the first Carried line under the heading, so a line written by an earlier version is reused too:
Carried forward by stack-it. Each line says something the generated section above did not; prune what the section grows to cover.
Dating it or rewording it per run would put a differently worded line in that position on every carry; write it verbatim.
Run this against sections you just regenerated yourself and every line lands in step 2, which is why a second run in a row carries nothing.
Text migrate carried over is never dropped in silence. The first regeneration after a placeholder is the run where the user finally sees that list and decides what to keep, so show it to them even when it's long.
(b) Resolve the pointer files first
Before the pointer step and the bridge step, resolve AGENTS.md and CLAUDE.md to the files they actually are.
- They resolve to the same file, whichever direction the symlink runs: treat them as one file. Write the pointer line once and skip the bridge step entirely, because a file that is
AGENTS.mdmust never contain@AGENTS.md. CLAUDE.mdis a symlink to anything other thanAGENTS.md(a dotfiles repo, a file shared outside the project): that's an intentional bridge exception. Never write through it. Report where it points and that it doesn't importAGENTS.md, and offer to place the import only if the user asks.AGENTS.mdis a symlink to a file outside the project: same rule, don't write through it.
(c) Ensure the AGENTS.md pointer
AGENTS.md gets exactly one line from stack-it, verbatim:
Before adding a dependency or running the build, test, or lint toolchain, read the stack reference: @docs/stack.md
This is a presence check, not a managed block. If AGENTS.md already contains @docs/stack.md outside backticks, do nothing, even if the sentence around it has been reworded; the pointer is there and the wording is the user's. Otherwise append the line, creating AGENTS.md if it's missing and putting a blank line before it if the file already has content. Never add markers to AGENTS.md, never wrap the path in backticks or a code fence (a backticked path isn't an import, so Claude Code would skip it), and never add a second line for docs/stack-notes.md.
(d) Ensure the CLAUDE.md bridge
Claude Code loads CLAUDE.md, not AGENTS.md, at session start, so it needs a one-line file that imports the other. Take the first case that matches:
CLAUDE.mddoesn't exist, or holds nothing but whitespace: create it containing the single line@AGENTS.md. If any text remains, take one of the cases below instead.CLAUDE.mdis a symlink toAGENTS.md, or already contains@AGENTS.mdoutside backticks: leave it alone.CLAUDE.mdalready contains@docs/stack.mdoutside backticks (the fallback from an earlier declined import): leave it alone.CLAUDE.mdexists with none of those: ask once, "add@AGENTS.mdat the top of CLAUDE.md? If not, I'll append the pointer line instead so Claude Code still reads docs/stack.md." On yes, add that one line at the top. On no, append the pointer line, under the same presence check and with no markers.
CLAUDE.md never receives generated stack content. The most it ever holds is that one import line or that one pointer line.
(e) Refresh the README's managed block
install-stack and scaffold-and-verify update the lockfile, so this skill will be re-run after the stack changes, and the README section must update in place without clobbering anything a human wrote around it. Put the generated content between markers and replace only what's inside them:
<!-- stack-it:stack start -->
> Generated by stack-it's document-stack. Edits inside this block are overwritten on the next run.
...generated Tech stack + Getting started...
<!-- stack-it:stack end -->
Apply Marker integrity from Step 1 to the README before you replace anything in it. The rule is the same one that governs the two docs files and the old CLAUDE.md block.
On each run: if the pair already exists in the README, replace only the content between them; if neither marker is there, insert the block after the title and intro, before the deeper sections. Everything outside the markers stays exactly as the user left it, and there's exactly one marker pair, never a second block appended. Each docs file gets its own pair (Step 4a); the two pointer files get presence-checked lines and never any markers.
Step 5: Report
Tell the user what you did: which source you used (lockfile or inferred-from-code, and if inferred, that they confirmed it), whether migrate ran and what it moved, and every file you wrote or created, one line each. That's docs/stack.md and docs/stack-notes.md (each created or its generated section replaced, plus any header line you corrected to match the file's tier), AGENTS.md (created, pointer appended, or already pointing), CLAUDE.md (created as a bridge, left alone, or the declined-import fallback), and the README (block refreshed or inserted).
Give the sizes, since the whole layout exists to hold one of them down: the byte size of each docs file's managed section and of each tail, and then docs/stack.md's whole-file size, section plus tail, labelled "loads at session start". The tail is loaded along with the section, so the number that matters to a session is the file, not the managed part of it. Warn plainly when that whole-file number goes over 8 KB, say what you moved to the notes tier, and, if it is still over after the three moves, say that too and that you left it rather than dropping pins or commands.
List the notes you moved under ## Notes carried forward and the versions and commands the new sections superseded, one line each, and say nothing needed carrying only when both lists are empty. Name any note already in the notes tail that the new sections now cover, as a prune candidate: the tail is the user's, so you say what has gone redundant and they decide. Surface any discrepancy or caveat you found. If you inferred the stack, remind them the result is only as accurate as the codebase signals and they should correct anything you got wrong.
Boundaries
This skill documents; it does not install, choose, research, or verify tools (those are install-stack, decide-stack, and scaffold-and-verify). It treats the stack YAML as read-only input and never edits it (keeping the lockfile current is the install/verify stages' job). It doesn't restructure an older stack-it layout either: it detects the format and hands that to migrate. And it documents the stack, not the whole project: it doesn't write general prose, API references, or code comments.
Bundled resources
${CLAUDE_PLUGIN_ROOT}/scripts/validate_yaml.py: Validate the lockfile before trusting it, with--stage stack. Runpython ${CLAUDE_PLUGIN_ROOT}/scripts/validate_yaml.py --helpfor usage.