添加功能
oleg-vasilyev2026-10-27查看 1658复制 1655
向 FoolProof 添加一个新的功能文件夹——包括各层、功能契约(commands、listen、resume)、功能被命名的两个位置、将其隔离的 lint 区域,以及安装器旨在防范的各种隐患。阶段 1 工作,在写下一行代码之前——适用于添加一个不属于现有功能的命令或界面,或更改功能向安装器声明的内容。
📘如何使用这个技能
- 1复制技能内容·点击上方「Copy SKILL.md」复制全部内容,或 Download 下载到本地
- 2打开你的 AI 客户端·支持 Claude Code、Trae、Cursor、ChatGPT、DeepSeek、Kimi 等任意 AI 助手
- 3粘贴为系统提示/知识文件·Claude Code:放入 .claude/skills/ 目录;普通对话:作为首条消息发送并说「请严格按这份技能文档执行」
- 4直接提出你的需求·AI 会按技能定义的工作流、格式和约束来处理,无需再解释背景
💡 提示:技能生效后,每次对话只需一句需求,AI 会按 SKILL.md 的流程执行。
SKILL.md9981 chars
---
name: add-a-feature
description: Add a new feature folder to FoolProof — the layers, the feature contract (commands, listen, resume), the two places a feature is named, the lint zone that fences it off, and the hazards the installer exists to prevent. Stage 1 work, before a line is written — use when adding a command or a screen that is not part of an existing feature, or when changing what a feature declares to the installer.
---
# Adding a feature
> **Stage 1** of [how a change becomes a release](../../../DEVELOPMENT-FLOW.md).
**A feature is a folder you can delete.** Removing `features/scoresheet/` must leave
the rest compiling and passing, and adding one must not require editing another.
Outside the folder it costs `package.json`, `src/main.ts` and the one spec that is
supposed to know the roster — and every step below serves that.
Name the folder after **what the player gets**, not after an internal noun:
`live-game/` is the game running right now on a card of buttons, `merge-names/` is
the screen that makes two names one player. A folder called `players/` or
`handlers/` is a bucket, and the next feature will be dumped in it.
## 1. The layers, and imports point downward only
```
features/<name>/
<name>-feature.ts the entry point: builds the parts and declares the commands
<name>-drawings.ts only if it draws: what the tools may draw of it, and the
tools it lends them — #shared/drawings/drawings-contract.ts
copy.en.ts every string this feature shows a user
domain/ the pure core — no framework, no I/O, no rendering
render/ state in, message text and SVG out; still pure
samples/ only if it draws: the states worth drawing at
bot/ the impure edge: grammY handlers, debouncing, rasterizing
```
`bot` may reach `render` and `domain`; `render` and `samples` may reach `domain`;
`domain` reaches nothing. **A feature that draws nothing has neither "only if"
file**, and one that does may still not be imported from `scripts/`: the tooling asks
through the contract and finds the module by its name at run time, which is what
keeps the folder deletable. Skip a layer with nothing in it rather than leaving it
empty — `diagnostics/` has no `domain/` because it decides nothing.
**The entry point sits at the feature root, not inside a layer.** It is the
feature's composition root, the same job `src/main.ts` does one level up, so
opening the folder shows what the feature *is* before how it works.
Two ways to name that subfolder wrong, both paid for here. A **bucket** — `helpers/`,
`common/`, a second `shared/` inside a feature — is the vagueness the file names were
cured of. A **process** name is worse than it looks: `bot/opening/` reads fine to
whoever has just split the folder and tells everybody else nothing, because opening
is not something the player ends up holding. Name it after the thing they do hold —
a picture, a screen, an entity the commands are about.
**`check-docs` fails a layer root above nine files**, which is unambiguous crowding
rather than a real limit — the fix is always a named subfolder, never a bigger
number. It is a late alarm on purpose: seven fired on a folder that needed no split,
so the question is the rule and the count only makes somebody ask it.
## 2. What the feature declares
A feature does **not** get the `Bot`. It returns a `Feature`
(`shared/telegram/feature-contract.ts`):
- `commands` — each with `menuDescription` and `help`, both read from `copyIn(locale)`.
`/help` and the `/` menu are generated from this one list, so they cannot drift
from what is installed. `hidden: true` keeps a command out of both while still
registering it, which is how the installer filters twice from a single list.
- `listen` — optional, receives a narrow `Listeners` with only `onText` and
`onTap`.
- `resume` — optional, called once after the commands are installed and before
polling starts: the seam for catching up on whatever the previous run left
behind. **It may never be the reason the bot fails to start** — a `resume` that
throws is logged and skipped.
- `stop` — optional, for a timer or a pending edit that must be flushed.
Two hazards this contract exists to prevent, both of which have already happened
here:
- **Commands must be registered before any `bot.on("message:text")` filter**, or a
text handler that returns without calling `next()` silently swallows every
command below it. The installer registers commands first, then listeners, and calls
`next()` after each text listener — which is what lets a second feature hear text
at all; a scenario replying to the *other* feature's question proves it.
- **`onTap` takes the pattern the feature owns**, not just a handler:
`listeners.onTap(MERGE_TAPS, handler)`. A bare `bot.on("callback_query:data")`
per feature meant the first feature registered saw every tap and answered "Card
updated" for data that was never its own — the second feature's buttons were dead
before they were written. Export the pattern from the feature's own codec and
make sure it cannot match another screen's data. A tap matching nothing is
answered by the installer itself.
## 3. Name it in exactly two places
- `package.json` `imports`: `"#<name>/*": "./src/features/<name>/*"`. These are
Node's own subpath imports, the only aliasing that survives having no build step,
and one declaration serves the compiler, Node, Vitest and Stryker's sandbox.
- `src/main.ts`: build it and put it in the `features` array. `main.spec.ts` is the
one spec that is *supposed* to know the roster, so it changes too.
There are **no relative imports in `src/`** — not even for a sibling file. The value
of the rule is that every import line reads the same way.
## 4. Fence it off, and prove the fence
Nothing to add: `eslint.config.js` reads `src/features/` from disk, so the folder you
just made already has its zones — `domain/`, `render/` and `samples/` may not import a
framework or reach upward, and none may reach into another feature or `#app/`.
**A zone is not finished until a deliberate violation has been shown to fail the
lint.** Write a throwaway file in each new zone that imports something banned, run
the lint gate on it, see it fail, delete it. Zones have shipped here that never fired,
every one of them silently:
- a later flat-config block **replaces** an earlier one for a file matched by both,
so purity and independence have to be one pattern list per zone;
- minimatch reads a leading `#` as a **comment**, so `#live-game/**` matches
nothing — alias bans are compiled to `regex` patterns instead;
- `no-restricted-imports` reads only a **static** `import`, so every file loading its
subject with `await import("…")` — almost every spec here — passed every zone. The
bans compile into `no-restricted-syntax` too, so a static-only probe proves half.
## 5. If the feature draws anything, open the drawings first
Before sketching a new poster or screen, **open the committed PNGs in
`docs/posters/` and look at them**. Not the renderer, not the palette module —
the pictures. The visual language here is carried by things no source file states:
sheets run past two thousand pixels tall, sections are dense, a row is a numbered
index plus a coloured spine plus a title plus a muted reason, and the headline bad
news is a full-width red plate.
A mockup drawn from reading `palette.ts` and `card-metrics.ts` came back from the
owner twice, and the fix both times was already sitting in `docs/posters/awards-en.png`.
Two `Read` calls would have skipped both rounds.
Then draw the mockup with the real renderer as soon as one exists, and **look at
the rasterized PNG yourself** before asking anybody else to. The two defects that
survived into review — an axis with a single label, and a sheet 36px past
`IMAGE_MAX_HEIGHT` — were both visible in the picture and invisible in the code.
## 6. Tests, copy, and how big the phase is
- Every file gets a spec beside it, and everything it imports is mocked. Load the
`write-a-spec` skill before writing them.
- Every user-readable string lives in `copy.en.ts` and `copy.ru.ts` and nowhere else,
and a copy function interpolates rather than decides — `CLAUDE.md` has why.
- **Freeze a refusal union against the copy lines it will be spoken with**: every
argument a refusal's line interpolates is a field of that union member, not
something the handler digs up afterwards.
- A keyboard whose buttons carry `callback_data` gets e2e scenarios; see
[`e2e/README.md`](../../../e2e/README.md). A **URL** button does not earn one on
its own — nothing routes back to the bot, so there is no dispatch to prove.
- A new feature that adds a repository method or a screen with states is a
contract-changing phase: it owes a `PLAN.md` section. The `finish-phase` skill has
the table and the gates.
## Judging a folder you did not add
The feature's defining property is mechanical, so test it rather than reading for
it. Ask in this order — the first is worth the other five:
1. **Delete the folder and run `node scripts/gates/gate-runner.ts typecheck`.** Anything that stops compiling
outside `src/main.ts` and `src/main.spec.ts` is the finding, and its path names
the coupling. Restore afterwards; the probe is the point, not the deletion.
2. Does the folder name say what the player gets, or an internal noun?
3. Does `commands` list one entry per thing the feature gives the player, so
`/help` and the `/` menu cannot drift from what is installed?
4. Was a deliberate violation shown to **fail** the lint for each new zone? A zone
that never fires looks exactly like one with nothing to report.
5. Can the pattern passed to `onTap` match another screen's `callback_data`?
6. Does anything in `scripts/` now import this feature? It may not — the tooling asks
what features offer and finds them at run time; naming its folder is allowed.