pattern-mcp 0.7.0 → 0.8.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/README.md +314 -155
- package/dist/index.js +631 -40
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -32,9 +32,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
32
32
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
33
33
|
import { execFileSync } from "node:child_process";
|
|
34
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
35
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
37
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
38
38
|
import { captureApiError, captureRecommendation, printTelemetryNoticeOnce, shutdownTelemetry, } from "./telemetry.js";
|
|
39
39
|
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
40
40
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
@@ -72,7 +72,12 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
|
|
|
72
72
|
// token counts even though their Mobbin fetch *failed*
|
|
73
73
|
// (url_not_accessible, 0 bytes returned) -- the cost driver there is the
|
|
74
74
|
// extra Mobbin/Figma-restricted search calls, not fetched content size,
|
|
75
|
-
// so this cap can't address it.
|
|
75
|
+
// so this cap can't address it. (The Mobbin fetch failures themselves
|
|
76
|
+
// were later confirmed, 2026-09-02, to be Mobbin blocking Anthropic's
|
|
77
|
+
// fetch bot specifically -- the same URL 403s to that bot and 200s to a
|
|
78
|
+
// generic user agent -- the same structural problem already known for
|
|
79
|
+
// Figma's robots.txt block, just a different enforcement mechanism. See
|
|
80
|
+
// the step-6 system prompt's Mobbin note below.) That's tracked as a separate, differently
|
|
76
81
|
// -scoped backlog item, not folded into this one.
|
|
77
82
|
const FETCH_MAX_CONTENT_TOKENS_RAW = process.env.PATTERN_FETCH_MAX_CONTENT_TOKENS ?? "12000";
|
|
78
83
|
const FETCH_MAX_CONTENT_TOKENS = (() => {
|
|
@@ -137,6 +142,16 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
|
|
|
137
142
|
// of what's in this file (see README's "no verdict caching" rule).
|
|
138
143
|
const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
|
|
139
144
|
const MAX_DECISIONS_PER_PROJECT = 50;
|
|
145
|
+
// Registered design systems (Solo Dev architecture,
|
|
146
|
+
// pattern-solo-design-system-architecture.md) -- one registration per
|
|
147
|
+
// project_id, config-shaped like MEMORY_PATH (overwritten wholesale by a
|
|
148
|
+
// fresh register_design_system call, not appended to). Same homedir
|
|
149
|
+
// convention as LOG_PATH/MEMORY_PATH/LEDGER_PATH: a local file keyed by
|
|
150
|
+
// the caller-supplied project_id string, no server-side component. When a
|
|
151
|
+
// project has a registration, recommend_component scores ONLY against it
|
|
152
|
+
// (one-or-the-other per project, not additive with shadcn/21st.dev/reui --
|
|
153
|
+
// see runSinglePass's designSystem branch).
|
|
154
|
+
const DESIGN_SYSTEMS_PATH = process.env.PATTERN_DESIGN_SYSTEMS_PATH ?? join(homedir(), ".pattern", "design_systems.json");
|
|
140
155
|
// Per-project judgment ledger -- distinct from both LOG_PATH and
|
|
141
156
|
// MEMORY_PATH above. Every recommend_component call that reaches the API
|
|
142
157
|
// with a project_id and lands on reason "scored" or "no_candidates_found"
|
|
@@ -494,6 +509,7 @@ const EXPORT_LEDGER_PROVENANCE_TOOL_NAME = "export_ledger_provenance";
|
|
|
494
509
|
const POST_LEDGER_PROVENANCE_TOOL_NAME = "post_ledger_provenance_to_github";
|
|
495
510
|
const SWEEP_LEDGER_LIVENESS_TOOL_NAME = "sweep_ledger_liveness";
|
|
496
511
|
const BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME = "backfill_ledger_snapshot_ref";
|
|
512
|
+
const REGISTER_DESIGN_SYSTEM_TOOL_NAME = "register_design_system";
|
|
497
513
|
const INPUT_SCHEMA = {
|
|
498
514
|
type: "object",
|
|
499
515
|
properties: {
|
|
@@ -791,6 +807,24 @@ const BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA = {
|
|
|
791
807
|
},
|
|
792
808
|
required: ["project_id"],
|
|
793
809
|
};
|
|
810
|
+
const REGISTER_DESIGN_SYSTEM_INPUT_SCHEMA = {
|
|
811
|
+
type: "object",
|
|
812
|
+
properties: {
|
|
813
|
+
project_id: {
|
|
814
|
+
type: "string",
|
|
815
|
+
description: "The project this registration belongs to -- must match the project_id used in recommend_component calls for it to be scored against. Registering a design system replaces (does not merge with) any prior registration for this same project_id, and switches recommend_component to score ONLY against it for this project -- shadcn/ui, 21st.dev, and ReUI are no longer searched once a project has a registration.",
|
|
816
|
+
},
|
|
817
|
+
manifest_path: {
|
|
818
|
+
type: "string",
|
|
819
|
+
description: "Path to a components manifest, relative to the project root (PATTERN_PROJECT_ROOT, defaults to this server's working directory) -- never an absolute path. Two recognized shapes: a hand-authored JSON array of {name, props, description, usage_example} objects (optionally wrapped in {\"components\": [...]}); or a Storybook-exported stories/index JSON file (an object with a top-level \"entries\" or \"stories\" map) -- component names only in that case, since Storybook's basic export doesn't carry prop data. Exactly one of manifest_path or directory_path is required.",
|
|
820
|
+
},
|
|
821
|
+
directory_path: {
|
|
822
|
+
type: "string",
|
|
823
|
+
description: "Path to a directory of component source files, relative to the project root -- never an absolute path. Scanned recursively for .jsx/.tsx/.js/.ts files (excluding node_modules/dist/build/.git and test/story files); each exported, uppercase-named function or const component found is a candidate, with props read from a `<Name>Props` interface/type, a `.propTypes` block, or (as a last resort) the component's own destructured parameters. This is a heuristic scan, not a full parser -- an empty or partial props list for some components is expected, not a bug, especially on plain JS with no prop typing at all. Exactly one of manifest_path or directory_path is required.",
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
required: ["project_id"],
|
|
827
|
+
};
|
|
794
828
|
// Shared between buildSystemPrompt's own step 2 and
|
|
795
829
|
// buildExtractionSystemPrompt (the extract_requirements tool's standalone
|
|
796
830
|
// prompt) -- the extraction *instructions* are one piece of text reused
|
|
@@ -800,6 +834,54 @@ const BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA = {
|
|
|
800
834
|
// tools at all). This is what "factor it out into a shared function" means
|
|
801
835
|
// here: the wording, not a shared HTTP call.
|
|
802
836
|
const EXTRACTION_INSTRUCTIONS = "Turn the component need + domain into a concrete checklist of elements the component must contain -- specific enough to check against real code, not a vibe. Ground it in the stated domain, not the component name alone. Extract exactly 8 checklist items, ranked by importance to the component's core function (most important first) -- a fixed count, not a range, so coverage = met/total isn't itself a moving target across runs.";
|
|
837
|
+
// Shared between buildSystemPrompt and buildDesignSystemSystemPrompt --
|
|
838
|
+
// step 6 (custom_build reference grounding via Mobbin/Figma Community) is
|
|
839
|
+
// entirely candidate-source-agnostic: it fires identically whether step 3
|
|
840
|
+
// discovered candidates via live search or scored against a registered
|
|
841
|
+
// design system, so this is one wording, not two copies that could drift
|
|
842
|
+
// out of sync (see EXTRACTION_INSTRUCTIONS's comment for the same
|
|
843
|
+
// "factor out the wording, not a function" reasoning).
|
|
844
|
+
const CUSTOM_BUILD_REFERENCE_INSTRUCTIONS = `Search TWO reference sources, one search call each (two calls total, reserved separately from the discovery budget above):
|
|
845
|
+
- Mobbin (site:mobbin.com) for the closest real-app screen matching the stated domain (e.g. real Airbnb screens for an Airbnb-style app).
|
|
846
|
+
- Figma Community (site:figma.com/community) for a relevant real component or template file matching the stated domain and component need. Plain web search only -- there is no Figma API token available, don't attempt to use one.
|
|
847
|
+
|
|
848
|
+
A search result URL is very often a category/browse page (e.g. mobbin.com/explore/mobile/screens/notifications), not a direct link to the specific screen or flow you actually identified (e.g. "Saturn Calendar - Notifications List"). Figma Community results are different: a URL containing "/community/file/" is already file-specific by Figma's own URL structure -- there is nothing more specific to find, so leave it as-is and do not spend a fetch on it. Only a Figma result that is NOT a "/community/file/" URL (a browse/tag/search page, e.g. figma.com/community/mobile-apps) has the same category-vs-specific gap Mobbin has.
|
|
849
|
+
|
|
850
|
+
For each Mobbin result, and for any Figma Community result that isn't already a "/community/file/" URL: fetch that result's URL with the web_fetch tool (reserved separately from both search budgets above, and separately from steps 2-5 -- see step 4) and look in the fetched page content for a more specific permalink pointing at that same specific screen or flow you already identified. Use that permalink as the reference "url" ONLY if you can actually see it written in the fetched content -- never construct, guess, or pattern-match your way to a deep-link URL that isn't literally present on the page, even if you're confident you know the site's URL scheme. Note that Figma's robots.txt blocks automated fetching of the entire site, so a Figma category-page fetch will very likely fail outright -- that's expected, not a bug. Mobbin fetches are also very likely to fail: Mobbin blocks Anthropic's fetch bot specifically (confirmed directly -- the same URL that 403s to that bot returns 200 to a generic browser user agent), so treat a failed Mobbin fetch the same way, expected, not a sign anything went wrong. Each source gets at most ONE fetch attempt: if it fails for any reason, do not retry it by guessing a different URL variant for the same page (e.g. adding or removing a path segment) -- that guessed variant isn't a URL you actually found, it's exactly the kind of construction this process forbids, and the tool will reject it anyway since it never appeared in a real search or fetch result. Accept the failure and move on. If a fetch fails, or the fetched page doesn't expose a more specific link (login-gated, or the specific screen genuinely isn't linkable separately from the browse view), keep the category/search URL as "url" and say so plainly in "reference_description" -- e.g. "This is a Mobbin search entry point for the notifications category, not a direct link to the Saturn Calendar screen described below" -- so the reader knows they're landing on a browse page and will need to find the specific screen themselves.
|
|
851
|
+
|
|
852
|
+
Include a reference for each source that actually returned a real, relevant result from a search you actually ran -- never name a plausible-sounding URL from memory for either source. If out of search budget, or a search found nothing relevant, that source is simply not included; there is no benefit to guessing, since anything not backed by an actual successful search for that source will be silently discarded server-side. The same no-fabrication rule applies to the fetch step: a claimed deep-link URL that isn't backed by an actual fetch of that page literally containing that link will be silently replaced server-side with the honest category-URL fallback, so there is no benefit to guessing there either.
|
|
853
|
+
|
|
854
|
+
Shape the "reference" field based on how many sources actually grounded:
|
|
855
|
+
- Both Mobbin and Figma Community grounded: an array of both reference objects.
|
|
856
|
+
- Only one grounded: a single reference object (not a one-element array).
|
|
857
|
+
- Neither grounded: omit "reference" entirely (null), same as a custom_build verdict with no usable reference at all today.
|
|
858
|
+
|
|
859
|
+
Each reference object has: "source" ("Mobbin" or "Figma Community"), "url", and either "flow_name" (Mobbin) or "file_name" (Figma Community) -- whichever matches its own source. Each also gets its own "reference_description": 1-2 sentences of plain-language description of what that specific screen or file actually shows -- specific enough that an agent that can't open the URL still has something to act on. E.g. "Airbnb's checkout screen shows the cancellation policy as an expandable section below the price breakdown, with the exact refund percentage next to each date threshold." Base each description only on what you actually saw in that source's own search result, not a generic guess, and not by borrowing detail from the other source.`;
|
|
860
|
+
// Shared for the same reason as CUSTOM_BUILD_REFERENCE_INSTRUCTIONS above
|
|
861
|
+
// -- step 8 doesn't depend on where candidates came from, only on whether
|
|
862
|
+
// past-decision context was included in the user message.
|
|
863
|
+
const PAST_DECISION_SIGNAL_INSTRUCTIONS = `Include a top-level "past_decision_signal" field in your response: { "considered": true|false, "note": "string" }. Set "considered": true only if at least one listed past decision was genuinely similar enough to this need that it actually factored into your scoring or recommendation -- not just present in the list. "note" is one sentence: if considered is true, name which past decision and how it factored in (e.g. "Consistent with this project's prior custom build of a similar price breakdown component"); if false, one sentence on why none applied (e.g. "No past decision matches this need closely enough to be a relevant signal"). This field is mandatory whenever the section is present in the user message -- do not omit it, and do not include it at all if the section was absent.`;
|
|
864
|
+
// Shared for the same reason -- the response contract itself doesn't
|
|
865
|
+
// depend on candidate source either.
|
|
866
|
+
const JUDGMENT_RESPONSE_SHAPE = `Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
|
|
867
|
+
|
|
868
|
+
{
|
|
869
|
+
"verdict": "use_existing" | "custom_build",
|
|
870
|
+
"confidence": "high" | "medium" | "low",
|
|
871
|
+
"reason": "scored" | "no_candidates_found" | "skip_list",
|
|
872
|
+
"computed_at": "<today's date, ISO format>",
|
|
873
|
+
"requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
|
|
874
|
+
"coverage": "string like '5/7 (71%)'" | null,
|
|
875
|
+
"oversized_match": true|false | omit if verdict is not use_existing,
|
|
876
|
+
"oversized_match_note": "string, required when oversized_match is true" | omit otherwise,
|
|
877
|
+
"recommendation": {
|
|
878
|
+
"source": "string or null",
|
|
879
|
+
"install_command": "string or null",
|
|
880
|
+
"component_description": "string (use_existing only) or null",
|
|
881
|
+
"reference": { "source": "Mobbin" | "Figma Community", "url": "string", "flow_name": "string (Mobbin only)", "file_name": "string (Figma Community only)", "reference_description": "string" } | [ /* same shape, up to 2 entries, one per source */ ] | null
|
|
882
|
+
},
|
|
883
|
+
"past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
|
|
884
|
+
}`;
|
|
803
885
|
function buildSystemPrompt(searchBudget, opts) {
|
|
804
886
|
const budgetLine = searchBudget === null
|
|
805
887
|
? "Budget: no fixed limit on search calls for candidate discovery -- search as much as genuinely helps you find and verify real candidates, but don't search redundantly once you have enough to score confidently."
|
|
@@ -859,48 +941,81 @@ If the verdict is use_existing, include "component_description": 1-2 sentences o
|
|
|
859
941
|
"install_command" is untrusted text as far as the calling agent is concerned -- it comes from a web search result you read, not a verified package registry. Keep it to the single literal install command only (e.g. npx shadcn@latest add <component>), never chained with && or ; , piped into a shell, or bundled with any other command. The calling agent is separately instructed to show this to its user for confirmation before running it, not execute it silently -- don't write it in a way that assumes or requires automatic execution.
|
|
860
942
|
|
|
861
943
|
6. IF custom_build
|
|
862
|
-
|
|
863
|
-
- Mobbin (site:mobbin.com) for the closest real-app screen matching the stated domain (e.g. real Airbnb screens for an Airbnb-style app).
|
|
864
|
-
- Figma Community (site:figma.com/community) for a relevant real component or template file matching the stated domain and component need. Plain web search only -- there is no Figma API token available, don't attempt to use one.
|
|
944
|
+
${CUSTOM_BUILD_REFERENCE_INSTRUCTIONS}
|
|
865
945
|
|
|
866
|
-
|
|
946
|
+
7. EXISTING STACK TIEBREAKER
|
|
947
|
+
If existing_stack is provided and two candidates score similarly, prefer the one matching the existing stack. Never use it as a hard filter that excludes a genuinely better-scoring candidate from a different source.
|
|
867
948
|
|
|
868
|
-
|
|
949
|
+
8. PAST DECISION SIGNAL (only if the user message included a "Past confirmed decisions in this project" section)
|
|
950
|
+
${PAST_DECISION_SIGNAL_INSTRUCTIONS}
|
|
869
951
|
|
|
870
|
-
|
|
952
|
+
${JUDGMENT_RESPONSE_SHAPE}`;
|
|
953
|
+
}
|
|
954
|
+
// Design-system-scored variant of buildSystemPrompt -- used by
|
|
955
|
+
// runSinglePass instead of buildSystemPrompt whenever the caller's
|
|
956
|
+
// project_id has a registration from register_design_system (see
|
|
957
|
+
// getRegisteredDesignSystem). Steps 1, 2, 5 (skip-list, checklist,
|
|
958
|
+
// thresholds/oversized-match), 6, 7, 8, and the response shape are
|
|
959
|
+
// unchanged in substance from buildSystemPrompt -- only step 3 (discovery)
|
|
960
|
+
// and step 4 (scoring evidence) differ, because there's no live search to
|
|
961
|
+
// run: the candidate pool is already fully known from the registration,
|
|
962
|
+
// passed inline in the user message (see runSinglePass's designSystemBlock).
|
|
963
|
+
function buildDesignSystemSystemPrompt(opts) {
|
|
964
|
+
const step2 = opts?.checklistProvided
|
|
965
|
+
? `2. USE THE PROVIDED CHECKLIST
|
|
966
|
+
The user message includes a "Provided checklist" section -- a requirement checklist already prepared for you (either hand-written by the calling agent, or produced by a prior extract_requirements call). Do not extract your own checklist, and do not add, remove, reorder, or reword any item. Treat it as fixed input and score coverage against exactly these items in step 4 below.`
|
|
967
|
+
: `2. EXTRACT REQUIREMENTS
|
|
968
|
+
${EXTRACTION_INSTRUCTIONS}`;
|
|
969
|
+
return `You are a UI component judgment layer. Given a component need, you decide whether it should be met with a component already in this project's own registered design system, or requires a custom build guided by a real-app reference. This project has registered its own design system as the candidate pool for this call (see the "Registered design system candidates" section in the user message below) -- score ONLY against those candidates, never against shadcn/ui, 21st.dev, ReUI, or any other external library. You have access to a web_search tool, but it is reserved entirely for step 6 below (custom_build reference grounding) -- do not use it for candidate discovery, there is nothing to discover, the candidate pool is already given to you in full.
|
|
871
970
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
971
|
+
If the user message includes a "Past confirmed decisions in this project" section, treat it only as a signal, not a rule: if a highly similar past decision exists, consider consistency with it while scoring and recommending, but don't let it override a genuinely better match among the registered candidates, and don't skip or shortcut your own scoring because a past decision exists. You decide relevance yourself. Step 8 below tells you exactly how to report what you did with it.
|
|
972
|
+
|
|
973
|
+
Follow this process exactly:
|
|
974
|
+
|
|
975
|
+
1. SKIP-LIST CHECK
|
|
976
|
+
If the component need is a trivial, single-purpose primitive with no meaningful internal structure (button, input, checkbox, label, badge, spinner, loader, tooltip, avatar, icon), skip the rest of this process and return verdict "use_existing" with reason "skip_list", confidence "high", and a note that this is a commodity primitive not worth scoring.
|
|
977
|
+
|
|
978
|
+
${step2}
|
|
979
|
+
|
|
980
|
+
3. MATCH AGAINST THE REGISTERED DESIGN SYSTEM
|
|
981
|
+
The "Registered design system candidates" section below lists every candidate available for this call: each has a name and, where known, its props and a description/usage example. This data was already extracted from this project's own manifest or component code -- do not search the web for candidates, do not invent props or capabilities beyond what's listed, and do not assume a candidate has a prop just because a similarly-named external component typically would. A candidate with an empty or sparse props list is expected on some registered design systems (a directory scan or a bare-bones manifest can only capture what was actually written) -- score it honestly against what's listed, which will often mean lower coverage or lower confidence, not a bug in this process.
|
|
982
|
+
|
|
983
|
+
If none of the registered candidates are even plausibly relevant to the component need -- not just a weak match, but nothing on-topic at all -- stop here and return verdict "custom_build" with reason "no_candidates_found". Do not fabricate a coverage score in this case; omit requirements_checked and coverage entirely.
|
|
984
|
+
|
|
985
|
+
4. SCORE COVERAGE AGAINST THE CHECKLIST
|
|
986
|
+
For each plausibly relevant registered candidate, evaluate against the checklist using only the props/description/usage_example data given for it in the user message. There is nothing to fetch here -- unlike an external library, this data already IS this project's own real source of truth, not a summary of it. Mark each requirement met or not-met with a one-line reason grounded in what the candidate's listed data actually shows, never a guess at what a component with this name would probably support elsewhere. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
|
|
987
|
+
|
|
988
|
+
5. APPLY VERDICT THRESHOLDS
|
|
989
|
+
coverage >= 80% -> verdict "use_existing", confidence "high"
|
|
990
|
+
coverage 40-79% -> verdict "use_existing", confidence "low" (list the missing fields)
|
|
991
|
+
coverage < 40% -> verdict "custom_build"
|
|
992
|
+
|
|
993
|
+
Before finalizing a "high" confidence use_existing verdict, check for an OVERSIZED MATCH: a
|
|
994
|
+
candidate can satisfy every checklist item and still be the wrong call if its real capabilities
|
|
995
|
+
substantially exceed what the stated project scope actually needs. This is a distinct check from
|
|
996
|
+
coverage -- a component can be 100% covered and still be an Oversized Match. Weigh it against what
|
|
997
|
+
the component_need and domain actually state about scale.
|
|
998
|
+
|
|
999
|
+
Report this via two top-level fields, "oversized_match" (boolean) and "oversized_match_note" (string,
|
|
1000
|
+
required when true): set oversized_match true and name the specific excess capability in the note, not
|
|
1001
|
+
a vague "this may be more than needed." Do this regardless of what you also write for "confidence"
|
|
1002
|
+
below -- the server derives the actual confidence cap from oversized_match deterministically, so don't
|
|
1003
|
+
rely on your own "confidence" value alone to carry this signal.
|
|
1004
|
+
|
|
1005
|
+
If the verdict is use_existing, include "component_description": 1-2 sentences of plain-language description of what the recommended candidate actually does, grounded only in the data given for it above -- not a generic guess at what a component with this name would typically look like.
|
|
1006
|
+
|
|
1007
|
+
"install_command" should be omitted (null) for a registered-design-system candidate -- there is no install step for a component that's already part of this project's own codebase or design spec; the calling agent already has it.
|
|
876
1008
|
|
|
877
|
-
|
|
1009
|
+
6. IF custom_build
|
|
1010
|
+
${CUSTOM_BUILD_REFERENCE_INSTRUCTIONS}
|
|
878
1011
|
|
|
879
1012
|
7. EXISTING STACK TIEBREAKER
|
|
880
|
-
If existing_stack is provided and two candidates score similarly, prefer the one matching the existing stack. Never use it as a hard filter that excludes a genuinely better-scoring candidate
|
|
1013
|
+
If existing_stack is provided and two registered candidates score similarly, prefer the one matching the existing stack. Never use it as a hard filter that excludes a genuinely better-scoring registered candidate.
|
|
881
1014
|
|
|
882
1015
|
8. PAST DECISION SIGNAL (only if the user message included a "Past confirmed decisions in this project" section)
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
|
|
1016
|
+
${PAST_DECISION_SIGNAL_INSTRUCTIONS}
|
|
886
1017
|
|
|
887
|
-
{
|
|
888
|
-
"verdict": "use_existing" | "custom_build",
|
|
889
|
-
"confidence": "high" | "medium" | "low",
|
|
890
|
-
"reason": "scored" | "no_candidates_found" | "skip_list",
|
|
891
|
-
"computed_at": "<today's date, ISO format>",
|
|
892
|
-
"requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
|
|
893
|
-
"coverage": "string like '5/7 (71%)'" | null,
|
|
894
|
-
"oversized_match": true|false | omit if verdict is not use_existing,
|
|
895
|
-
"oversized_match_note": "string, required when oversized_match is true" | omit otherwise,
|
|
896
|
-
"recommendation": {
|
|
897
|
-
"source": "string or null",
|
|
898
|
-
"install_command": "string or null",
|
|
899
|
-
"component_description": "string (use_existing only) or null",
|
|
900
|
-
"reference": { "source": "Mobbin" | "Figma Community", "url": "string", "flow_name": "string (Mobbin only)", "file_name": "string (Figma Community only)", "reference_description": "string" } | [ /* same shape, up to 2 entries, one per source */ ] | null
|
|
901
|
-
},
|
|
902
|
-
"past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
|
|
903
|
-
}`;
|
|
1018
|
+
${JUDGMENT_RESPONSE_SHAPE}`;
|
|
904
1019
|
}
|
|
905
1020
|
// Standalone prompt for the extract_requirements tool -- shares
|
|
906
1021
|
// EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
|
|
@@ -997,10 +1112,28 @@ async function runSinglePass(input) {
|
|
|
997
1112
|
.map((item, i) => `${i + 1}. ${item}`)
|
|
998
1113
|
.join("\n")}`
|
|
999
1114
|
: "";
|
|
1115
|
+
// Solo Dev design-system architecture: a project_id with a registration
|
|
1116
|
+
// (see registerDesignSystem) switches this whole call onto
|
|
1117
|
+
// buildDesignSystemSystemPrompt below -- no live search, score directly
|
|
1118
|
+
// against the candidates listed here instead. No registration -> this
|
|
1119
|
+
// stays null and every line below behaves exactly as it did before this
|
|
1120
|
+
// feature existed (see registerDesignSystem's own comment: one-or-the-
|
|
1121
|
+
// other per project, not additive with the external-library path).
|
|
1122
|
+
const designSystem = input.project_id ? getRegisteredDesignSystem(input.project_id) : null;
|
|
1123
|
+
const designSystemBlock = designSystem
|
|
1124
|
+
? `\n\nRegistered design system candidates (source: ${designSystem.source_kind}, ${designSystem.source_path}):\n${designSystem.candidates
|
|
1125
|
+
.map((c, i) => {
|
|
1126
|
+
const propsPart = c.props.length > 0 ? `props: ${c.props.join(", ")}` : "props: (none captured)";
|
|
1127
|
+
const descPart = c.description ? `; description: ${c.description}` : "";
|
|
1128
|
+
const usagePart = c.usage_example ? `; usage_example: ${c.usage_example}` : "";
|
|
1129
|
+
return `${i + 1}. ${c.name} -- ${propsPart}${descPart}${usagePart}`;
|
|
1130
|
+
})
|
|
1131
|
+
.join("\n")}`
|
|
1132
|
+
: "";
|
|
1000
1133
|
const userMessage = `component_need: ${input.component_need}
|
|
1001
1134
|
domain: ${input.domain}
|
|
1002
1135
|
framework: ${input.framework}
|
|
1003
|
-
existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
|
|
1136
|
+
existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}${designSystemBlock}`;
|
|
1004
1137
|
// Diagnostic only, same pattern as the other stderr diagnostics in this
|
|
1005
1138
|
// file -- proves the memory lookup actually reached the prompt sent to
|
|
1006
1139
|
// the model, not just that it was read from disk successfully.
|
|
@@ -1027,7 +1160,9 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
1027
1160
|
system: [
|
|
1028
1161
|
{
|
|
1029
1162
|
type: "text",
|
|
1030
|
-
text:
|
|
1163
|
+
text: designSystem
|
|
1164
|
+
? buildDesignSystemSystemPrompt({ checklistProvided: checklistSource === "provided" })
|
|
1165
|
+
: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
|
|
1031
1166
|
cache_control: { type: "ephemeral" },
|
|
1032
1167
|
},
|
|
1033
1168
|
],
|
|
@@ -1049,7 +1184,13 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
1049
1184
|
// where 0 Mobbin queries were attempted but a specific Mobbin
|
|
1050
1185
|
// URL was still returned. Figma Community gets the same
|
|
1051
1186
|
// treatment now that it's a second reference source.
|
|
1052
|
-
|
|
1187
|
+
//
|
|
1188
|
+
// Design-system-scored calls skip step 3's discovery search
|
|
1189
|
+
// entirely (the candidate pool is already given, not searched
|
|
1190
|
+
// for), so they only ever need the 2 reserved step-6 slots --
|
|
1191
|
+
// fixed at 2 regardless of SEARCH_BUDGET, which governs external-
|
|
1192
|
+
// library discovery only and has no meaning in this mode.
|
|
1193
|
+
...(designSystem ? { max_uses: 2 } : SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
|
|
1053
1194
|
},
|
|
1054
1195
|
{
|
|
1055
1196
|
type: "web_fetch_20250910",
|
|
@@ -1067,7 +1208,12 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
1067
1208
|
// never more than once per source). Not reserved from the
|
|
1068
1209
|
// web_search budget above; this is a separate tool with its own
|
|
1069
1210
|
// separate cap.
|
|
1070
|
-
|
|
1211
|
+
//
|
|
1212
|
+
// Design-system-scored calls have no step-4 verification fetch
|
|
1213
|
+
// (there's no URL to verify -- the registered data already IS the
|
|
1214
|
+
// source of truth, see buildDesignSystemSystemPrompt's step 4),
|
|
1215
|
+
// so only the 2 step-6 slots are reserved.
|
|
1216
|
+
max_uses: designSystem ? 2 : 3,
|
|
1071
1217
|
// Category/browse pages can be large, and all we need from them
|
|
1072
1218
|
// is a permalink, not the full page -- caps token cost of a
|
|
1073
1219
|
// fetch that turns out not to have a deep link after all. See
|
|
@@ -1193,6 +1339,38 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
1193
1339
|
enforceCoverageRecount(parsed);
|
|
1194
1340
|
enforceVerdictThreshold(parsed);
|
|
1195
1341
|
enforceRecommendationConsistency(parsed);
|
|
1342
|
+
// Set server-side, never trusted from the model's own "source" text --
|
|
1343
|
+
// same "server derives what it already knows deterministically" policy
|
|
1344
|
+
// as the other enforce* calls above. A design-system-scored use_existing
|
|
1345
|
+
// verdict always gets the literal string "design_system" here,
|
|
1346
|
+
// regardless of what the model wrote, so downstream consumers (the
|
|
1347
|
+
// ledger, provenance markdown, read_ledger rollups) can match on it
|
|
1348
|
+
// reliably instead of parsing free-text.
|
|
1349
|
+
if (designSystem && parsed.verdict === "use_existing" && parsed.recommendation) {
|
|
1350
|
+
parsed.recommendation.source = "design_system";
|
|
1351
|
+
}
|
|
1352
|
+
// Design-system recall check (see findKeywordOverlapCandidates above):
|
|
1353
|
+
// only meaningful when the model claimed nothing registered was even
|
|
1354
|
+
// plausibly relevant -- a "scored" custom_build already means a real
|
|
1355
|
+
// candidate was found, evaluated, and fell below threshold, a
|
|
1356
|
+
// different, already-instrumented failure mode (requirements_checked
|
|
1357
|
+
// shows exactly what was missing). Logged even on a clean miss (no
|
|
1358
|
+
// overlap found) so the check's own execution is visible in stderr,
|
|
1359
|
+
// not just its hits.
|
|
1360
|
+
if (designSystem && parsed.reason === "no_candidates_found") {
|
|
1361
|
+
const overlap = findKeywordOverlapCandidates(input.component_need, input.domain, designSystem.candidates);
|
|
1362
|
+
console.error(JSON.stringify({
|
|
1363
|
+
diagnostic: "design_system_recall_check",
|
|
1364
|
+
project_id: input.project_id,
|
|
1365
|
+
possible_missed_candidates: overlap.map((m) => m.name),
|
|
1366
|
+
}));
|
|
1367
|
+
if (overlap.length > 0) {
|
|
1368
|
+
parsed.design_system_recall_check = {
|
|
1369
|
+
possible_missed_candidates: overlap,
|
|
1370
|
+
note: "These registered design-system candidates share keywords with this component_need but were not selected as a match -- the verdict may have missed a real one. This is a weak, keyword-only signal, not proof of an actual match: double-check these candidates yourself (or re-run this call) before trusting custom_build here.",
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1196
1374
|
// Set server-side rather than trusted from the model -- deterministic
|
|
1197
1375
|
// from whether input.checklist was actually supplied, same "never trust
|
|
1198
1376
|
// the model where the server already knows the truth" policy as the
|
|
@@ -1424,6 +1602,363 @@ export function getPastDecisions(projectId) {
|
|
|
1424
1602
|
const memory = readMemory();
|
|
1425
1603
|
return memory[projectId] ?? [];
|
|
1426
1604
|
}
|
|
1605
|
+
// Same "malformed/missing collapses to empty, never throws" policy as
|
|
1606
|
+
// readMemory -- a fresh install or a hand-edited file that doesn't parse
|
|
1607
|
+
// shouldn't break recommend_component for every project, it should just
|
|
1608
|
+
// behave as if nothing is registered.
|
|
1609
|
+
function readDesignSystems() {
|
|
1610
|
+
try {
|
|
1611
|
+
const raw = readFileSync(DESIGN_SYSTEMS_PATH, "utf8");
|
|
1612
|
+
const parsed = JSON.parse(raw);
|
|
1613
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1614
|
+
return parsed;
|
|
1615
|
+
}
|
|
1616
|
+
return {};
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
return {};
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
function writeDesignSystems(file) {
|
|
1623
|
+
mkdirSync(dirname(DESIGN_SYSTEMS_PATH), { recursive: true });
|
|
1624
|
+
writeFileSync(DESIGN_SYSTEMS_PATH, JSON.stringify(file, null, 2), "utf8");
|
|
1625
|
+
}
|
|
1626
|
+
// Read-only lookup used by runSinglePass. No project_id -> no lookup,
|
|
1627
|
+
// same "never fall back to a shared/global bucket" rule as
|
|
1628
|
+
// getPastDecisions above.
|
|
1629
|
+
export function getRegisteredDesignSystem(projectId) {
|
|
1630
|
+
return readDesignSystems()[projectId] ?? null;
|
|
1631
|
+
}
|
|
1632
|
+
// Extracts the substring between the first "{" at or after fromIndex and
|
|
1633
|
+
// its matching "}", tracking brace depth so a nested object type inside a
|
|
1634
|
+
// props interface doesn't truncate the capture early -- a plain non-greedy
|
|
1635
|
+
// regex on "{...}" breaks on exactly that shape (e.g. `style?: { color:
|
|
1636
|
+
// string }`).
|
|
1637
|
+
function extractBalancedBraceBody(text, fromIndex) {
|
|
1638
|
+
const openIdx = text.indexOf("{", fromIndex);
|
|
1639
|
+
if (openIdx === -1)
|
|
1640
|
+
return null;
|
|
1641
|
+
let depth = 0;
|
|
1642
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
1643
|
+
if (text[i] === "{")
|
|
1644
|
+
depth++;
|
|
1645
|
+
else if (text[i] === "}") {
|
|
1646
|
+
depth--;
|
|
1647
|
+
if (depth === 0)
|
|
1648
|
+
return text.slice(openIdx + 1, i);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return null;
|
|
1652
|
+
}
|
|
1653
|
+
// Heuristic, not a parser -- matches "name:" / "name?:" field declarations
|
|
1654
|
+
// at the start of a line or after a separator. Good enough for the flat,
|
|
1655
|
+
// single-level prop interfaces real components typically declare; a
|
|
1656
|
+
// deliberately best-effort choice over pulling in a full TypeScript AST
|
|
1657
|
+
// parser for this (see BACKLOG.md's manifest-quality risk -- sparse or
|
|
1658
|
+
// imperfect extraction here is expected, not a bug).
|
|
1659
|
+
function extractPropNamesFromBody(body) {
|
|
1660
|
+
const names = new Set();
|
|
1661
|
+
const re = /(?:^|[;,{(\n])\s*([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:/g;
|
|
1662
|
+
let m;
|
|
1663
|
+
while ((m = re.exec(body)) !== null) {
|
|
1664
|
+
names.add(m[1]);
|
|
1665
|
+
}
|
|
1666
|
+
return [...names];
|
|
1667
|
+
}
|
|
1668
|
+
// Best-effort extraction of a destructured function-parameter's field
|
|
1669
|
+
// names, e.g. `({ title, onClose, variant = "default" })` -> ["title",
|
|
1670
|
+
// "onClose", "variant"]. Only used as a last-resort fallback when neither
|
|
1671
|
+
// a `<Name>Props` interface/type nor a `.propTypes` block was found.
|
|
1672
|
+
function extractDestructuredParamNames(defWindow) {
|
|
1673
|
+
const match = defWindow.match(/\(\s*\{([^}]*)\}/);
|
|
1674
|
+
if (!match)
|
|
1675
|
+
return [];
|
|
1676
|
+
return match[1]
|
|
1677
|
+
.split(",")
|
|
1678
|
+
.map((p) => p.trim().split(/[:=]/)[0].trim())
|
|
1679
|
+
.filter((p) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(p));
|
|
1680
|
+
}
|
|
1681
|
+
const DESIGN_SYSTEM_SCAN_EXCLUDED_DIRS = new Set([
|
|
1682
|
+
"node_modules",
|
|
1683
|
+
"dist",
|
|
1684
|
+
"build",
|
|
1685
|
+
".git",
|
|
1686
|
+
".next",
|
|
1687
|
+
".turbo",
|
|
1688
|
+
"coverage",
|
|
1689
|
+
]);
|
|
1690
|
+
const DESIGN_SYSTEM_SCAN_EXTENSIONS = new Set([".jsx", ".tsx", ".js", ".ts"]);
|
|
1691
|
+
// .d.ts (type declarations, not components) and test/story files (not the
|
|
1692
|
+
// component's own definition, and .stories.* would otherwise double-count
|
|
1693
|
+
// alongside the real component file) are excluded by filename fragment.
|
|
1694
|
+
const DESIGN_SYSTEM_SCAN_EXCLUDED_NAME_FRAGMENTS = [".test.", ".spec.", ".stories.", ".d.ts"];
|
|
1695
|
+
function walkComponentFiles(root) {
|
|
1696
|
+
const files = [];
|
|
1697
|
+
const stack = [root];
|
|
1698
|
+
while (stack.length > 0) {
|
|
1699
|
+
const dir = stack.pop();
|
|
1700
|
+
let entries;
|
|
1701
|
+
try {
|
|
1702
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
for (const entry of entries) {
|
|
1708
|
+
if (entry.isDirectory()) {
|
|
1709
|
+
if (!DESIGN_SYSTEM_SCAN_EXCLUDED_DIRS.has(entry.name))
|
|
1710
|
+
stack.push(join(dir, entry.name));
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
if (!DESIGN_SYSTEM_SCAN_EXTENSIONS.has(extname(entry.name)))
|
|
1714
|
+
continue;
|
|
1715
|
+
if (DESIGN_SYSTEM_SCAN_EXCLUDED_NAME_FRAGMENTS.some((frag) => entry.name.includes(frag)))
|
|
1716
|
+
continue;
|
|
1717
|
+
files.push(join(dir, entry.name));
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return files;
|
|
1721
|
+
}
|
|
1722
|
+
// Component detection: an uppercase-leading exported function or const,
|
|
1723
|
+
// React's own naming convention for components -- deliberately excludes
|
|
1724
|
+
// lowercase exported helpers/hooks, which aren't components. Props are
|
|
1725
|
+
// resolved in priority order: a `<Name>Props` interface/type (most
|
|
1726
|
+
// reliable, TypeScript projects), then a `.propTypes` block (plain JS
|
|
1727
|
+
// with PropTypes), then a best-effort destructure of the function's own
|
|
1728
|
+
// parameter list. An empty result from all three is a real, expected
|
|
1729
|
+
// outcome for an untyped, undestructured component -- not an error.
|
|
1730
|
+
function scanComponentFile(absPath, relPath) {
|
|
1731
|
+
let content;
|
|
1732
|
+
try {
|
|
1733
|
+
content = readFileSync(absPath, "utf8");
|
|
1734
|
+
}
|
|
1735
|
+
catch {
|
|
1736
|
+
return [];
|
|
1737
|
+
}
|
|
1738
|
+
const names = new Map();
|
|
1739
|
+
const fnRe = /export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9_]*)\s*\(/g;
|
|
1740
|
+
const constRe = /export\s+(?:default\s+)?const\s+([A-Z][A-Za-z0-9_]*)\s*(?::[^=\n]+)?=/g;
|
|
1741
|
+
let m;
|
|
1742
|
+
while ((m = fnRe.exec(content)) !== null)
|
|
1743
|
+
names.set(m[1], m.index);
|
|
1744
|
+
while ((m = constRe.exec(content)) !== null)
|
|
1745
|
+
if (!names.has(m[1]))
|
|
1746
|
+
names.set(m[1], m.index);
|
|
1747
|
+
const candidates = [];
|
|
1748
|
+
for (const [name, idx] of names) {
|
|
1749
|
+
let props = [];
|
|
1750
|
+
const interfaceMatch = content.match(new RegExp(`(?:interface|type)\\s+${name}Props\\b`));
|
|
1751
|
+
if (interfaceMatch?.index !== undefined) {
|
|
1752
|
+
const body = extractBalancedBraceBody(content, interfaceMatch.index);
|
|
1753
|
+
if (body)
|
|
1754
|
+
props = extractPropNamesFromBody(body);
|
|
1755
|
+
}
|
|
1756
|
+
if (props.length === 0) {
|
|
1757
|
+
const propTypesMatch = content.match(new RegExp(`${name}\\.propTypes\\s*=`));
|
|
1758
|
+
if (propTypesMatch?.index !== undefined) {
|
|
1759
|
+
const body = extractBalancedBraceBody(content, propTypesMatch.index);
|
|
1760
|
+
if (body)
|
|
1761
|
+
props = extractPropNamesFromBody(body);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (props.length === 0) {
|
|
1765
|
+
props = extractDestructuredParamNames(content.slice(idx, Math.min(content.length, idx + 500)));
|
|
1766
|
+
}
|
|
1767
|
+
candidates.push({ name, props, description: null, usage_example: null, file_path: relPath });
|
|
1768
|
+
}
|
|
1769
|
+
return candidates;
|
|
1770
|
+
}
|
|
1771
|
+
function scanDirectoryForDesignSystem(absRoot) {
|
|
1772
|
+
const candidates = [];
|
|
1773
|
+
for (const abs of walkComponentFiles(absRoot)) {
|
|
1774
|
+
candidates.push(...scanComponentFile(abs, relative(absRoot, abs)));
|
|
1775
|
+
}
|
|
1776
|
+
return candidates;
|
|
1777
|
+
}
|
|
1778
|
+
// Parses a manifest file's raw text into a flat candidate list. Two
|
|
1779
|
+
// recognized shapes, matching the architecture doc's launch scope:
|
|
1780
|
+
// - Hand-authored: a top-level array of {name, props?, description?,
|
|
1781
|
+
// usage_example?} objects, optionally wrapped in {"components": [...]}.
|
|
1782
|
+
// - Storybook-exported index (stories.json v3, or index.json v4+): both
|
|
1783
|
+
// key stories/entries by id, each carrying a "title" like
|
|
1784
|
+
// "Components/Button" that groups stories under a component name.
|
|
1785
|
+
// Props aren't part of this export (only Storybook's heavier docgen
|
|
1786
|
+
// addon captures those), so candidates from this path start with an
|
|
1787
|
+
// empty props list -- expected, not a bug, per the same manifest-
|
|
1788
|
+
// quality risk noted on the directory-scan path above.
|
|
1789
|
+
// Throws with a clear, specific message on anything else, per the
|
|
1790
|
+
// architecture doc's own risk mitigation: "fail loud on malformed input
|
|
1791
|
+
// rather than scoring against garbage."
|
|
1792
|
+
function parseManifestCandidates(raw, sourcePath) {
|
|
1793
|
+
let parsed;
|
|
1794
|
+
try {
|
|
1795
|
+
parsed = JSON.parse(raw);
|
|
1796
|
+
}
|
|
1797
|
+
catch {
|
|
1798
|
+
throw new Error(`Manifest at "${sourcePath}" is not valid JSON.`);
|
|
1799
|
+
}
|
|
1800
|
+
const handAuthoredArray = Array.isArray(parsed)
|
|
1801
|
+
? parsed
|
|
1802
|
+
: parsed && typeof parsed === "object" && Array.isArray(parsed.components)
|
|
1803
|
+
? parsed.components
|
|
1804
|
+
: null;
|
|
1805
|
+
if (handAuthoredArray) {
|
|
1806
|
+
return handAuthoredArray.map((item, i) => {
|
|
1807
|
+
if (!item || typeof item !== "object" || typeof item.name !== "string" || !item.name) {
|
|
1808
|
+
throw new Error(`Manifest entry at index ${i} in "${sourcePath}" is missing a required "name" string.`);
|
|
1809
|
+
}
|
|
1810
|
+
const record = item;
|
|
1811
|
+
return {
|
|
1812
|
+
name: record.name,
|
|
1813
|
+
props: Array.isArray(record.props) ? record.props.filter((p) => typeof p === "string") : [],
|
|
1814
|
+
description: typeof record.description === "string" ? record.description : null,
|
|
1815
|
+
usage_example: typeof record.usage_example === "string" ? record.usage_example : null,
|
|
1816
|
+
file_path: null,
|
|
1817
|
+
};
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
const storybookEntries = parsed && typeof parsed === "object"
|
|
1821
|
+
? (parsed.entries ?? parsed.stories ?? null)
|
|
1822
|
+
: null;
|
|
1823
|
+
if (storybookEntries && typeof storybookEntries === "object") {
|
|
1824
|
+
const names = new Set();
|
|
1825
|
+
for (const entry of Object.values(storybookEntries)) {
|
|
1826
|
+
const title = entry && typeof entry === "object" ? entry.title : null;
|
|
1827
|
+
if (typeof title !== "string")
|
|
1828
|
+
continue;
|
|
1829
|
+
const name = title.split("/").pop()?.trim();
|
|
1830
|
+
if (name)
|
|
1831
|
+
names.add(name);
|
|
1832
|
+
}
|
|
1833
|
+
if (names.size === 0) {
|
|
1834
|
+
throw new Error(`Manifest at "${sourcePath}" looked like a Storybook export (found "entries"/"stories") but no component titles could be extracted from it.`);
|
|
1835
|
+
}
|
|
1836
|
+
return [...names].map((name) => ({ name, props: [], description: null, usage_example: null, file_path: null }));
|
|
1837
|
+
}
|
|
1838
|
+
throw new Error(`Manifest at "${sourcePath}" doesn't match a recognized shape. Expected either a hand-authored array of ` +
|
|
1839
|
+
`{name, props, description, usage_example} objects (optionally wrapped in {"components": [...]}), or a ` +
|
|
1840
|
+
`Storybook-exported stories/index JSON file (an object with a top-level "entries" or "stories" map).`);
|
|
1841
|
+
}
|
|
1842
|
+
// Core of the register_design_system tool. Exactly one of manifest_path
|
|
1843
|
+
// or directory_path, both resolved via resolveWithinRoot -- same
|
|
1844
|
+
// PROJECT_ROOT-scoped, relative-path-only boundary check_ledger_liveness
|
|
1845
|
+
// already established for file_path, reused rather than inventing a
|
|
1846
|
+
// second filesystem-access convention. Overwrites any prior registration
|
|
1847
|
+
// for this project_id wholesale (one-or-the-other per project, not
|
|
1848
|
+
// additive/merged across repeat calls).
|
|
1849
|
+
export function registerDesignSystem(input) {
|
|
1850
|
+
const provided = [input.manifest_path, input.directory_path].filter((v) => v !== undefined && v !== "");
|
|
1851
|
+
if (provided.length !== 1) {
|
|
1852
|
+
throw new Error("register_design_system requires exactly one of manifest_path or directory_path (both relative to the project root).");
|
|
1853
|
+
}
|
|
1854
|
+
let sourceKind;
|
|
1855
|
+
let sourcePath;
|
|
1856
|
+
let candidates;
|
|
1857
|
+
if (input.manifest_path) {
|
|
1858
|
+
sourceKind = "manifest";
|
|
1859
|
+
sourcePath = input.manifest_path;
|
|
1860
|
+
const abs = resolveWithinRoot(PROJECT_ROOT, input.manifest_path);
|
|
1861
|
+
if (!abs) {
|
|
1862
|
+
throw new Error(`manifest_path "${input.manifest_path}" must be a relative path within the project root (${PROJECT_ROOT}) -- it was either absolute or escaped the project root.`);
|
|
1863
|
+
}
|
|
1864
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) {
|
|
1865
|
+
throw new Error(`No file found at "${input.manifest_path}" (resolved to ${abs}).`);
|
|
1866
|
+
}
|
|
1867
|
+
candidates = parseManifestCandidates(readFileSync(abs, "utf8"), input.manifest_path);
|
|
1868
|
+
}
|
|
1869
|
+
else {
|
|
1870
|
+
sourceKind = "directory_scan";
|
|
1871
|
+
sourcePath = input.directory_path;
|
|
1872
|
+
const abs = resolveWithinRoot(PROJECT_ROOT, sourcePath);
|
|
1873
|
+
if (!abs) {
|
|
1874
|
+
throw new Error(`directory_path "${sourcePath}" must be a relative path within the project root (${PROJECT_ROOT}) -- it was either absolute or escaped the project root.`);
|
|
1875
|
+
}
|
|
1876
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
1877
|
+
throw new Error(`No directory found at "${sourcePath}" (resolved to ${abs}).`);
|
|
1878
|
+
}
|
|
1879
|
+
candidates = scanDirectoryForDesignSystem(abs);
|
|
1880
|
+
}
|
|
1881
|
+
if (candidates.length === 0) {
|
|
1882
|
+
throw new Error(`No components could be found at "${sourcePath}" (${sourceKind}). Check the manifest shape, or that the directory actually contains component files recognized by the scan (export default function/const, uppercase-leading name).`);
|
|
1883
|
+
}
|
|
1884
|
+
const registration = {
|
|
1885
|
+
project_id: input.project_id,
|
|
1886
|
+
source_kind: sourceKind,
|
|
1887
|
+
source_path: sourcePath,
|
|
1888
|
+
registered_at: new Date().toISOString(),
|
|
1889
|
+
candidate_count: candidates.length,
|
|
1890
|
+
candidates,
|
|
1891
|
+
};
|
|
1892
|
+
const file = readDesignSystems();
|
|
1893
|
+
file[input.project_id] = registration;
|
|
1894
|
+
writeDesignSystems(file);
|
|
1895
|
+
return registration;
|
|
1896
|
+
}
|
|
1897
|
+
// ---------------------------------------------------------------------
|
|
1898
|
+
// Design-system recall check
|
|
1899
|
+
//
|
|
1900
|
+
// Catches a real, specific failure mode raised after this feature shipped:
|
|
1901
|
+
// the model can say "no_candidates_found" against a registered design
|
|
1902
|
+
// system even when a genuinely relevant candidate is sitting right there
|
|
1903
|
+
// in the prompt it was just given -- a reading-comprehension miss over
|
|
1904
|
+
// its own known-complete candidate list, not a live-search gap (compare
|
|
1905
|
+
// external-library mode, where "nothing found" is at least grounded in a
|
|
1906
|
+
// real search actually coming back empty). Unlike the ensemble
|
|
1907
|
+
// (isBoundaryRisk), which only re-checks coverage-boundary "scored"
|
|
1908
|
+
// results, nothing previously re-checked a "no_candidates_found" verdict
|
|
1909
|
+
// at all -- it was trusted on the first pass. This doesn't fix that by
|
|
1910
|
+
// spending another API call; it's a cheap, deterministic, zero-cost local
|
|
1911
|
+
// keyword-overlap check between component_need/domain and every
|
|
1912
|
+
// registered candidate's own name/props/description, run only when
|
|
1913
|
+
// reason is "no_candidates_found" in design-system mode. A hit doesn't
|
|
1914
|
+
// override the verdict -- a shared keyword is weak evidence, not proof of
|
|
1915
|
+
// a real match -- it only surfaces the risk on the response so the
|
|
1916
|
+
// calling agent knows to double-check before trusting a "nothing here"
|
|
1917
|
+
// answer, same "show the uncertainty, don't paper over it" policy as
|
|
1918
|
+
// ensemble/oversized_match elsewhere in this file.
|
|
1919
|
+
// ---------------------------------------------------------------------
|
|
1920
|
+
// Deliberately generic English filler, not UI-specific -- a UI-specific
|
|
1921
|
+
// word like "banner" or "list" is exactly the kind of overlap this check
|
|
1922
|
+
// exists to catch, so only true stopwords are excluded here.
|
|
1923
|
+
const KEYWORD_STOPWORDS = new Set([
|
|
1924
|
+
"the", "a", "an", "and", "or", "of", "to", "for", "with", "in", "on", "at",
|
|
1925
|
+
"by", "from", "is", "are", "was", "were", "be", "been", "being", "that",
|
|
1926
|
+
"this", "these", "those", "it", "its", "as", "not", "no", "if", "when",
|
|
1927
|
+
"which", "what", "who", "how", "into", "over", "out", "up", "down", "new",
|
|
1928
|
+
"real", "component", "components", "need", "needs", "show", "showing",
|
|
1929
|
+
"shows", "display", "displays", "displaying", "user", "users", "each",
|
|
1930
|
+
"other", "also", "app", "style", "product",
|
|
1931
|
+
]);
|
|
1932
|
+
// Splits on non-alphanumeric boundaries AND camelCase/PascalCase boundaries
|
|
1933
|
+
// (so "bonusAmount" -> "bonus", "amount" and "ReferralBanner" -> "referral",
|
|
1934
|
+
// "banner"), lowercases, then drops stopwords and anything under 3
|
|
1935
|
+
// characters -- short tokens ("id", "on") are too generic to be a
|
|
1936
|
+
// meaningful signal either way.
|
|
1937
|
+
function extractKeywords(text) {
|
|
1938
|
+
const words = text
|
|
1939
|
+
.split(/[^A-Za-z0-9]+/)
|
|
1940
|
+
.flatMap((w) => w.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/\s+/))
|
|
1941
|
+
.map((w) => w.toLowerCase())
|
|
1942
|
+
.filter((w) => w.length >= 3 && !KEYWORD_STOPWORDS.has(w));
|
|
1943
|
+
return new Set(words);
|
|
1944
|
+
}
|
|
1945
|
+
function candidateKeywords(c) {
|
|
1946
|
+
return extractKeywords([c.name, ...c.props, c.description ?? "", c.usage_example ?? ""].join(" "));
|
|
1947
|
+
}
|
|
1948
|
+
// Returns every registered candidate sharing at least one real keyword
|
|
1949
|
+
// with component_need/domain, ranked by how many keywords it shares --
|
|
1950
|
+
// capped at 5 so a large design system can't produce an unreadable dump
|
|
1951
|
+
// on a genuinely generic need.
|
|
1952
|
+
export function findKeywordOverlapCandidates(componentNeed, domain, candidates) {
|
|
1953
|
+
const needKeywords = extractKeywords(`${componentNeed} ${domain}`);
|
|
1954
|
+
if (needKeywords.size === 0)
|
|
1955
|
+
return [];
|
|
1956
|
+
const matches = candidates
|
|
1957
|
+
.map((c) => ({ name: c.name, shared_keywords: [...candidateKeywords(c)].filter((k) => needKeywords.has(k)) }))
|
|
1958
|
+
.filter((m) => m.shared_keywords.length > 0);
|
|
1959
|
+
matches.sort((a, b) => b.shared_keywords.length - a.shared_keywords.length);
|
|
1960
|
+
return matches.slice(0, 5);
|
|
1961
|
+
}
|
|
1427
1962
|
function hashConventions(existingStack) {
|
|
1428
1963
|
if (!existingStack)
|
|
1429
1964
|
return null;
|
|
@@ -1920,6 +2455,15 @@ export function formatProvenanceArtifact(entry) {
|
|
|
1920
2455
|
const chosen = c.name !== null && c.name === entry.chosen_candidate ? "✓" : "";
|
|
1921
2456
|
lines.push(`| ${c.source ?? "n/a"} | ${c.name ?? "n/a"} | ${c.coverage_pct ?? "n/a"} | ${chosen} |`);
|
|
1922
2457
|
}
|
|
2458
|
+
// Solo Dev design-system architecture P3: distinguish a design_system
|
|
2459
|
+
// source from an external library at render time -- reads only
|
|
2460
|
+
// entry.candidates_evaluated (already-persisted, distilled data), so
|
|
2461
|
+
// this stays a pure function of the entry alone like the rest of this
|
|
2462
|
+
// formatter.
|
|
2463
|
+
if (entry.candidates_evaluated.some((c) => c.source === "design_system")) {
|
|
2464
|
+
lines.push("");
|
|
2465
|
+
lines.push("_Sourced from this project's own registered design system (`register_design_system`), not an external library._");
|
|
2466
|
+
}
|
|
1923
2467
|
}
|
|
1924
2468
|
lines.push("");
|
|
1925
2469
|
lines.push(`_Generated by Pattern (\`export_ledger_provenance\`) from ledger entry \`${entry.id}\`._`);
|
|
@@ -2838,7 +3382,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2838
3382
|
"the user after the call (e.g. 'that judgment cost ~$0.12'), the " +
|
|
2839
3383
|
"same way install_command is shown before running -- it's real " +
|
|
2840
3384
|
"spend against the user's own API key, not internal bookkeeping " +
|
|
2841
|
-
"to keep from them."
|
|
3385
|
+
"to keep from them. If project_id has a design system registered " +
|
|
3386
|
+
"via register_design_system, this call scores ONLY against that " +
|
|
3387
|
+
"project's own registered candidates instead of shadcn/ui, " +
|
|
3388
|
+
"21st.dev, and ReUI -- no separate flag needed, it's automatic " +
|
|
3389
|
+
"based on project_id alone. In that mode, a custom_build verdict " +
|
|
3390
|
+
"with reason no_candidates_found may also carry a top-level " +
|
|
3391
|
+
"design_system_recall_check field -- a deterministic, zero-cost " +
|
|
3392
|
+
"keyword-overlap check flagging registered candidates that share " +
|
|
3393
|
+
"real keywords with this need but weren't selected. This is a " +
|
|
3394
|
+
"weak signal, not proof of a missed match -- if present, surface " +
|
|
3395
|
+
"it to the user before accepting the custom_build verdict at " +
|
|
3396
|
+
"face value.",
|
|
2842
3397
|
inputSchema: INPUT_SCHEMA,
|
|
2843
3398
|
},
|
|
2844
3399
|
{
|
|
@@ -3000,6 +3555,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
3000
3555
|
"for later lookup; never modifies ledger.jsonl itself.",
|
|
3001
3556
|
inputSchema: BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA,
|
|
3002
3557
|
},
|
|
3558
|
+
{
|
|
3559
|
+
name: REGISTER_DESIGN_SYSTEM_TOOL_NAME,
|
|
3560
|
+
description: "Points recommend_component at THIS project's own design system " +
|
|
3561
|
+
"instead of shadcn/ui, 21st.dev, and ReUI -- for a solo dev with " +
|
|
3562
|
+
"their own component library or design spec who wants Pattern's " +
|
|
3563
|
+
"coverage scoring against real candidates they'll actually use, " +
|
|
3564
|
+
"not external libraries they won't. Pass either manifest_path (a " +
|
|
3565
|
+
"hand-authored JSON manifest or a Storybook-exported stories/" +
|
|
3566
|
+
"index JSON file) or directory_path (a components folder, scanned " +
|
|
3567
|
+
"heuristically for exported components and their props) -- both " +
|
|
3568
|
+
"relative to the project root, never absolute. Registering " +
|
|
3569
|
+
"REPLACES any prior registration for this project_id, and once " +
|
|
3570
|
+
"registered, recommend_component scores ONLY against these " +
|
|
3571
|
+
"candidates for this project_id -- external-library search stops " +
|
|
3572
|
+
"entirely, it does not layer on top. This only writes local " +
|
|
3573
|
+
"config; it never calls the Anthropic API. Re-run this whenever " +
|
|
3574
|
+
"the design system's own components change meaningfully -- " +
|
|
3575
|
+
"registration is a point-in-time snapshot, not a live link.",
|
|
3576
|
+
inputSchema: REGISTER_DESIGN_SYSTEM_INPUT_SCHEMA,
|
|
3577
|
+
},
|
|
3003
3578
|
],
|
|
3004
3579
|
}));
|
|
3005
3580
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -3213,6 +3788,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3213
3788
|
};
|
|
3214
3789
|
}
|
|
3215
3790
|
}
|
|
3791
|
+
if (request.params.name === REGISTER_DESIGN_SYSTEM_TOOL_NAME) {
|
|
3792
|
+
const args = request.params.arguments;
|
|
3793
|
+
try {
|
|
3794
|
+
const registration = registerDesignSystem(args);
|
|
3795
|
+
return {
|
|
3796
|
+
content: [{ type: "text", text: JSON.stringify({ status: "registered", registration }) }],
|
|
3797
|
+
};
|
|
3798
|
+
}
|
|
3799
|
+
catch (err) {
|
|
3800
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3801
|
+
return {
|
|
3802
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
3803
|
+
isError: true,
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3216
3807
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
3217
3808
|
});
|
|
3218
3809
|
async function main() {
|