extract-features
SkillAI & modelsSix-axis Feature Inventory (routes / models / jobs / tests / UI / docs) — a "what we must preserve" spec for greenfield rewrites.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the extract-features skill
What this skill tells your AI
The instructions your AI receives, as published by me2resh/apexyard in .claude/skills/extract-features/SKILL.md and read by ahel’s review.
Writing rule
When this skill writes a durable artifact, read .claude/rules/writing-standard.md. Use the controlled technical writing profile.
/extract-features — Feature Inventory for Greenfield Rewrites
Walks the target project's codebase across six discovery axes and writes a consolidated Feature Inventory. The artefact is the "what we must preserve" specification for a greenfield rewrite (different language, framework, or architecture) — instead of reverse-engineering features one route at a time, hand the inventory to the rewrite team.
This skill complements /handover:
/handoverproduces a high-level project assessment — origin, current health, integration plan, applicable roles. The output is the bridge between "we just inherited this codebase" and "this codebase is now governed by our normal SDLC"./extract-featuresproduces a granular feature catalogue — every route, model, job, test name, UI screen, and documented capability the existing system exposes. The output is the input to a greenfield-rewrite spec.
Run /handover first when adopting an unfamiliar repo; run /extract-features second when you've decided to rewrite it.
See also: /feature-diagram <feature-slug> — once the inventory exists, this skill emits a per-feature Mermaid sub-graph (routes + models + jobs + screens for one feature) at projects/<name>/features/<slug>.md. The inventory's Feature column gains a link to each per-feature diagram. Sibling to /c4 (system topology) and /dfd (data flows) in the architecture-doc family — different lens (per-feature slice) on the same codebase. See AgDR-0035 for the design rationale.
LSP-aware (optional, recommended)
Discovery walks across all six axes — documentSymbol, references, definition queries — are the obvious win for LSP. With ENABLE_LSP_TOOL=1 + per-language plugin (per docs/getting-started.md § "Optional: LSP-aware code navigation"), route-handler enumeration, model walks, and test-name extraction are ~3-15× cheaper in token cost than grep + Read. Without LSP, the skill falls back to grep transparently using the framework signatures listed below. No new failure mode, just optional speed.
The skill detects the active language from package.json / pyproject.toml / Gemfile / go.mod / Cargo.toml and dispatches to the matching axis-walker logic regardless of LSP state.
Path resolution
Read the registry path via portfolio_registry and the per-project docs dir via portfolio_projects_dir from .claude/hooks/_lib-portfolio-paths.sh. Source the helper at the top of any bash block that touches those paths:
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
projects_dir=$(portfolio_projects_dir)
Defaults match today's single-fork layout (./projects). Adopters in split-portfolio mode override the portfolio.{registry, projects_dir} keys in .claude/project-config.json — the helper resolves whichever mode they're in. See docs/multi-project.md.
Write targets (see me2resh/apexyard#373 + #443): paths documented as projects/<name>/X in this skill are canonical adopter-facing forms — implement them in bash as "${projects_dir}/<name>/X". Never construct from "${PWD}/projects/...", "$(git rev-parse --show-toplevel)/projects/...", or a literal ./projects/... — those break in split-portfolio v2 mode where projects_dir resolves to a sibling repo.
REQUIRED per-block preamble (see #443): Claude executes each bash block as a separate shell invocation. The projects_dir assignment from the Path resolution section above does NOT carry into later blocks. Every bash block that writes to a projects/<name>/X path MUST start with this three-line preamble so it's self-contained:
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-read-config.sh"
source "$(git rev-parse --show-toplevel)/.claude/hooks/_lib-portfolio-paths.sh"
projects_dir=$(portfolio_projects_dir)
# ... now write to "${projects_dir}/<name>/X"
The Path resolution section's example sources the helper once for documentation purposes; it does not absolve later blocks from sourcing it themselves. Treat each bash fence as a fresh process.
Usage
/extract-features # current project (cwd inside workspace/<name>/)
/extract-features billing-api # registered project; resolve to workspace/billing-api/
/extract-features . # treat cwd as the project root
/extract-features billing-api --with-mockups # also emit AI-inferred ASCII wireframes per UI screen
If <project-name> is given but workspace/<name>/ doesn't exist, the skill stops and asks the user to clone the project (it does not auto-clone — that's a side-effect with cost; same convention as /handover).
--with-mockups (opt-in, default off)
When set, the inventory file gains a new ## Screens section after the existing axes. For each UI screen discovered in axis 5, the skill emits a low-fidelity ASCII wireframe — boxed layout, form fields ([ ] text, [v] dropdown, [X] checkbox, [ Button ] button), tables as ASCII grids, max 80 chars wide.
The wireframes are model-inferred from static analysis — route component imports, form-field bindings, data-model field types. They're not lifts from real DOM. Two design constraints follow:
- ASCII format only. No PNG/SVG/HTML. ASCII boxes keep the fidelity honest — a reader sees a sketch and treats it as a sketch.
- Mandatory disclaimer header per wireframe. Every screen wireframe carries
> AI-inferred sketch — verify before relying on. Source: <route-or-component-path>on its own line above the box.
Backward-compat: running /extract-features without --with-mockups produces today's inventory exactly — the flag adds the ## Screens section; nothing else changes.
File-size policy: if more than 10 UI screens are detected, the skill writes one file per screen under <projects_dir>/<name>/screens/<slug>.md and the ## Screens section in the inventory becomes a linked index. Threshold rationale in AgDR-0036.
Output location
projects/<name>/feature-inventory.md ← the artefact
The file is a one-off scan, not a recurring audit. There is no audit-history rotation. If the file already exists, the skill OFFERS (default-no) to overwrite — accept only if the codebase has changed substantially since the last run.
Custom template override (forward-looking)
If custom-templates/extract-features.md exists in the configured custom-templates root (per the framework's template-override layer, when available), use it as the artefact template. Otherwise use the framework default in step 4 below. Resolve via:
custom_template=""
if [[ -n "${APEXYARD_CUSTOM_TEMPLATES_ROOT:-}" ]] \
&& [[ -f "${APEXYARD_CUSTOM_TEMPLATES_ROOT}/extract-features.md" ]]; then
custom_template="${APEXYARD_CUSTOM_TEMPLATES_ROOT}/extract-features.md"
fi
If the template-override layer is not yet present in this fork, the variable is unset and the skill silently uses the default. Single-fork mode adopters are unaffected.
Process
0. Resolve the target
- If the argument is
.→ use cwd. - If the argument names a registered project → resolve to
workspace/<name>/(the live working copy). If the workspace clone doesn't exist, prompt the user to clone first; do not auto-clone. - If no argument and cwd is inside
workspace/<name>/→ use that project. - If no argument and cwd is the ops-fork root → ask which registered project.
If the resolved target has none of the discovery signals (no package.json, pyproject.toml, Gemfile, go.mod, Cargo.toml, no source dirs) → stop and tell the user there's nothing to scan.
Capture and report the scope: which subdirectories will be walked, which will be skipped (vendored: node_modules, vendor, .venv, target, dist, build, coverage, .next, .nuxt).
1. Detect the tech stack
Same detection table as /handover step 3 — minimum information to dispatch the per-axis walkers:
| Signal | Stack |
|---|---|
package.json | Node — read dependencies / devDependencies to identify framework |
pyproject.toml / requirements.txt / setup.py | Python |
Gemfile / Gemfile.lock | Ruby |
go.mod | Go |
Cargo.toml | Rust |
composer.json | PHP |
pom.xml / build.gradle | JVM |
Multiple stacks in one repo (monorepo, polyglot) → walk each subroot independently and merge findings under one inventory.
2. Walk the six discovery axes (in parallel where possible)
Run the six axis-walkers below. Each produces a list; the consolidated matrix in step 3 dedupes across axes (a route handler covered by a test name shouldn't appear twice under different "features").
When LSP is enabled and the per-language plugin is installed, prefer documentSymbol over grep for handler / class / function enumeration. When LSP is absent, use the grep signatures listed.
Axis 2a — HTTP routes / entry points
Routes describe the HTTP shape of the system — every URL the system exposes is a candidate user-facing feature.
| Framework | Signature (grep fallback) |
|---|---|
| Express / Connect | app\.(get|post|put|patch|delete|all)\s*\( ; router\.(get|post|...) |
| Fastify | (fastify|app)\.(get|post|put|patch|delete|route)\s*\( |
| NestJS | @(Get|Post|Put|Patch|Delete|All|Options|Head)\s*\( |
| Hapi | server\.route\s*\( |
| Hono / Koa | app\.(get|post|...); router\.(get|post|...) |
| Next.js (Pages Router) | files under pages/api/**/*.{ts,tsx,js,jsx} |
| Next.js (App Router) | files named route.{ts,js} under app/** |
| Remix | files named loader|action exports under app/routes/** |
| FastAPI | @(app|router)\.(get|post|put|patch|delete|api_route)\s*\( |
| Flask | @(app|bp)\.route\s*\( ; @(app|bp)\.(get|post|put|patch|delete) |
| Django | urls.py → path(, re_path(, url( ; class-based views; DRF @api_view, routers.register |
| Rails | config/routes.rb → get, post, resources, resource, namespace, scope |
| Sinatra | ^\s*(get|post|put|patch|delete)\s+['"] |
| Gin | router\.(GET|POST|PUT|PATCH|DELETE|Handle)\s*\( |
| Echo | e\.(GET|POST|...) ; g\.(GET|POST|...) |
| Chi / Fiber | r\.(Get|Post|...) ; app\.(Get|Post|...) |
| Axum | Router::new()\.route\s*\( |
| Actix-web | \.service\s*\( ; web::(get|post|put|patch|delete) |
| Rocket | #\[(get|post|put|patch|delete) |
| Laravel | routes/web.php, routes/api.php → Route::(get|post|...) |
| Spring | @(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping) |
| AWS SAM / Serverless | template.yaml / serverless.yml → Events: with Api: / HttpApi: |
| GraphQL | Query|Mutation|Subscription resolver definitions; SDL .graphql files |
For each route capture: HTTP method, path, handler symbol (file + line), and any docstring / comment immediately above. The handler name + comment is usually a strong feature signal.
Axis 2b — Data models / DB schema
Models describe the data shape of the system — every persistent entity and its relations.
| Framework | Signature |
|---|---|
| Prisma | prisma/schema.prisma → model X { ... } blocks; prisma/migrations/** |
| TypeORM | @Entity\(\) decorators; classes extending BaseEntity |
| Sequelize | sequelize.define\s*\( ; classes extending Model |
| Drizzle | pgTable|mysqlTable|sqliteTable\s*\( ; drizzle.config.{ts,js} |
| Mongoose | mongoose\.(model|Schema) ; new Schema\s*\( |
| Knex | knexfile.{js,ts}; knex.schema.createTable in migrations |
| SQLAlchemy | classes extending Base / db.Model ; __tablename__ ; Column( |
| Django ORM | models.py → classes extending models.Model |
| Active Record (Rails) | app/models/**/*.rb → class X < ApplicationRecord; db/schema.rb ; db/migrate/** |
| GORM (Go) | structs with gorm: tags; db.AutoMigrate( |
| Diesel (Rust) | table! macro; schema.rs |
| ActiveRecord-Java (JPA / Hibernate) | @Entity annotations |
| Raw SQL | migrations/**/*.sql ; db/schema.sql |
For each model capture: name, table name (if different), field list (name + type), relations (@OneToMany, belongs_to, references), and any unique / index constraints. Field names hint at features (an email_verified_at column implies email-verification flow).
Axis 2c — Async jobs / queue handlers
Background jobs describe deferred work — usually one job = one async feature (send email, generate report, sync external system).
| Framework | Signature |
|---|---|
| BullMQ / Bull | new Queue\s*\( ; new Worker\s*\( ; \.process\s*\( |
| Bee-queue | new Queue\s*\( (with bee-queue import) |
| Agenda | agenda\.define\s*\( |
| Inngest | inngest\.createFunction\s*\( ; serve\(\{ functions: |
| node-cron | cron\.schedule\s*\( |
| Celery | @app\.task ; @shared_task ; @task |
| RQ | Queue\(.*\)\.enqueue |
| APScheduler | scheduler\.add_job ; @scheduler\.scheduled_job |
| Sidekiq | classes including Sidekiq::Worker / Sidekiq::Job ; perform\( method |
| Resque | classes with @queue = |
| Active Job (Rails) | app/jobs/**/*.rb → class X < ApplicationJob |
| AWS SQS handlers | Lambda handlers with Records[].eventSource == "aws:sqs" ; SAM Events: SQS: |
| AWS EventBridge / cron | SAM Events: Schedule: ; CloudWatch Events rules |
| Cron defs | crontab files; */N * * * * patterns in YAML / config |
| Temporal / Cadence | @workflow.defn ; @activity.defn |
| Faktory | classes including Faktory::Job |
For each job capture: name, trigger (queue name, cron expression, event source), and the handler function. Job names are typically verb-phrases that name a feature directly.
Axis 2d — Test names (the gold axis)
Test names are the cheapest, most accurate signal of what a system DOES — they're written by humans deliberately describing behaviour. Routes describe HTTP shape; models describe data shape; tests describe behaviour. Walking test names surfaces features that routes + models can't (e.g. "user can recover password via email", "admin can bulk-delete users with confirmation").
| Framework | Signature |
|---|---|
| Jest / Vitest / Mocha | describe\s*\(\s*['"];it\s*(\s*['"] ; test\s*\(\s*['"]` |
| Playwright / Cypress | test\s*\(\s*['"];describe\s*(\s*['"] ; it\s*\(\s*['"]` |
| pytest | def test_[a-z_]+\s*\( ; class Test\w+: |
| unittest | def test_[a-z_]+\s*\(self ; class (\w+)\s*\(\s*unittest\.TestCase |
| RSpec | describe\s+['"] ; context\s+['"] ; it\s+['"] |
| Minitest | class \w+ < (Minitest::Test|ActiveSupport::TestCase) ; def test_\w+ ; it ['"] |
| Go testing | func Test\w+\(t \*testing\.T\) ; t\.Run\s*\(\s*" |
| Cargo test | #\[test\] ; mod tests ; fn \w+\(\) inside tests module |
| JUnit | @Test annotations; method names void should_\w+ ; void test\w+ |
| PHPUnit | function test\w+\( ; @test docblocks |
For each test capture: the full describe/context/it sentence (concatenated for nested specs). Cluster the sentences — patterns like "user can …", "admin can …", "guest cannot …" reveal features and roles.
Axis 2e — UI screens / forms / interactions
UI components describe the interaction surface of the system — every screen, form, and named interaction is a candidate feature.
| Framework | Signature |
|---|---|
| React (router) | react-router-dom <Route path=; Next.js pages/**; Next.js app/**/page.{tsx,js}; Remix app/routes/** |
| React (components) | top-level function|const \w+ = ... returning JSX in src/components/, src/screens/, src/pages/, src/views/ |
| Vue | .vue files; defineComponent\s*\( ; route files (router/index.ts) |
| Svelte / SvelteKit | routes/**/+page.svelte ; routes/**/+layout.svelte ; .svelte components |
| Angular | @Component\s*\( ; RouterModule\.forRoot\s*\(\s*\[ ; path: entries |
| Form libraries | <form> tags; useForm\(; react-hook-form register\(; formik; <Field name="; Vue v-model |
| Storybook | *.stories.{ts,tsx,js,jsx,mdx} files — story names are user-flow names |
| Tailwind / CSS modules | not features themselves, but presence indicates UI surface area |
| Mobile (RN) | react-navigation Stack.Screen name= ; <Tab.Screen name= |
| Mobile (Flutter) | MaterialPageRoute ; GoRoute ; Navigator.push |
For each screen capture: route path (if router-mapped), component name, and the form fields it contains (if any). Form-field names + labels are explicit feature signals (a confirmEmail field implies email-verification UX).
Axis 2f — Documented features
Documented features are the author's own enumeration — usually the most accurate but least complete (often stale).
Sources:
README*— look for "Features" / "What it does" sections (^##\s+Features?headers and the bullet list that follows)docs/features/**— entire directory if presentdocs/index.md/docs/README.md— top-level docsCHANGELOG*— extract## [Unreleased] Addedand historical### AddedblocksCONTRIBUTING.md— sometimes includes a feature taxonomy- API docs (
openapi.yaml,swagger.json) — every operation summary is a feature - GitHub Issues with
enhancement/featurelabels (closed) — can hint at what shipped, but treat as supplementary
For each documented feature capture: title, source (file + line), and the description verbatim. This is the only axis where the author's intent is recorded; the other five infer it from the code.
3. Consolidate
Build a single feature matrix that dedupes findings across the six axes. The matrix columns:
| Column | Source |
|---|---|
| Feature | Inferred name (verb-phrase preferred: "Create order", "Reset password via email", "Bulk-delete users") |
| Surface | UI / API / Job / Internal — the entry point the user / operator interacts with |
| Status | Active (referenced from a current code path) / Deprecated (only in CHANGELOG / removed code) / Untested (route exists, no test names match) |
| Source | Which axes corroborated the feature — e.g. route + test, model + UI, doc only |
| Notes | Constraints, side effects, integrations the scanner spotted (e.g. "sends email via SendGrid", "Stripe webhook required", "rate-limited to 10/min") |
Deduplication rules:
- A route + a test that exercises it + a UI form that posts to it → one matrix row, sources:
route + test + UI - A model with no route and no test → still a row (data feature), sources:
model only - A documented feature not corroborated by code → row with
Status: Documented but not found in codeand a note flagging stale docs
Aim for 30-150 rows for a typical mid-sized app. If you produce fewer than 10 rows on a non-trivial codebase, the walker missed signatures — flag in "Coverage gaps".
4. Write the inventory
Default template (used when no custom override is configured):
# {project-name} — Feature Inventory
**Date**: {YYYY-MM-DD}
**Scanner**: `/extract-features` (apexyard)
**Scope**: {repo path}
**Stack detected**: {language(s) + framework(s) from step 1}
## Coverage scope
**Walked**:
- {list of subdirectories actually scanned, e.g. `src/`, `app/`, `tests/`, `docs/`}
**Skipped** (vendored / generated / fixtures):
- {list of directories pruned, e.g. `node_modules/`, `dist/`, `.next/`, `coverage/`, `tests/fixtures/`}
**Axes that produced findings**:
- {checked list of the six axes, with `(N items)` per axis}
## Consolidated feature matrix
| # | Feature | Surface | Status | Source | Notes |
|---|---------|---------|--------|--------|-------|
| 1 | Create order | API + UI | Active | route + test + UI | POST `/api/orders`; charges Stripe; sends confirmation email |
| 2 | Reset password via email | UI + Job | Active | route + test + UI + job | one-time token expires in 1h |
| ... | ... | ... | ... | ... | ... |
## Per-axis findings
### HTTP routes / entry points ({N})
| Method | Path | Handler | File | Notes |
|--------|------|---------|------|-------|
| ... | ... | ... | ... | ... |
### Data models / DB schema ({N})
| Model | Table | Fields | Relations | File |
|-------|-------|--------|-----------|------|
| ... | ... | ... | ... | ... |
### Async jobs / queue handlers ({N})
| Job | Trigger | Handler | File |
|-----|---------|---------|------|
| ... | ... | ... | ... |
### Test names ({N})
Grouped by file or feature cluster:
#### `tests/orders.test.ts`
- Order creation › creates order with valid payload › returns 201
- Order creation › rejects invalid currency › returns 400
- Order creation › sends confirmation email on success
- ...
### UI screens / forms / interactions ({N})
| Route | Component | Fields | File |
|-------|-----------|--------|------|
| ... | ... | ... | ... |
### Documented features ({N})
| Title | Source | Description |
|-------|--------|-------------|
| ... | ... | ... |
{IF --with-mockups, INSERT the `## Screens` section here. Each wireframe carries
the mandatory disclaimer header `> AI-inferred sketch — verify before relying
on. Source: <path>` on its own line above the ASCII box. See step 4b.}
## Coverage gaps
The scanner could **not** determine these — they need human review of the existing code or stakeholder interviews:
- **Business rules embedded in code logic** — discount stacking, eligibility checks, fraud heuristics. The scanner sees the function, not the policy.
- **Integration patterns** — webhook signature schemes, retry policies, dead-letter queues unless they're explicit in IaC.
- **Permission / authorisation matrix** — which roles can do what. Routes show endpoints; only manual review of guards / middleware reveals the full matrix.
- **Configuration-driven behaviour** — feature flags, environment-specific toggles.
- **Implicit features in cron / SQS handlers** with generic names — a job called `process_queue` doesn't name a feature.
- **Data-cleanup / TTL policies** — usually only in DB triggers or cron specs.
- **Stale documented features** — entries in CHANGELOG / README that reference removed code.
## Recommended next steps
1. **Review with the previous owner** — reconcile the matrix with their mental model. Expect ~10-20% drift (features they think exist that don't, features that exist they forgot about).
2. **Write user stories per matrix row** — translate "GET /api/orders" into "As a customer, I want to view my orders". The inventory is the input; user-story authoring is a human task (consider `/feature` for each story you intend to ship in v1).
3. **Identify the smallest-coherent-subset for v1 of the rewrite** — not every feature needs to ship in the first release. Bucket features into `must-have v1` / `nice-to-have v1.x` / `defer / drop`.
4. **Validate the deferred / dropped buckets with stakeholders** — the prior-art bias makes the inventory feel exhaustive, but rewriting is also an opportunity to drop dead weight.
5. **Run `/handover {project-name}`** if not already — for the high-level project assessment and integration plan that complements this granular inventory.
6. **Use `/c4 {project-name} --level=2`** to capture the existing system's container topology — pairs well with the inventory as input to a rewrite design.
## Open questions
- {anything axis-specific the scanner couldn't resolve, e.g. "saw `app.use(authMiddleware)` but couldn't trace the auth scheme — JWT? session? OAuth?"}
4b. Emit ASCII wireframes (only when --with-mockups is set)
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 501
- Forks
- 274
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
extract-features- Source
- github.com/me2resh/apexyard