pattern-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/dist/index.js +1078 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Don Richard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
# Pattern
|
|
2
|
+
|
|
3
|
+
MCP server exposing two tools. `recommend_component` judges whether a UI
|
|
4
|
+
component need should be met with an existing shadcn/ui or 21st.dev
|
|
5
|
+
component, or requires a custom build guided by a real-app reference from
|
|
6
|
+
Mobbin and/or Figma Community. Returns a structured verdict, not a list of
|
|
7
|
+
search results — built for an agent to consume mid-build, not for a human
|
|
8
|
+
to browse. `record_component_decision` records a decision the calling agent
|
|
9
|
+
has actually acted on, so a later `recommend_component` call in the same
|
|
10
|
+
project can weigh it as a consistency signal — see
|
|
11
|
+
[Per-project decision memory](#per-project-decision-memory).
|
|
12
|
+
|
|
13
|
+
This implements the judgment layer validated in the product brief: field/
|
|
14
|
+
requirement coverage scored against real component evidence, thresholded
|
|
15
|
+
into `use_existing` / `custom_build`, with a `no_candidates_found` bucket
|
|
16
|
+
kept distinct from low coverage, a static skip-list for trivial primitives,
|
|
17
|
+
and a `computed_at` timestamp since coverage is a snapshot, not a permanent
|
|
18
|
+
fact.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
The server does not scrape shadcn/21st.dev/Mobbin/Figma itself. Each tool
|
|
23
|
+
call makes one or more requests to the Anthropic Messages API
|
|
24
|
+
(`claude-sonnet-5` by default) with the server-side `web_search` tool
|
|
25
|
+
enabled, and a system prompt that encodes the full process: skip-list
|
|
26
|
+
check, requirement extraction, candidate search, real-evidence coverage
|
|
27
|
+
scoring, threshold, and — on `custom_build` — reference lookups against
|
|
28
|
+
Mobbin and Figma Community. No new credentials are required for the Figma
|
|
29
|
+
lookup — it uses the same plain `web_search` mechanism as everything else
|
|
30
|
+
in the tool, not the Figma API. The model returns structured JSON; the
|
|
31
|
+
server recomputes the coverage fraction from the `requirements_checked`
|
|
32
|
+
array itself (rather than trusting the model's stated percentage) and
|
|
33
|
+
applies the verdict/confidence threshold in code.
|
|
34
|
+
|
|
35
|
+
**Boundary-risk ensemble.** Validation found that a single run's coverage
|
|
36
|
+
score can vary between calls on the same input — not because search results
|
|
37
|
+
differ, but because the model can judge the same piece of evidence
|
|
38
|
+
differently run to run (see Known limitations). When a call's recounted
|
|
39
|
+
coverage lands close enough to a threshold boundary to plausibly flip the
|
|
40
|
+
verdict, the server automatically re-runs the judgment 2 more times and
|
|
41
|
+
takes the majority verdict. If the 3 runs disagree (a 2/3 split), the result
|
|
42
|
+
ships with `confidence: "low"` and an `ensemble` field so the calling agent
|
|
43
|
+
can see it was a close call rather than a confident read. Calls that land
|
|
44
|
+
clearly inside a threshold band never trigger this and stay single-run —
|
|
45
|
+
see [Cost](#cost) below for the measured impact.
|
|
46
|
+
|
|
47
|
+
Trivial primitives (button, input, checkbox, label, badge, spinner, tooltip,
|
|
48
|
+
avatar, icon) are caught locally before any API call, so they don't spend a
|
|
49
|
+
request.
|
|
50
|
+
|
|
51
|
+
## Setup — quickstart
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone <this repo>
|
|
55
|
+
cd pattern-mcp
|
|
56
|
+
npm install
|
|
57
|
+
npm run build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires `ANTHROPIC_API_KEY` — the account whose key you use pays for every
|
|
61
|
+
call this tool makes (see [Cost](#cost) below). Get one from the
|
|
62
|
+
[Anthropic Console](https://console.anthropic.com) (Settings → API Keys);
|
|
63
|
+
this requires its own billing setup. **This is not the same thing as a
|
|
64
|
+
Claude.ai or Claude Code subscription** — a Pro/Max plan does not cover
|
|
65
|
+
API usage, and a subscription login won't get you a key. You need a
|
|
66
|
+
separate Console account with credits or a payment method attached.
|
|
67
|
+
|
|
68
|
+
**Point your MCP client at it** — this is a standard MCP server, so it works
|
|
69
|
+
with any MCP-compatible client, not just one. Drop this into your client's
|
|
70
|
+
config (adjusting the path per client), swapping in your own project path
|
|
71
|
+
and key:
|
|
72
|
+
|
|
73
|
+
- **Claude Code**: either add `"pattern": { ... }` (the
|
|
74
|
+
block below) to the `mcpServers` object in `.mcp.json` at your project
|
|
75
|
+
root, or run:
|
|
76
|
+
```bash
|
|
77
|
+
claude mcp add pattern \
|
|
78
|
+
-e ANTHROPIC_API_KEY=sk-ant-... \
|
|
79
|
+
-- node /absolute/path/to/pattern-mcp/dist/index.js
|
|
80
|
+
```
|
|
81
|
+
This registers under `--scope local` (the default) — tied to the
|
|
82
|
+
current project directory only. Add `--scope user` (or `-s user`)
|
|
83
|
+
instead to make it available across **all** your projects:
|
|
84
|
+
```bash
|
|
85
|
+
claude mcp add pattern \
|
|
86
|
+
-e ANTHROPIC_API_KEY=sk-ant-... \
|
|
87
|
+
--scope user \
|
|
88
|
+
-- node /absolute/path/to/pattern-mcp/dist/index.js
|
|
89
|
+
```
|
|
90
|
+
**Flag order matters here.** `-e`/`--env` and `-s`/`--scope` must come
|
|
91
|
+
*before* the `--` separator and command — `claude mcp add`'s
|
|
92
|
+
`[args...]` capture is variadic, so a flag placed *after* the command
|
|
93
|
+
(e.g. `node dist/index.js --scope user`) is liable to be swallowed as
|
|
94
|
+
an argument to `node` itself instead of being parsed as a flag for
|
|
95
|
+
`claude mcp add`. Keep all your flags on the left of `--`, the command
|
|
96
|
+
and its own args on the right.
|
|
97
|
+
|
|
98
|
+
`claude mcp add` stores this in `~/.claude.json` (a local- or
|
|
99
|
+
user-scoped entry depending on `--scope`), not in a project file —
|
|
100
|
+
check with `claude mcp list` (should show
|
|
101
|
+
`pattern ... ✔ Connected`). Avoid `claude mcp get
|
|
102
|
+
pattern` if you can — it prints your key back to the
|
|
103
|
+
terminal in plaintext, so `claude mcp list`'s connection status is
|
|
104
|
+
usually enough without that risk.
|
|
105
|
+
- Cursor: `.cursor/mcp.json`
|
|
106
|
+
- Codex CLI: `~/.codex/config.toml` (global) or `.codex/config.json`
|
|
107
|
+
(project-level) — same `mcpServers` shape, TOML or JSON depending on file
|
|
108
|
+
- Claude Desktop: its MCP settings file
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"pattern": {
|
|
114
|
+
"command": "node",
|
|
115
|
+
"args": ["/absolute/path/to/pattern-mcp/dist/index.js"],
|
|
116
|
+
"env": { "ANTHROPIC_API_KEY": "sk-ant-..." }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Restart your MCP client, then confirm it picked up the tool — ask your
|
|
123
|
+
agent to list its available MCP tools and look for `recommend_component`.
|
|
124
|
+
For Claude Code specifically, `claude mcp list` will show a health-checked
|
|
125
|
+
`✔ Connected` status without needing to ask the agent directly.
|
|
126
|
+
|
|
127
|
+
## Try it
|
|
128
|
+
|
|
129
|
+
Ask your agent something like: *"Use recommend_component to find me a UI
|
|
130
|
+
component for a price breakdown showing nightly rate, cleaning fee, service
|
|
131
|
+
fee, and taxes — I'm building an Airbnb-style booking checkout in React with
|
|
132
|
+
Tailwind."* The agent should call the tool and act on the verdict directly
|
|
133
|
+
(install a real component, or start from the returned checklist and
|
|
134
|
+
Mobbin/Figma Community reference) rather than just describing what it
|
|
135
|
+
found.
|
|
136
|
+
|
|
137
|
+
**What you'll actually see:** both verdict paths now include a written,
|
|
138
|
+
grounded description, not just a bare link or install command. A
|
|
139
|
+
`use_existing` verdict includes `component_description` — what the
|
|
140
|
+
recommended component actually does and looks like, described before the
|
|
141
|
+
agent installs anything. A `custom_build` verdict includes
|
|
142
|
+
`reference_description` for each reference it found — what that Mobbin
|
|
143
|
+
screen or Figma Community file actually shows. Either way, testers get a
|
|
144
|
+
specific, readable description grounded in what the model actually found
|
|
145
|
+
during search, not generic filler.
|
|
146
|
+
|
|
147
|
+
If you want to sanity-check the tool itself rather than a real feature,
|
|
148
|
+
these five needs are the ones this project's own validation was built
|
|
149
|
+
against, spanning the full range of outcomes (clean commodity match,
|
|
150
|
+
false-positive-prone case, zero candidates, and boundary/near-tie cases):
|
|
151
|
+
price breakdown with fees and taxes, cancellation policy display, host
|
|
152
|
+
earnings dashboard, image gallery for a property listing, and a host-guest
|
|
153
|
+
messaging inbox — all in the same Airbnb-style rental marketplace domain.
|
|
154
|
+
|
|
155
|
+
## Tool: `recommend_component`
|
|
156
|
+
|
|
157
|
+
**Input:**
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"component_need": "price breakdown with fees and taxes",
|
|
161
|
+
"domain": "Airbnb-style rental marketplace",
|
|
162
|
+
"framework": "React + Tailwind",
|
|
163
|
+
"existing_stack": "already using shadcn/ui",
|
|
164
|
+
"project_id": "my-booking-app"
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
`component_need` should be specific, not a category — "price breakdown with
|
|
168
|
+
fees and taxes" not "pricing". Vague category names are what produced
|
|
169
|
+
false-positive matches during validation (a generic SaaS pricing-tier
|
|
170
|
+
component scoring as a match for a booking checkout).
|
|
171
|
+
|
|
172
|
+
`project_id` is optional — a project name or path the calling agent
|
|
173
|
+
supplies. When present, past decisions recorded for that same `project_id`
|
|
174
|
+
via `record_component_decision` are pulled from
|
|
175
|
+
[per-project decision memory](#per-project-decision-memory) and included in
|
|
176
|
+
the prompt as a *signal, not a rule*: the model is instructed to weigh
|
|
177
|
+
consistency with a highly similar past decision, but never to let it
|
|
178
|
+
override a genuinely better match this search finds, and never to skip
|
|
179
|
+
searching or scoring because a past decision exists. Coverage is still
|
|
180
|
+
computed fresh on every call regardless — see
|
|
181
|
+
[No caching, by design](#known-limitations-carried-over-from-validation).
|
|
182
|
+
Omit `project_id` to skip memory entirely; there's no shared/global bucket
|
|
183
|
+
it falls back to.
|
|
184
|
+
|
|
185
|
+
**Output:** JSON matching:
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"verdict": "use_existing | custom_build",
|
|
189
|
+
"confidence": "high | medium | low",
|
|
190
|
+
"reason": "scored | no_candidates_found | skip_list",
|
|
191
|
+
"computed_at": "2026-08-23",
|
|
192
|
+
"requirements_checked": [ { "requirement": "...", "met": true, "evidence": "..." } ],
|
|
193
|
+
"coverage": "5/7 (71%)",
|
|
194
|
+
"recommendation": {
|
|
195
|
+
"source": "21st.dev | shadcn | null",
|
|
196
|
+
"install_command": "string | null",
|
|
197
|
+
"component_description": "string (use_existing only) | null",
|
|
198
|
+
"reference": {
|
|
199
|
+
"source": "Mobbin | Figma Community",
|
|
200
|
+
"url": "...",
|
|
201
|
+
"flow_name": "... (Mobbin only)",
|
|
202
|
+
"file_name": "... (Figma Community only)",
|
|
203
|
+
"reference_description": "...",
|
|
204
|
+
"url_type": "deep_link | entry_point"
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
"ensemble": { "triggered": false },
|
|
208
|
+
"past_decision_signal": { "considered": true, "note": "..." }
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
`ensemble.triggered` is `false` on the normal single-pass path. On a
|
|
212
|
+
boundary-risk coverage result it becomes
|
|
213
|
+
`{ "triggered": true, "runs": ["use_existing", "custom_build", "use_existing"], "agreement": "2/3" }`
|
|
214
|
+
— see [Ensemble cost](#ensemble-cost-boundary-risk-cases-only) below.
|
|
215
|
+
|
|
216
|
+
`past_decision_signal` only appears when `project_id` was provided **and**
|
|
217
|
+
that project has at least one past decision recorded — omitted entirely
|
|
218
|
+
otherwise, never a hollow `{ "considered": false }` on a call with nothing
|
|
219
|
+
to consider. `considered` is `true` only when a past decision was
|
|
220
|
+
genuinely similar enough to factor into scoring or recommendation, not
|
|
221
|
+
just present in the list; `note` names which decision and how, or why none
|
|
222
|
+
applied. This is enforced server-side, not just prompted: a
|
|
223
|
+
`considered`/`note` pair the model returns on a call that had no
|
|
224
|
+
past-decision context in its prompt is discarded rather than trusted — see
|
|
225
|
+
[Per-project decision memory](#per-project-decision-memory).
|
|
226
|
+
|
|
227
|
+
**`recommendation.reference` shape depends on how many sources actually
|
|
228
|
+
grounded**, not just on the verdict. On a `custom_build` verdict:
|
|
229
|
+
- Both Mobbin and Figma Community returned a real, grounded result:
|
|
230
|
+
`reference` is an **array of both** objects.
|
|
231
|
+
- Only one of the two grounded: `reference` is a **single object**, same
|
|
232
|
+
shape as before this feature existed — never a one-element array.
|
|
233
|
+
- Neither grounded: `reference` is `null`, same as today's
|
|
234
|
+
no-fabrication rule for a Mobbin-only lookup that found nothing.
|
|
235
|
+
|
|
236
|
+
No new credentials are required for the Figma Community reference — it
|
|
237
|
+
uses the same `web_search` mechanism as every other lookup in this tool,
|
|
238
|
+
not the Figma API, so there's no separate token to configure.
|
|
239
|
+
|
|
240
|
+
**`reference.url_type` tells you whether the URL is a deep link or just a
|
|
241
|
+
search entry point.** A Mobbin or Figma Community search result is very
|
|
242
|
+
often a category/browse page (e.g.
|
|
243
|
+
`mobbin.com/explore/mobile/screens/notifications`), not a direct link to
|
|
244
|
+
the specific screen or flow the model actually identified (e.g. "Saturn
|
|
245
|
+
Calendar - Notifications List") — the original gap this field exists to
|
|
246
|
+
disclose. On a `custom_build` verdict:
|
|
247
|
+
|
|
248
|
+
- **Mobbin**: the server fetches the search result page (via the
|
|
249
|
+
`web_fetch` tool) and looks for a more specific permalink to the
|
|
250
|
+
identified screen/flow actually written on that page. Found and
|
|
251
|
+
confirmed → `url_type: "deep_link"` and `url` is that permalink. Not
|
|
252
|
+
found (including when the fetch itself fails) → `url_type:
|
|
253
|
+
"entry_point"`, `url` stays the category/search page, and
|
|
254
|
+
`reference_description` is guaranteed to say so explicitly (append or
|
|
255
|
+
auto-generated server-side, never left to the model alone) — so a
|
|
256
|
+
reader always knows whether they're getting the exact screen or a
|
|
257
|
+
browse page they'll need to search themselves.
|
|
258
|
+
- **Figma Community**: a result URL containing `/community/file/` is
|
|
259
|
+
already file-specific by Figma's own URL structure, so it's treated as
|
|
260
|
+
`url_type: "deep_link"` without spending a fetch on it. A result that
|
|
261
|
+
*isn't* a `/community/file/` URL (an occasional browse/tag page) goes
|
|
262
|
+
through the same fetch-and-verify path as Mobbin. In practice a Figma
|
|
263
|
+
fetch will almost always fail regardless — `figma.com/robots.txt`
|
|
264
|
+
disallows `ClaudeBot` site-wide — so a non-file Figma result reliably
|
|
265
|
+
ends up `entry_point`, honestly.
|
|
266
|
+
|
|
267
|
+
This is enforced the same way as every other grounding rule in this
|
|
268
|
+
project: **server-side, not just prompt instruction.** A claimed deep
|
|
269
|
+
link is only kept if it's literally present in the text of a page the
|
|
270
|
+
server actually fetched; a claim that fails that check is silently
|
|
271
|
+
replaced with a real URL from an actual search/fetch result (never
|
|
272
|
+
discarded to a guess), and the entry-point caveat is force-appended to
|
|
273
|
+
`reference_description` if the model's own text didn't already disclose
|
|
274
|
+
it. `src/index.ts`'s `applyDeepLinkGrounding` is the single place this
|
|
275
|
+
happens — see its comments for the exact rules, including why a
|
|
276
|
+
model-guessed URL-pattern retry (e.g. stripping a path segment after a
|
|
277
|
+
fetch fails) is both prompted against and independently rejected by the
|
|
278
|
+
`web_fetch` tool itself (`url_not_in_prior_context`).
|
|
279
|
+
|
|
280
|
+
**`install_command` is untrusted text.** It's derived from a web search
|
|
281
|
+
result the model read, not a verified package registry, and the server
|
|
282
|
+
does not execute or validate it. The calling agent is instructed (in the
|
|
283
|
+
tool description and system prompt) to always display it to the user for
|
|
284
|
+
confirmation before running it, and never execute it automatically or
|
|
285
|
+
silently — this is expected agent behavior this project depends on, not
|
|
286
|
+
something the server enforces. See [SECURITY.md](./SECURITY.md).
|
|
287
|
+
|
|
288
|
+
## Tool: `record_component_decision`
|
|
289
|
+
|
|
290
|
+
Records a decision the calling agent has actually acted on — call it
|
|
291
|
+
**after** installing an existing component or finishing a custom build, not
|
|
292
|
+
on every `recommend_component` verdict returned. Its only job is appending
|
|
293
|
+
one entry to local [per-project decision memory](#per-project-decision-memory);
|
|
294
|
+
it runs no judgment logic and makes no Anthropic API call, so it's
|
|
295
|
+
effectively free and instant.
|
|
296
|
+
|
|
297
|
+
**Input:**
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"project_id": "my-booking-app",
|
|
301
|
+
"component_need": "price breakdown with fees and taxes",
|
|
302
|
+
"domain": "Airbnb-style rental marketplace",
|
|
303
|
+
"action": "custom_built",
|
|
304
|
+
"source": "custom",
|
|
305
|
+
"timestamp": "2026-08-25T14:32:00.000Z"
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
- `project_id` (required) — must match the `project_id` you pass to
|
|
309
|
+
`recommend_component` for this decision to ever be surfaced there. Use a
|
|
310
|
+
stable value, e.g. the project's directory path or name.
|
|
311
|
+
- `component_need` (required), `domain` (optional) — same fields as
|
|
312
|
+
`recommend_component`'s input; free text, not matched against anything
|
|
313
|
+
server-side.
|
|
314
|
+
- `action` (required) — `"installed"` or `"custom_built"`.
|
|
315
|
+
- `source` (required) — e.g. `"shadcn"`, `"21st.dev"`, or `"custom"` for a
|
|
316
|
+
custom build.
|
|
317
|
+
- `timestamp` (optional) — ISO 8601; defaults to the current time if
|
|
318
|
+
omitted.
|
|
319
|
+
|
|
320
|
+
**Output:**
|
|
321
|
+
```json
|
|
322
|
+
{ "status": "recorded", "project_id": "my-booking-app", "entry": { "...": "..." } }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Per-project decision memory
|
|
326
|
+
|
|
327
|
+
`record_component_decision` appends to a local JSON file, default path
|
|
328
|
+
`~/.pattern/memory.json`, overridable via
|
|
329
|
+
`PATTERN_MEMORY_PATH` — same override pattern as
|
|
330
|
+
[`PATTERN_LOG_PATH`](#local-call-log). It's a flat object keyed by
|
|
331
|
+
`project_id`, each value an array of decision entries in the same shape as
|
|
332
|
+
`record_component_decision`'s input (minus `project_id` itself, since
|
|
333
|
+
that's the key):
|
|
334
|
+
|
|
335
|
+
```json
|
|
336
|
+
{
|
|
337
|
+
"my-booking-app": [
|
|
338
|
+
{
|
|
339
|
+
"component_need": "price breakdown with fees and taxes",
|
|
340
|
+
"domain": "Airbnb-style rental marketplace",
|
|
341
|
+
"action": "custom_built",
|
|
342
|
+
"source": "custom",
|
|
343
|
+
"timestamp": "2026-08-25T14:32:00.000Z"
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Each project's array is capped at the **50 most recent entries** — once a
|
|
350
|
+
project hits the cap, the oldest entry is dropped as a new one is added, so
|
|
351
|
+
the file stays bounded for a long-lived project without manual cleanup.
|
|
352
|
+
|
|
353
|
+
**Only explicitly confirmed decisions are stored here — not every verdict
|
|
354
|
+
`recommend_component` returns.** The server never writes to this file on
|
|
355
|
+
its own; `recommend_component` only ever *reads* it (when `project_id` is
|
|
356
|
+
provided) and never writes to it. A verdict you don't act on, or act on
|
|
357
|
+
differently than recommended, leaves no trace here unless you call
|
|
358
|
+
`record_component_decision` yourself to say what you actually did.
|
|
359
|
+
|
|
360
|
+
**This is local-only plaintext**, same caveat pattern as the
|
|
361
|
+
[local call log](#local-call-log): nothing in this file is sent anywhere by
|
|
362
|
+
this server. `component_need` and `domain` are written here the same way
|
|
363
|
+
they're written to `calls.log` — see
|
|
364
|
+
[SECURITY.md](./SECURITY.md#what-actually-leaves-your-machine) before
|
|
365
|
+
putting anything sensitive in those fields. A write failure (disk full,
|
|
366
|
+
read-only filesystem, permissions) surfaces as a tool error on
|
|
367
|
+
`record_component_decision` itself, since — unlike the best-effort call
|
|
368
|
+
log — writing the decision *is* that tool's entire job, not a side effect
|
|
369
|
+
of it.
|
|
370
|
+
|
|
371
|
+
**This does not weaken the no-verdict-caching rule.** Memory only ever adds
|
|
372
|
+
past-decision context to the prompt for a fresh judgment pass — see
|
|
373
|
+
[No caching, by design](#known-limitations-carried-over-from-validation)
|
|
374
|
+
and the `project_id` note under
|
|
375
|
+
[Tool: `recommend_component`](#tool-recommend_component). Coverage is
|
|
376
|
+
recomputed from a real search every single call, with or without a
|
|
377
|
+
`project_id`.
|
|
378
|
+
|
|
379
|
+
## Cost
|
|
380
|
+
|
|
381
|
+
A single pass (search → score → respond) costs roughly $0.06–$0.10 with
|
|
382
|
+
Sonnet 5 at current pricing ($2/M input, $10/M output, $0.01 per
|
|
383
|
+
web_search call) — skip-listed primitives cost $0 since they never reach
|
|
384
|
+
the API. Three things keep a single pass down without touching quality:
|
|
385
|
+
|
|
386
|
+
- **Prompt caching** on the system block (`cache_control: ephemeral`) —
|
|
387
|
+
the instructions are identical every call, so repeated turns and repeated
|
|
388
|
+
invocations read from cache instead of re-billing full price.
|
|
389
|
+
- **A 2-search budget** for candidate discovery, plus 2 more reserved
|
|
390
|
+
specifically for the `custom_build` reference lookups (one each for
|
|
391
|
+
Mobbin and Figma Community) so neither has to compete with discovery
|
|
392
|
+
for the same cap — shadcn and 21st.dev are searched in the same turn
|
|
393
|
+
rather than sequentially, so the growing conversation gets re-sent
|
|
394
|
+
fewer times per call.
|
|
395
|
+
- **A separate 2-call `web_fetch` budget**, used only for the step-6
|
|
396
|
+
deep-link check described above (`max_content_tokens: 15000` caps what
|
|
397
|
+
a single category-page fetch can cost). `web_fetch` itself has no
|
|
398
|
+
per-call charge beyond the tokens the fetched page adds to context, and
|
|
399
|
+
the system prompt explicitly reserves this tool for step 6 only — the
|
|
400
|
+
model is instructed not to reach for it during requirement scoring
|
|
401
|
+
(step 4), so it doesn't compete with the reference lookups it exists
|
|
402
|
+
for.
|
|
403
|
+
- **`PATTERN_MODEL` env var** (defaults to `claude-sonnet-5`) — lets you
|
|
404
|
+
swap in a cheaper model (e.g. Haiku 4.5) without a code change. Before
|
|
405
|
+
trusting a cheaper model in production, re-run the 5 validated test cases
|
|
406
|
+
from the product brief (price breakdown, cancellation policy, earnings
|
|
407
|
+
dashboard, gallery, messaging) and diff the verdicts against Sonnet's —
|
|
408
|
+
this hasn't been tested, only reasoned about.
|
|
409
|
+
|
|
410
|
+
### Ensemble cost (boundary-risk cases only)
|
|
411
|
+
|
|
412
|
+
Testing found that a single pass isn't reliable near the verdict
|
|
413
|
+
thresholds: with the requirement checklist fixed at exactly 8 items,
|
|
414
|
+
coverage can only land on one of 9 discrete values (0, 12.5, 25, 37.5,
|
|
415
|
+
50, 62.5, 75, 87.5, 100%), and the 40%/80% thresholds sit *between* two
|
|
416
|
+
of those values (37.5↔50, and 75↔87.5). For met-counts of 3, 4, 6, or 7,
|
|
417
|
+
a single item's met/unmet judgment flipping is enough to change the
|
|
418
|
+
verdict — and it does, run to run, on identical input.
|
|
419
|
+
|
|
420
|
+
To catch that, the server runs a **targeted ensemble**: every pass still
|
|
421
|
+
runs once as normal, but if the result lands on one of those four risky
|
|
422
|
+
met-counts (`isBoundaryRisk` in `src/index.ts`), it triggers 2 additional
|
|
423
|
+
full passes (3 total) and takes the majority verdict. Confidence is
|
|
424
|
+
forced to `"low"` on a genuine 2/3 split, regardless of what any
|
|
425
|
+
individual pass reported — a real disagreement across identical inputs
|
|
426
|
+
is uncertainty the tool should surface, not paper over. Everything else
|
|
427
|
+
(0, 1, 2, 5, 8 met — far enough from both thresholds that a 1-item swing
|
|
428
|
+
can't flip the verdict) returns the single pass as-is, at 1x cost. An
|
|
429
|
+
earlier version also triggered on `reason: "no_candidates_found"`
|
|
430
|
+
(a separate source of run-to-run inconsistency); that trigger was removed
|
|
431
|
+
after testing showed it never actually changed a verdict in this
|
|
432
|
+
session and was pure added cost.
|
|
433
|
+
|
|
434
|
+
The output includes an `ensemble` field so callers can see whether this
|
|
435
|
+
happened: `{ "triggered": false }` on the fast path, or
|
|
436
|
+
`{ "triggered": true, "runs": ["use_existing", "custom_build", "use_existing"], "agreement": "2/3" }`
|
|
437
|
+
when it fired.
|
|
438
|
+
|
|
439
|
+
**Measured cost, not just worst case:** across the last 5-case × 3-run
|
|
440
|
+
test batch (15 outer calls), 8 stayed single-run and 7 triggered the
|
|
441
|
+
ensemble (21 calls), for **29 total API calls — a ~1.9x blended average
|
|
442
|
+
multiplier**, not the 3x a naive "ensemble triggered" framing implies.
|
|
443
|
+
Worst case is still 3x per call when it triggers; most calls don't.
|
|
444
|
+
|
|
445
|
+
Ensembling does *not* fully eliminate the underlying variance for the
|
|
446
|
+
hardest cases. When a case's true coverage sits close enough to a
|
|
447
|
+
threshold that per-item judgment is close to a coin flip, majority-of-3
|
|
448
|
+
is a noisy estimator: it protects any single call against one unlucky
|
|
449
|
+
draw, but a *different* set of 3 draws on the next invocation can still
|
|
450
|
+
land on the other side. One case (image gallery) kept flipping across
|
|
451
|
+
outer runs even with the ensemble active, always with a 2/3 split and
|
|
452
|
+
`confidence: "low"` — the tool is correctly reporting low confidence on
|
|
453
|
+
a genuinely ambiguous case rather than a bug to fix with a bigger N.
|
|
454
|
+
|
|
455
|
+
### Session call cap
|
|
456
|
+
|
|
457
|
+
The server caps itself at **40 calls per process lifetime** by default,
|
|
458
|
+
configurable via `PATTERN_SESSION_CAP`. This protects against a
|
|
459
|
+
*buggy calling agent* looping on the tool — a retry loop, a stuck agent
|
|
460
|
+
re-calling the same need repeatedly — not against normal project usage.
|
|
461
|
+
The number is grounded in real usage, not arbitrary: a full pass through
|
|
462
|
+
a realistic ~25-component project (scaled up from this project's own
|
|
463
|
+
5-case Airbnb-style validation list) costs 25 calls, so 40 leaves
|
|
464
|
+
headroom for iteration on top of that without being so high it fails to
|
|
465
|
+
catch an actual runaway loop before it gets expensive. Skip-listed
|
|
466
|
+
primitives don't count toward the cap, since they never reach the API.
|
|
467
|
+
The counter is in-memory and resets when the server process restarts —
|
|
468
|
+
raise the cap via the env var if 40 is genuinely too low for your
|
|
469
|
+
project, don't just restart repeatedly to reset it.
|
|
470
|
+
|
|
471
|
+
## Local call log
|
|
472
|
+
|
|
473
|
+
Every call that reaches the API (skip-list hits excluded, same exclusion
|
|
474
|
+
as the session cap) appends one JSON line to a local log file — default
|
|
475
|
+
path `~/.pattern/calls.log`, overridable via
|
|
476
|
+
`PATTERN_LOG_PATH`. This is **local-only**: nothing here is sent
|
|
477
|
+
anywhere by this server, it's purely for your own debugging/usage
|
|
478
|
+
visibility.
|
|
479
|
+
|
|
480
|
+
Each line looks like:
|
|
481
|
+
```json
|
|
482
|
+
{"timestamp":"2026-08-24T21:12:43.882Z","component_need":"cancellation policy display","domain":"Airbnb-style rental marketplace","framework":"React + Tailwind","verdict":"custom_build","confidence":"high","reason":"scored","coverage":"2/8 (25%)","ensemble_triggered":false,"reference_sources_grounded":["Mobbin","Figma Community"]}
|
|
483
|
+
```
|
|
484
|
+
`ensemble_agreement` is only present when `ensemble_triggered` is `true`.
|
|
485
|
+
`reference_sources_grounded` is only present on `custom_build` verdicts,
|
|
486
|
+
and only lists sources (`"Mobbin"`, `"Figma Community"`) that actually
|
|
487
|
+
grounded — matches whatever `recommendation.reference` ended up being
|
|
488
|
+
after grounding is enforced (see the
|
|
489
|
+
[Tool](#tool-recommend_component) section above for the full shape
|
|
490
|
+
rules).
|
|
491
|
+
|
|
492
|
+
**Deliberately excluded**: full `requirements_checked` evidence text, and
|
|
493
|
+
the API key — never written here. **Included in plaintext**:
|
|
494
|
+
`component_need` and `domain` — see
|
|
495
|
+
[SECURITY.md](./SECURITY.md#what-actually-leaves-your-machine) before
|
|
496
|
+
putting anything sensitive in those fields. The log directory is created
|
|
497
|
+
automatically if it doesn't exist, and a write failure (disk full,
|
|
498
|
+
read-only filesystem, permissions) is caught and reported to stderr —
|
|
499
|
+
it never breaks the tool call itself.
|
|
500
|
+
|
|
501
|
+
**Reviewing a log file** — including a tester's, if they send you
|
|
502
|
+
theirs (there's no automatic collection; this project doesn't phone
|
|
503
|
+
home): run `node summarize-log.js [path]`, defaulting to the same
|
|
504
|
+
location the server itself uses. It prints a verdict/confidence/reason
|
|
505
|
+
breakdown, ensemble trigger and agreement rates, reference-source
|
|
506
|
+
grounding rates on `custom_build` verdicts, and flags any
|
|
507
|
+
`component_need` called more than once — a signal worth checking
|
|
508
|
+
against the [session cap](#session-call-cap) if you see it.
|
|
509
|
+
|
|
510
|
+
## Known limitations (carried over from validation)
|
|
511
|
+
|
|
512
|
+
- **Evidence judgment varies run to run, independent of search results.**
|
|
513
|
+
Validation traced a real case where two runs found the exact same named
|
|
514
|
+
candidate components via the exact same search queries, but the model
|
|
515
|
+
judged the same evidence differently — e.g. reading one candidate's
|
|
516
|
+
"Export" action as present in one run and absent in another, for the
|
|
517
|
+
identical component. This isn't a search-consistency or code bug; it's
|
|
518
|
+
inherent to how the model reads natural-language evidence, and it's what
|
|
519
|
+
the boundary-risk ensemble exists to catch and disclose (as a 2/3
|
|
520
|
+
`agreement` split) rather than eliminate. If you see a verdict flip
|
|
521
|
+
between your own runs on the same input, this is almost certainly why.
|
|
522
|
+
- **No caching, by design.** Every call re-searches and re-scores from
|
|
523
|
+
scratch. A `custom_build` verdict can go stale as libraries ship new
|
|
524
|
+
components (validated: shadcn's June 2026 chat primitives turned a likely
|
|
525
|
+
custom-build messaging component into a near-perfect match). If you add
|
|
526
|
+
caching at the calling-agent layer, keep it session-scoped only — never
|
|
527
|
+
persist a verdict across sessions or builds. This still holds with
|
|
528
|
+
[per-project decision memory](#per-project-decision-memory) in the
|
|
529
|
+
picture: memory only ever adds context to the prompt for a fresh
|
|
530
|
+
judgment pass, it never substitutes for one — a `recommend_component`
|
|
531
|
+
call with a `project_id` still always re-searches and re-scores.
|
|
532
|
+
- **Skip-list is a starting point, not validated against real usage yet.**
|
|
533
|
+
Log every call and whether it hit the skip-list; watch for agents calling
|
|
534
|
+
the tool anyway on skip-listed items (list too narrow) or shipping generic
|
|
535
|
+
UI for something that should've been skipped (list missing an entry).
|
|
536
|
+
- **Not testable end-to-end in a fully sandboxed environment.** This server
|
|
537
|
+
needs outbound network access to `api.anthropic.com` plus whatever the
|
|
538
|
+
model's web_search tool reaches — it won't run somewhere that blocks
|
|
539
|
+
general internet access.
|
|
540
|
+
- **Requirement extraction and coverage scoring are judgment calls made by
|
|
541
|
+
the model**, not deterministic lookups, even with the ensemble and
|
|
542
|
+
server-side recount in place. Spot-check early outputs against real
|
|
543
|
+
components before trusting the pipeline unattended.
|