pattern-mcp 0.7.0 → 0.8.1

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.
Files changed (3) hide show
  1. package/README.md +314 -155
  2. package/dist/index.js +631 -40
  3. package/package.json +2 -2
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. That's tracked as a separate, differently
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
- Search TWO reference sources, one search call each (two calls total, reserved separately from the discovery budget above):
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
- 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.
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
- 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. 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.
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
- 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.
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
- Shape the "reference" field based on how many sources actually grounded:
873
- - Both Mobbin and Figma Community grounded: an array of both reference objects.
874
- - Only one grounded: a single reference object (not a one-element array).
875
- - Neither grounded: omit "reference" entirely (null), same as a custom_build verdict with no usable reference at all today.
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
- 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.
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 from a different source.
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
- 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.
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: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
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
- ...(SEARCH_BUDGET !== null ? { max_uses: SEARCH_BUDGET + 2 } : {}),
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
- max_uses: 3,
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() {