Compare commits

...
Author SHA1 Message Date
Matt PocockandClaude Opus 4.8 0d54032526 feat(wayfinder): run research inline as a subagent, not as a ticket
Research is fully AFK and yields a fact, not a decision, so it never
earns a ticket's session boundary. Drop `research` from the
`wayfinder:<type>` set and reframe it as inline fuel: any session
spins up a `/research` subagent the moment it hits a knowledge gap,
captures the findings on a throwaway `research/<name>` branch, and
leaves a context pointer on the ticket it informs (or the map's Notes).

Updates the SKILL, the GitHub/GitLab/local tracker type lists, and the
docs page, plus a changeset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:15:50 +01:00
Matt PocockandGitHub 391a2701dd Merge pull request #505 from mattpocock/add-setup-deep-modules-skill
Add setup-ts-deep-modules skill (in-progress)
2026-07-10 14:14:59 +01:00
Matt PocockandGitHub 2ee14df19c Merge pull request #488 from mattpocock/prototype-primary-secondary-source
prototype: keep the prototype as a primary source
2026-07-10 11:45:42 +01:00
Matt PocockandClaude Opus 4.8 fa460cbf09 prototype: drop the vestigial "let it go"
Rule 4 is about skipping polish, not the prototype's fate; the trailing
"then let it go" was a leftover from the old delete-it framing and now
misreads as discard, contradicting capture-as-primary-source. Cut it,
and rename LOGIC step 7 off "let go".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:35:29 +01:00
Matt PocockandClaude Opus 4.8 3687fb4f38 Name lib/ + tests/ as the canonical package subfolders
The depth-based rules already treat any subfolder as private, so the
config needs no change. Document lib/ (implementation) and tests/ as the
conventional two-folder shape, with entry points at the root — a fixed
convention that never needs the config extended for a new folder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:16:03 +01:00
Matt PocockandClaude Opus 4.8 3ea30afc68 Rename to setup-ts-deep-modules; entry-points model over single barrel
- Rename skill to setup-ts-deep-modules (it's TypeScript-specific).
- A package's public surface is now its ENTRY POINTS — every file at the
  package root — not one designated index.ts. Implementation lives in
  subfolders and is private. This is depth-based and extension-agnostic.
- Packages can expose several small entry points instead of funnelling
  everything through one giant barrel index; barrels are discouraged, and
  the generated repo docs (step 7) say so explicitly.
- Rules renamed accordingly: entrypoint-boundary-from-app,
  entrypoint-boundary-across-packages, tests-through-entrypoints.
- Example package now hides impl in a lib/ subfolder.

Re-validated against dependency-cruiser 16: multiple entry points and
cross-package entry imports pass; deep imports into subfolders fail with
the expected rule from app code, other packages, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:10:02 +01:00
Matt PocockandClaude Opus 4.8 f947f93b92 Put the convention README in the packages folder + point CLAUDE.md at it
Step 7 now writes <packages-root>/README.md next to the packages it
governs, and adds a context pointer from CLAUDE.md/AGENTS.md so an agent
discovers the boundary rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:55:08 +01:00
Matt PocockandClaude Opus 4.8 d80ff7a840 Reference codebase-design as /codebase-design, not a path
The install location isn't known at runtime, so a relative path breaks.
Use the literal skill invocation instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:53:56 +01:00
Matt PocockandClaude Opus 4.8 0375c88bfb prototype: collapse the when-done section into rule 6
The "keep it as a primary source" section restated rule 6 and was
padded with no-ops. Fold the one extra bit (capture the answer) into
rule 6 and delete the section: capture the prototype as a primary
source on a throwaway branch, pointer on the issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:49:44 +01:00
Matt PocockandClaude Opus 4.8 30a9c7447f Add setup-deep-modules skill (in-progress)
A user-invoked setup skill that wires dependency-cruiser into a
TypeScript repo so each package under the packages root is a deep
module: everything hidden behind its `index.ts` interface.

Ships a `.dependency-cruiser.cjs` template with four error-level rules —
index boundary from app code, index boundary across packages (with
intra-package freedom via $1 back-references), tests-through-the-index,
and no-circular — plus a commented layering stub. The skill installs the
tool, folds a `lint:boundaries` script into the repo's umbrella check,
scaffolds an example package, and self-verifies that a deep import fails.

Rules validated against a live dependency-cruiser 16 run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:49:13 +01:00
Matt PocockandGitHub 89d370d181 Merge pull request #504 from mattpocock/fix/to-tickets-stray-content
fix(to-tickets): remove stray </content> tag
2026-07-10 09:46:06 +01:00
Matt PocockandClaude Opus 4.8 371b9c9c94 prototype: capture the prototype, don't dispose of it
Reframe the artifact handling as capturing the prototype somewhere out
of main — a throwaway branch — with a context pointer to it left on the
implementation issue, instead of "disposing" of it. The main branch
still keeps only the validated decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:39:52 +01:00
Matt PocockandClaude Opus 4.8 cdec9f6eb2 prototype: simplify to single primary-source idea; dedup branch files
Drop the secondary-source label — the actionable distinction is just
that the prototype is a primary source to retain on a throwaway branch.
Collapse the re-descriptions in LOGIC/UI so they point to SKILL for the
disposal concept and add only branch-specific nuance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:57:48 +01:00
Matt PocockandClaude Opus 4.8 d6274600ab prototype: reframe disposal around primary vs secondary source
Retain the prototype as a primary source on a throwaway branch linked
from the relevant issue, rather than deleting it, while the distilled
answer remains the secondary source. Keeps prototype scaffolding out of
the main branch without destroying the runnable evidence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:53:43 +01:00
14 changed files with 242 additions and 25 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"mattpocock-skills": minor
---
Reword how the **`prototype`** skill handles its artifacts around a single idea: **the prototype is a primary source**. Rather than being deleted once it's answered its question, the prototype is captured as runnable evidence on a throwaway branch (`prototype/<name>`) out of main, with a context pointer to it left on the implementation issue — so the main branch keeps only the validated decision while the exploration stays findable. The answer (verdict + question) is still captured durably in an issue/ADR/commit.
+5
View File
@@ -0,0 +1,5 @@
---
"mattpocock-skills": minor
---
Reframe how **`wayfinder`** handles research: it's no longer a ticket type — it runs **inline as a subagent**. Research is fully AFK (no human gates it) and produces a *fact*, not a decision, so it never earns a ticket's session boundary. Instead, any session — charting or resolving — spins up a `/research` subagent the moment it hits a knowledge gap, keeps working while it reads, and folds the findings straight into the decision it's making. The findings are captured as a primary source on a throwaway `research/<name>` branch (mirroring how `/prototype` captures prototypes), with a context pointer left on the ticket the research informs, or in the map's Notes if it informs the whole effort. Drops `research` from the `wayfinder:<type>` label set (`prototype`/`grilling`/`task`) across the GitHub, GitLab, and local trackers, and re-syncs the docs page.
+4 -2
View File
@@ -31,9 +31,11 @@ The question decides the shape, and there are two shapes:
Picking the wrong branch wastes the whole prototype, so the question comes first. Both branches keep state in memory, run from one command, and surface the full state on every step.
## The answer is the artifact
## Keep the prototype as a primary source
The code is disposable; the **answer** is the only thing worth keeping. When the prototype has settled its question, capture the verdict somewhere durable — a commit message, an ADR, an issue, or a `NOTES.md` next to it — alongside the question it answered, then delete or absorb the code. A prototype left rotting in the repo has outlived its purpose.
A finished prototype leaves two things. The **answer** the verdict plus the question it settled — is what you capture durably (a commit message, an ADR, an issue). The **prototype itself is a primary source** — the runnable evidence the answer came from.
The prototype doesn't belong in the main branch: no tests, no error handling, nothing to maintain. But that's not a reason to destroy it. Once the answer is captured, fold any validated decision into the real code, then capture the prototype on a throwaway branch — out of main, never merged — and leave a context pointer to it on the implementation issue. The main branch stays clean; the raw exploration stays one click away for anyone who wants to re-run it. A prototype left rotting in the main branch has outlived its purpose — a prototype captured as a primary source on a side branch hasn't.
## Where it fits
+2 -2
View File
@@ -30,7 +30,7 @@ The **map** is a single `wayfinder:map` issue whose tickets are its child issues
Beyond the live tickets lies the **fog of war** — decisions you can tell are coming but can't yet pin down. The test for whether something is a ticket or still fog is whether you can *state the question precisely now*, not whether you can answer it. Resolving a ticket clears the fog ahead of it, **graduating** whatever's now specifiable into fresh tickets. The **frontier** is the open, unblocked, unclaimed tickets — the edge of the known — and it's what the tracker's native blocking renders visually, so you see what's takeable without opening the map. Fog only gathers *toward* the **destination**; work past it is ruled **out of scope**, closed, never graduating.
Every ticket is **HITL** (human in the loop — grilling, prototype) or **AFK** (agent alone — research); a HITL ticket only resolves through a live exchange, so the agent never answers its own questions.
Every ticket is **HITL** (human in the loop — grilling, prototype) or an agent-driven **task**; a HITL ticket only resolves through a live exchange, so the agent never answers its own questions. Research is deliberately **not** a ticket: it's fully AFK and yields a fact, not a decision, so any session spins up a `/research` subagent inline the moment it needs external knowledge — capturing the findings on a throwaway `research/<name>` branch and leaving a context pointer on the ticket it informs — rather than parking a separate ticket for a later session to open, read, and close.
## It's working if
@@ -41,4 +41,4 @@ Every ticket is **HITL** (human in the loop — grilling, prototype) or **AFK**
## Where it fits
`wayfinder` is a big-idea **on-ramp**: an effort too large and foggy to spec in one sitting generates a cleared map of decisions, which then merges onto the main build flow. When the fog is pushed back and the way is clear, hand off to [to-spec](https://aihero.dev/skills-to-spec) to schedule the multi-session build (or, if the effort turned out small, implement directly). It leans on [grilling](https://aihero.dev/skills-grilling) and [domain-modeling](https://aihero.dev/skills-domain-modeling) to resolve individual tickets, and on [prototype](https://aihero.dev/skills-prototype) and [research](https://aihero.dev/skills-research) for the ticket types that need them. When you're unsure which skill or flow fits, [ask-matt](https://aihero.dev/skills-ask-matt) routes you.
`wayfinder` is a big-idea **on-ramp**: an effort too large and foggy to spec in one sitting generates a cleared map of decisions, which then merges onto the main build flow. When the fog is pushed back and the way is clear, hand off to [to-spec](https://aihero.dev/skills-to-spec) to schedule the multi-session build (or, if the effort turned out small, implement directly). It leans on [grilling](https://aihero.dev/skills-grilling) and [domain-modeling](https://aihero.dev/skills-domain-modeling) to resolve individual tickets, on [prototype](https://aihero.dev/skills-prototype) for the tickets that need a concrete artifact, and on [research](https://aihero.dev/skills-research) as an inline subagent whenever a decision needs external facts. When you're unsure which skill or flow fits, [ask-matt](https://aihero.dev/skills-ask-matt) routes you.
+3 -3
View File
@@ -36,7 +36,7 @@ The right shape depends on the question:
Pick whichever shape best fits the question being asked, *not* whichever is easiest to wire to a TUI. Keep it pure: no I/O, no terminal code, no `console.log` for control flow. The TUI imports it and calls into it; nothing flows the other direction.
This is what makes the prototype useful past its own lifetime. When the question's been answered, the validated reducer / machine / function set can be lifted into the real module — the TUI shell gets deleted.
This is what makes the prototype useful past its own lifetime: when the question's been answered, the validated reducer / machine / function set can be lifted into the real module on its own.
### 4. Build the smallest TUI that exposes the state
@@ -66,9 +66,9 @@ If the host project has no task runner, just put the command at the top of the p
Give the user the run command. They'll drive it themselves; the interesting moments are when they say "wait, that shouldn't be possible" or "huh, I assumed X would be different" — those are the bugs in the _idea_, which is the whole point. If they want new actions added, add them. Prototypes evolve.
### 7. Capture the answer
### 7. Capture the answer and the prototype
When the prototype has done its job, the answer to the question is the only thing worth keeping. If the user is around, ask what it taught them. If not, leave a `NOTES.md` next to the prototype so the answer can be filled in (or filled in by you, if you've watched the session) before the prototype gets deleted.
Once the prototype has answered its question, capture the answer, then capture the prototype the way the [SKILL](SKILL.md) describes. The logic-specific mapping: the validated reducer / machine / function set lifts into the real module (the decision, absorbed); the TUI shell rides along to the throwaway branch that keeps the prototype as a primary source.
## Anti-patterns
+2 -6
View File
@@ -21,10 +21,6 @@ The two branches produce very different artifacts — getting this wrong wastes
1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure.
2. **One command to run.** Whatever the project's existing task runner supports — `pnpm <name>`, `python <path>`, `bun <path>`, etc. The user must be able to start it without thinking.
3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name.
4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast and then delete it.
4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast.
5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed.
6. **Delete or absorb when done.** When the prototype has answered its question, either delete it or fold the validated decision into the real code — don't leave it rotting in the repo.
## When done
The _answer_ is the only thing worth keeping from a prototype. Capture it somewhere durable (commit message, ADR, issue, or a `NOTES.md` next to the prototype) along with the question it was answering. If the user is around, that capture is a quick conversation; if not, leave the placeholder so they (or you, on the next pass) can fill in the verdict before deleting the prototype.
6. **Capture it when done.** Fold any validated decision into the real code, then capture the prototype itself as a **primary source**: commit it to a throwaway branch, out of main, and leave a context pointer to that branch on the implementation issue. Capture the answer too — the verdict and the question it settled — in the issue or a commit. The main branch keeps only the validated decision.
+4 -4
View File
@@ -97,12 +97,12 @@ Surface the URL (and the `?variant=` keys). The user will flip through whenever
### 6. Capture the answer and clean up
Once a variant has won, write down which one and why (commit message, ADR, issue, or a `NOTES.md` next to the prototype if running AFK and the user hasn't responded yet). Then:
Once a variant has won, capture the answer — which variant and why — then capture the prototype the way the [SKILL](SKILL.md) describes. Fold the winner into the real code and move the rest onto the throwaway branch, not into main:
- **Sub-shape A** — delete the losing variants and the switcher; fold the winner into the existing page.
- **Sub-shape B** — promote the winning variant to a real route, delete the throwaway route and the switcher.
- **Sub-shape A** — fold the winner into the existing page; drop the losing variants and the switcher from main.
- **Sub-shape B** — promote the winning variant to a real route; drop the throwaway route and the switcher from main.
Don't leave variant components or the switcher lying around. They rot fast and confuse the next reader.
The full set of variants is the primary source, so it lands on the throwaway branch, not the bin — variant components and the switcher left in the main branch rot fast and confuse the next reader.
## Anti-patterns
@@ -38,7 +38,7 @@ Run `gh issue view <number> --comments`.
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`.
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #<map>` at the top of the child body. Labels: `wayfinder:<type>` (`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos/<owner>/<repo>/issues/<child>/dependencies/blocked_by -F issue_id=<blocker-db-id>`, where `<blocker-db-id>` is the blocker's numeric **database id** (`gh api repos/<owner>/<repo>/issues/<n> --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #<n>, #<n>` line at the top of the child body. A ticket is unblocked when every blocker is closed.
- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins.
- **Claim**: `gh issue edit <n> --add-assignee @me` — the session's first write.
@@ -39,7 +39,7 @@ Run `glab issue view <number> --comments`.
Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets.
- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `glab issue create --label wayfinder:map`. (On GitLab tiers with native epics, an epic may hold the map instead; a labelled issue works everywhere.)
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Child ticket**: an issue carrying `Part of #<map>` at the top of its description and labels `wayfinder:<type>` (`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev.
- **Blocking**: GitLab's **native blocking link** — the canonical, UI-visible representation. Add it with the `/blocked_by #<n>` quick action, posted as a note (`glab issue note <child> --message "/blocked_by #<blocker>"`). Native blocking links are a Premium/Ultimate feature; on the free tier (or where unavailable) fall back to a `Blocked by: #<n>, #<n>` line at the top of the description. A ticket is unblocked when every blocker is closed.
- **Frontier query**: `glab issue list -F json` scoped to the map's children, drop any with an open blocker — a native `blocked_by` link to an open issue (`glab api projects/:id/issues/:iid/links`), or an open issue in the `Blocked by` line — or an assignee; first in map order wins.
- **Claim**: `glab issue update <n> --assignee @me` — the session's first write.
@@ -23,7 +23,7 @@ Read the file at the referenced path. The user will normally pass the path or th
Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.
- **Map**: `.scratch/<effort>/map.md` — the Notes / Decisions-so-far / Fog body.
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
- **Claim**: set `Status: claimed` and save before any work.
+16 -5
View File
@@ -62,7 +62,7 @@ Each ticket is a **child issue** of the map; the tracker's issue id is its ident
<the decision or investigation this ticket resolves>
```
Each ticket carries a `wayfinder:<type>` label — one of `research`, `prototype`, `grilling`, `task` (see [Ticket Types](#ticket-types)).
Each ticket carries a `wayfinder:<type>` label — one of `prototype`, `grilling`, `task` (see [Ticket Types](#ticket-types)). Research is deliberately _not_ a ticket type: it's AFK fuel a session runs inline, never a stop on the route (see [Research runs inline, not as a ticket](#research-runs-inline-not-as-a-ticket)).
A session **claims** a ticket by assigning it to the dev driving the map, **first**, before any work, so concurrent sessions skip it. That assignee _is_ the claim: an open, unassigned ticket is unclaimed.
@@ -72,13 +72,24 @@ The answer isn't part of the body — it's recorded on resolution (see [Work thr
## Ticket Types
Every ticket is either **HITL** human in the loop, worked *with* a human who speaks for themselves or **AFK**, driven by the agent alone. A HITL ticket only resolves through that live exchange; the agent never stands in for the human's side of it (a grilling agent that answers its own questions has broken this).
A ticket exists because someone has to sit with it — so every ticket is either **HITL** (human in the loop, worked *with* a human who speaks for themselves) or a **task** the agent must drive by hand. A HITL ticket only resolves through that live exchange; the agent never stands in for the human's side of it (a grilling agent that answers its own questions has broken this).
- **Research** (AFK): Reading documentation, third-party APIs, or local resources like knowledge bases. Creates a markdown summary as a linked asset. Use when knowledge outside the current working directory is required.
- **Prototype** (HITL): Raise the fidelity of the discussion by making a cheap, rough, concrete artifact to react to — an outline, a rough take, a stub, or UI/logic code via the /prototype skill. Links the prototype as an asset. Use when "how should it look" or "how should it behave" is the key question.
- **Grilling** (HITL): Conversation via the /grilling and /domain-modeling skills, one question at a time. The default case.
- **Task** (HITL or AFK): Manual work that must happen before a *decision* can be made — nothing to decide, prototype, or research, but the discussion is blocked until it's done. Signing up for a service so its API can be judged, provisioning access, moving data so its shape can be seen. This is the one type that *does* rather than decides — and it earns its place by unblocking a decision, not by delivering the destination. The agent drives it alone where it can (AFK); otherwise it hands the human a precise checklist (HITL). Resolved when the work is done; the answer records what was done and any resulting facts (credentials location, new URLs, row counts) later tickets depend on.
## Research runs inline, not as a ticket
Research — reading docs, third-party APIs, source, or local knowledge bases to gather facts — is **fully AFK**: no human gates it, and it produces a *fact*, not a decision. That combination is exactly why it must **not** be its own ticket. A ticket earns its place as a session boundary: a self-contained unit some *later* session claims and works. Research needs no such boundary — any session, charting or resolving, can spin up a research **subagent** (`/research`) the moment it hits a knowledge gap, keep working while it reads, and fold the findings straight into the decision it's making. Parking research as a ticket only forces a second session to open it, do the AFK read, and close it — a session boundary bought for nothing.
So there is no research ticket and no research assignee. When a decision needs external knowledge:
1. **Launch a research subagent inline** via `/research`, scoped to the specific question. Charting can do this to sharpen the destination or the frontier; resolving can do it to answer the ticket in hand.
2. **Capture the findings as a primary source**, the way `/prototype` captures a prototype: commit the research Markdown to a **throwaway branch** out of main (`research/<name>`), so main stays clean but the reading stays findable.
3. **Leave a context pointer** to that branch — on the ticket whose decision the research informs, or, if it informs the whole effort rather than one ticket, in the map's **Notes**. The pointer is a link, never a paste; the map stays an index.
A blocked ticket that used to wait on a "research ticket" now simply carries, in its own body, the note that resolving it starts with a research subagent — the read happens inside that ticket's session, not as a prerequisite ticket before it.
## Fog of war
The map is _deliberately_ incomplete: don't chart what you can't yet see. Beyond the live tickets lies the **fog of war** — the dim view of decisions and investigations you can tell are coming but can't yet pin down, because they hang on questions still open. Resolving a ticket clears the fog ahead of it, graduating whatever's now specifiable into fresh tickets — one at a time, until the way to the destination is clear and no tickets remain.
@@ -109,7 +120,7 @@ Two modes. Either way, **never resolve more than one ticket per session.**
User invokes with a loose idea.
1. **Name the destination.** Run a `/grilling` and `/domain-modeling` session to pin down what this map is finding its way to — the spec, decision, or change. The destination fixes the scope, so it's settled first.
2. **Map the frontier.** Grill again, **breadth-first** this time: fan out across the whole space rather than deep on any one thread, surfacing the open decisions and the first steps takeable now. **If this surfaces no fog** — the way to the destination is already clear, the whole journey small enough for one session — you don't need a map. Stop and ask the user how they'd like to proceed.
2. **Map the frontier.** Grill again, **breadth-first** this time: fan out across the whole space rather than deep on any one thread, surfacing the open decisions and the first steps takeable now. Where a gap is a knowledge gap, resolve it with an inline `/research` subagent rather than charting a ticket for it (see [Research runs inline, not as a ticket](#research-runs-inline-not-as-a-ticket)). **If this surfaces no fog** — the way to the destination is already clear, the whole journey small enough for one session — you don't need a map. Stop and ask the user how they'd like to proceed.
3. **Create the map** (label `wayfinder:map`): Destination and Notes filled in, Decisions-so-far empty, the fog sketched into **Not yet specified**.
4. **Create the tickets you can specify now** as child issues of the map — then wire blocking edges in a **second pass** (issues need ids before they can reference each other). Wiring sorts them into the frontier and the blocked; everything you can't yet specify stays in the fog — the **Not yet specified** section.
5. Stop — charting the map is one session's work; do not also resolve tickets.
@@ -120,7 +131,7 @@ User invokes with a map (URL or number). A ticket is **optional** — without on
1. Load the **map** — the low-res view, not every ticket body.
2. Choose the ticket. If the user named one, use it. Otherwise take the first frontier ticket in order. **Claim it**: assign it to yourself before any work.
3. Resolve it — **zoom as needed**: fetch the full body of any related or closed ticket on demand; invoke the skills the `## Notes` block names. If in doubt, use `/grilling` and `/domain-modeling`.
3. Resolve it — **zoom as needed**: fetch the full body of any related or closed ticket on demand; invoke the skills the `## Notes` block names. If the ticket needs external knowledge, spin up a `/research` subagent inline and fold its findings in (see [Research runs inline, not as a ticket](#research-runs-inline-not-as-a-ticket)) — don't hand the reading off to a separate session. If in doubt, use `/grilling` and `/domain-modeling`.
4. Record the resolution: post the answer as a **resolution comment**, **close** the issue, and **append a context pointer** to the map's Decisions-so-far.
5. Add newly-surfaced tickets (create-then-wire); graduate any fog the answer has made specifiable, clearing each graduated patch from **Not yet specified** so it lives only as its new ticket. If the answer reveals a ticket — this one or another — sits beyond the destination, **rule it out of scope** rather than resolving it on the route. If the decision invalidates other parts of the map, update or delete those tickets.
+1
View File
@@ -8,3 +8,4 @@ Skills that are still being developed. They're not ready to ship — expect roug
- **[writing-fragments](./writing-fragments/SKILL.md)** — Grilling session that mines you for fragments — heterogeneous nuggets of writing — and appends them to a single document as raw material for a future article.
- **[writing-shape](./writing-shape/SKILL.md)** — Take a markdown file of raw material and shape it into an article paragraph by paragraph, arguing format choices at each step.
- **[claude-handoff](./claude-handoff/SKILL.md)** — Hand the current conversation off to a fresh background agent that picks up the work immediately, seeded with a handoff summary via `claude --bg`. User-invoked.
- **[setup-ts-deep-modules](./setup-ts-deep-modules/SKILL.md)** — Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files, tests exercising it through those. User-invoked.
@@ -0,0 +1,102 @@
---
name: setup-ts-deep-modules
description: Wire dependency-cruiser into a TypeScript repo so each package is a deep module — implementation hidden in subfolders, reachable only through its entry-point files. User-invoked.
disable-model-invocation: true
---
# Setup TS Deep Modules
Make every package in this repo a **deep module**: a lot of behaviour behind a small interface. A package's public surface is its **entry points** — the files at the package root — and everything in its subfolders is hidden. This skill installs [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) and the rules that make the entry points the only way in, then proves the rules bite.
For the vocabulary (deep module, interface, seam, depth), run the `/codebase-design` skill — use its language throughout.
## The shape this enforces
```
src/packages/
<name>/
index.ts ← an entry point (public). Import this from outside.
client.ts ← another entry point. Packages may expose SEVERAL.
lib/ ← implementation: hidden from outside, free to import each other.
tests/ ← co-located tests + fixtures (a subfolder, so private).
```
The public surface is the package's **root files** — not one designated `index.ts`. By convention implementation lives in `lib/` and tests in `tests/`, giving every package the same two-folder shape. The rule itself is general, though: *anything* in *any* subfolder is private, so you never extend the config to add a folder.
Four rules, all `error`:
1. **Entry-point boundary** — code outside a package (app code or another package) may import only that package's entry points (its root files), never anything in its subfolders.
2. **Intra-package freedom** — a package's own files import each other freely.
3. **Tests through the entry points** — files under `<pkg>/tests/` may import any package's entry points and their own `tests/` fixtures, but never any package's subfolder internals (not even their own). Integration tests across packages are fine; deep imports are not.
4. **No cycles** — no dependency cycles.
**Entry points, not a barrel.** Because the public surface is *every* root file, a package can expose several small entry points (`index.ts`, `client.ts`, `server.ts`) instead of funnelling everything through one giant `index.ts`. Barrel files that re-export a whole subtree are discouraged — keep entry points small and hide implementation in subfolders.
Layering (which packages may depend on which) is a *different* concern and is left as a commented stub in the config for this repo to fill in.
## Steps
### 1. Detect the environment
- **Package manager** — `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, else npm. Use it for every command below (`pnpm`/`yarn`/`npm run`/`bunx`).
- **Packages root** — if `src/` exists use `src/packages`, else `packages`. Confirm the choice with the user if the repo already has a different obvious convention.
- **Existing config** — check for a `.dependency-cruiser.*` file. If one exists, do **not** overwrite it: merge the four rules and the options in, and tell the user what you added.
**Done when:** package manager, packages root, and existing-config status are all known.
### 2. Install dependency-cruiser
Install `dependency-cruiser` as a devDependency with the detected package manager.
**Done when:** `dependency-cruiser` is in `devDependencies`.
### 3. Write the config
Copy [`dependency-cruiser.config.cjs`](./dependency-cruiser.config.cjs) to the repo root as `.dependency-cruiser.cjs`. Set `PACKAGES_ROOT` to the root detected in step 1. The rules are path-depth based and extension-agnostic, so nothing else needs adapting.
**Done when:** `.dependency-cruiser.cjs` exists with the correct `PACKAGES_ROOT`, and the four forbidden rules are present.
### 4. Wire it into the checks
- Add a `lint:boundaries` script: `depcruise <packages-root>` (or `depcruise src`).
- Fold it into the repo's umbrella check command — the one that already runs typecheck (e.g. a `check` / `ci` / `validate` script). Do **not** touch `tsconfig` or add path aliases.
- If there is no umbrella script, add `lint:boundaries` and tell the user to include it in CI.
**Done when:** `lint:boundaries` exists and runs as part of the same command as typecheck.
### 5. Scaffold the example package
Create a committed `<packages-root>/example/` as a copy-me template:
- `index.ts` — an entry point. Export one function that delegates to an internal file (so the package is visibly *deep*, not a pass-through).
- `lib/impl.ts` — an internal file in a **subfolder**, imported by `index.ts`, not reachable from outside.
- `tests/example.test.ts` — imports **only** `../index` (an entry point), and asserts against the public function.
Tell the user this is a starter template to copy or delete.
**Done when:** the example package exists, exposes its behaviour through a root entry point, and hides `impl` in a subfolder.
### 6. Prove the rules bite
This is the completion criterion for the whole skill — a config that doesn't fail on a violation is worthless.
1. Run `lint:boundaries`. It must **pass** on the clean example.
2. Temporarily add a deep import to `tests/example.test.ts` (e.g. `import { thing } from "../lib/impl"`). Run `lint:boundaries` again — it must **fail** with `tests-through-entrypoints`.
3. Revert the deep import. Run once more — it must **pass**.
**Done when:** you have observed a pass, then a fail on the deep import, then a pass again. If step 2 does not fail, the rules are not wired correctly — fix before finishing.
### 7. Document the convention
Write a `README.md` **in the packages folder** (`<packages-root>/README.md`) — next to the packages it governs — covering: the `src/packages/<name>/` layout (entry points at the root, `lib/` for implementation, `tests/` for tests), "import only through a package's entry points (its root files)", and how to run `lint:boundaries`. **Discourage barrel files** explicitly — expose several small entry points instead of re-exporting a whole subtree through one index. Keep it to the copy-me snippet plus the four rules in one paragraph each.
Then add a **context pointer** to it from the repo's agent-instructions file — `CLAUDE.md` if present, else `AGENTS.md` (create `AGENTS.md` if neither exists). One line is enough, e.g. `Packages are deep modules — see [src/packages/README.md](./src/packages/README.md) before adding or importing one.` This is what makes an agent discover the boundary rule instead of tripping over it.
**Done when:** `<packages-root>/README.md` exists and discourages barrels, and the repo's `CLAUDE.md`/`AGENTS.md` links to it.
## Notes
- The config's `$1` back-references (dependency-cruiser's group matching) are what let a package reach its own internals while outsiders can't — don't flatten them into separate per-package rules.
- Public vs private is decided by **depth**: a package's root files are entry points; anything in a subfolder is private. The conventional subfolders are `lib/` (implementation) and `tests/`, but the rule doesn't hardcode them — any subfolder is private, so a new folder never needs a config change. Adding an entry point is just adding a root file — no barrel.
- Packages are **flat**: one tier of immediate children under the root. A package's internals may nest as deep as you like; a package may not contain another package.
- Use `.cjs` (not `.js`) so the config's `module.exports` works even in `"type": "module"` repos.
@@ -0,0 +1,95 @@
// @ts-check
// Deep-module enforcement for dependency-cruiser.
//
// Each package under the packages root is a DEEP MODULE: a lot of behaviour
// behind a small interface. A package's PUBLIC SURFACE is its ENTRY POINTS —
// the files at the package root. Implementation lives in SUBFOLDERS and is
// private — by convention `lib/` for implementation and `tests/` for tests,
// though any subfolder is private. A package may expose several small entry
// points (index.ts, client.ts, server.ts, …) — prefer that over one giant
// barrel index.
//
// The only thing you should ever need to edit here is PACKAGES_ROOT.
/** Where packages live. One immediate child dir per package (flat, no nesting). */
const PACKAGES_ROOT = "src/packages";
// --- derived patterns (no need to edit) -------------------------------------
const R = PACKAGES_ROOT;
/**
* A package's private internals: anything nested inside a package subfolder.
* The package's root files are its entry points and are NOT matched here —
* they stay importable from outside.
*/
const PACKAGE_INTERNALS = `^${R}/[^/]+/[^/]+/`;
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "entrypoint-boundary-from-app",
comment:
"App/root code may import a package's entry points (its root files), but nothing inside its subfolders.",
severity: "error",
from: { pathNot: `^${R}/` }, // importer is NOT inside any package
to: { path: PACKAGE_INTERNALS },
},
{
name: "entrypoint-boundary-across-packages",
comment:
"A package's own files import each other freely, but may reach OTHER packages only through their entry points — never their internals.",
severity: "error",
// importer is inside a package ($1), but is not a test file
from: { path: `^${R}/([^/]+)/`, pathNot: `^${R}/[^/]+/tests/` },
to: {
path: PACKAGE_INTERNALS,
pathNot: `^${R}/$1/`, // same package → intra-package freedom
},
},
{
name: "tests-through-entrypoints",
comment:
"A package's tests exercise it through its entry points like everyone else: they may import any package's entry points and their own tests/ fixtures, but never any package's internals — not even their own.",
severity: "error",
from: { path: `^${R}/([^/]+)/tests/` }, // a test file, in package $1
to: {
path: PACKAGE_INTERNALS,
pathNot: `^${R}/$1/tests/`, // own tests/ fixtures → allowed
},
},
{
name: "tests-folder-is-private",
comment:
"A package's tests/ folder is reachable only from tests — nothing else may import fixtures.",
severity: "error",
from: { pathNot: `^${R}/[^/]+/tests/` }, // importer is not itself a test
to: { path: `^${R}/[^/]+/tests/` },
},
{
name: "no-circular",
comment: "No dependency cycles. Scope to `^${R}/` if you want to allow cycles outside packages.",
severity: "error",
from: {},
to: { circular: true },
},
// --- Layering (optional, off by default) ----------------------------------
// Interface-hiding controls HOW you import (through the entry points).
// Layering controls WHICH packages may depend on which. Add your own rules
// here, e.g.:
//
// {
// name: "ui-may-not-depend-on-billing",
// severity: "error",
// from: { path: `^${R}/ui/` },
// to: { path: `^${R}/billing/` },
// },
],
options: {
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.json" },
enhancedResolveOptions: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
};