pattern-mcp 0.6.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.
Files changed (3) hide show
  1. package/README.md +464 -57
  2. package/dist/index.js +1143 -56
  3. 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.
@@ -59,6 +59,34 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
59
59
  }
60
60
  return parsed;
61
61
  })();
62
+ // Cost/latency reduction plan, step 2 (BACKLOG.md): trimmed from 15,000
63
+ // after a real 4-case instrumentation sample (2026-09-02) showed the
64
+ // largest actual fetched page was ~10.7k tokens (a shadcn doc page),
65
+ // comfortably under this new cap with headroom. Deliberately NOT split
66
+ // into separate step-4 (candidate-doc) vs. step-6 (Mobbin/Figma) caps as
67
+ // originally scoped: both steps share one web_fetch tool instance, so a
68
+ // real split would mean defining two separately-named web_fetch tools and
69
+ // trusting the model to pick the right one per step -- a real behavior
70
+ // risk for a saving the same sample disproved anyway. Both custom_build
71
+ // cases in that sample had elevated "fresh" (fully-priced, uncached)
72
+ // token counts even though their Mobbin fetch *failed*
73
+ // (url_not_accessible, 0 bytes returned) -- the cost driver there is the
74
+ // extra Mobbin/Figma-restricted search calls, not fetched content size,
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
81
+ // -scoped backlog item, not folded into this one.
82
+ const FETCH_MAX_CONTENT_TOKENS_RAW = process.env.PATTERN_FETCH_MAX_CONTENT_TOKENS ?? "12000";
83
+ const FETCH_MAX_CONTENT_TOKENS = (() => {
84
+ const parsed = Number.parseInt(FETCH_MAX_CONTENT_TOKENS_RAW, 10);
85
+ if (!Number.isFinite(parsed) || parsed <= 0) {
86
+ throw new Error(`PATTERN_FETCH_MAX_CONTENT_TOKENS must be a positive integer, got: ${FETCH_MAX_CONTENT_TOKENS_RAW}`);
87
+ }
88
+ return parsed;
89
+ })();
62
90
  // Static skip-list: single-purpose primitives with no meaningful internal
63
91
  // structure to score coverage against. Decided in the product brief as a
64
92
  // starting point -- revisit once real usage data exists (see README).
@@ -114,6 +142,16 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
114
142
  // of what's in this file (see README's "no verdict caching" rule).
115
143
  const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
116
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");
117
155
  // Per-project judgment ledger -- distinct from both LOG_PATH and
118
156
  // MEMORY_PATH above. Every recommend_component call that reaches the API
119
157
  // with a project_id and lands on reason "scored" or "no_candidates_found"
@@ -188,6 +226,31 @@ function computeSnapshotRef(root) {
188
226
  return null;
189
227
  }
190
228
  }
229
+ // Feature 2 / Decision Provenance, P3: best-effort reconstruction of
230
+ // snapshot_ref for an entry written before that field existed (or written
231
+ // outside a git repo -- though a project that's never used git has
232
+ // nothing to reconstruct from either way). Finds the commit that was HEAD
233
+ // at or just before the entry's own timestamp. Necessarily an
234
+ // approximation, not a guarantee: a rebase, force-push, or history
235
+ // rewrite since that time can make "the commit HEAD pointed to then" no
236
+ // longer resolve to what the codebase actually looked like at judgment
237
+ // time -- exactly the risk the spec's own mitigation table already names.
238
+ // Read-only, same timeout/error-swallowing discipline as
239
+ // computeSnapshotRef above.
240
+ function reconstructSnapshotRef(root, atISOTimestamp) {
241
+ try {
242
+ const sha = execFileSync("git", ["log", `--before=${atISOTimestamp}`, "-1", "--format=%H"], {
243
+ cwd: root,
244
+ encoding: "utf8",
245
+ stdio: ["ignore", "pipe", "ignore"],
246
+ timeout: 2000,
247
+ }).trim();
248
+ return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
191
254
  // Kill switch for the cache-hit short-circuit specifically -- does NOT
192
255
  // disable the ledger itself. Entries still get written and read_ledger
193
256
  // still works either way; this only controls whether judgeComponent is
@@ -443,6 +506,10 @@ const REPORT_BUILD_COST_TOOL_NAME = "report_build_cost";
443
506
  const REPORT_OUTCOME_PROXY_TOOL_NAME = "report_outcome_proxy";
444
507
  const CHECK_LEDGER_LIVENESS_TOOL_NAME = "check_ledger_liveness";
445
508
  const EXPORT_LEDGER_PROVENANCE_TOOL_NAME = "export_ledger_provenance";
509
+ const POST_LEDGER_PROVENANCE_TOOL_NAME = "post_ledger_provenance_to_github";
510
+ const SWEEP_LEDGER_LIVENESS_TOOL_NAME = "sweep_ledger_liveness";
511
+ const BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME = "backfill_ledger_snapshot_ref";
512
+ const REGISTER_DESIGN_SYSTEM_TOOL_NAME = "register_design_system";
446
513
  const INPUT_SCHEMA = {
447
514
  type: "object",
448
515
  properties: {
@@ -692,6 +759,72 @@ const EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA = {
692
759
  },
693
760
  required: ["project_id", "ledger_entry_id"],
694
761
  };
762
+ const POST_LEDGER_PROVENANCE_INPUT_SCHEMA = {
763
+ type: "object",
764
+ properties: {
765
+ project_id: {
766
+ type: "string",
767
+ description: "The project_id used in the recommend_component call that produced this ledger entry.",
768
+ },
769
+ ledger_entry_id: {
770
+ type: "string",
771
+ description: "The specific entry to post, from read_ledger or check_ledger_liveness.",
772
+ },
773
+ repo: {
774
+ type: "string",
775
+ description: 'GitHub repo in "owner/repo" form, e.g. "my-org/my-booking-app".',
776
+ },
777
+ issue_number: {
778
+ type: "number",
779
+ description: "The PR or issue number to comment on -- GitHub treats both identically for comments, so no separate type flag is needed.",
780
+ },
781
+ },
782
+ required: ["project_id", "ledger_entry_id", "repo", "issue_number"],
783
+ };
784
+ const SWEEP_LEDGER_LIVENESS_INPUT_SCHEMA = {
785
+ type: "object",
786
+ properties: {
787
+ project_id: {
788
+ type: "string",
789
+ description: "Optional. Scope the sweep to one project_id. Omit to sweep every " +
790
+ "project_id present in the ledger -- the whole-ledger, scheduler-driven " +
791
+ "mode this tool exists for.",
792
+ },
793
+ },
794
+ required: [],
795
+ };
796
+ const BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA = {
797
+ type: "object",
798
+ properties: {
799
+ project_id: {
800
+ type: "string",
801
+ description: "The project_id whose ledger entries to backfill.",
802
+ },
803
+ ledger_entry_id: {
804
+ type: "string",
805
+ description: "Optional. Backfill just this one entry instead of every entry for project_id missing snapshot_ref.",
806
+ },
807
+ },
808
+ required: ["project_id"],
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
+ };
695
828
  // Shared between buildSystemPrompt's own step 2 and
696
829
  // buildExtractionSystemPrompt (the extract_requirements tool's standalone
697
830
  // prompt) -- the extraction *instructions* are one piece of text reused
@@ -701,6 +834,54 @@ const EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA = {
701
834
  // tools at all). This is what "factor it out into a shared function" means
702
835
  // here: the wording, not a shared HTTP call.
703
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
+ }`;
704
885
  function buildSystemPrompt(searchBudget, opts) {
705
886
  const budgetLine = searchBudget === null
706
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."
@@ -760,48 +941,81 @@ If the verdict is use_existing, include "component_description": 1-2 sentences o
760
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.
761
942
 
762
943
  6. IF custom_build
763
- Search TWO reference sources, one search call each (two calls total, reserved separately from the discovery budget above):
764
- - Mobbin (site:mobbin.com) for the closest real-app screen matching the stated domain (e.g. real Airbnb screens for an Airbnb-style app).
765
- - 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}
766
945
 
767
- 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.
768
948
 
769
- 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}
770
951
 
771
- 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.
772
970
 
773
- Shape the "reference" field based on how many sources actually grounded:
774
- - Both Mobbin and Figma Community grounded: an array of both reference objects.
775
- - Only one grounded: a single reference object (not a one-element array).
776
- - 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.
777
972
 
778
- 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.
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.
1008
+
1009
+ 6. IF custom_build
1010
+ ${CUSTOM_BUILD_REFERENCE_INSTRUCTIONS}
779
1011
 
780
1012
  7. EXISTING STACK TIEBREAKER
781
- 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.
782
1014
 
783
1015
  8. PAST DECISION SIGNAL (only if the user message included a "Past confirmed decisions in this project" section)
784
- 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.
1016
+ ${PAST_DECISION_SIGNAL_INSTRUCTIONS}
785
1017
 
786
- Respond with ONLY a single JSON object, no prose before or after, no markdown code fences, matching this exact shape:
787
-
788
- {
789
- "verdict": "use_existing" | "custom_build",
790
- "confidence": "high" | "medium" | "low",
791
- "reason": "scored" | "no_candidates_found" | "skip_list",
792
- "computed_at": "<today's date, ISO format>",
793
- "requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
794
- "coverage": "string like '5/7 (71%)'" | null,
795
- "oversized_match": true|false | omit if verdict is not use_existing,
796
- "oversized_match_note": "string, required when oversized_match is true" | omit otherwise,
797
- "recommendation": {
798
- "source": "string or null",
799
- "install_command": "string or null",
800
- "component_description": "string (use_existing only) or null",
801
- "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
802
- },
803
- "past_decision_signal": { "considered": true|false, "note": "string" } | omit this field entirely if step 8 doesn't apply
804
- }`;
1018
+ ${JUDGMENT_RESPONSE_SHAPE}`;
805
1019
  }
806
1020
  // Standalone prompt for the extract_requirements tool -- shares
807
1021
  // EXTRACTION_INSTRUCTIONS with buildSystemPrompt's own step 2 (see that
@@ -898,10 +1112,28 @@ async function runSinglePass(input) {
898
1112
  .map((item, i) => `${i + 1}. ${item}`)
899
1113
  .join("\n")}`
900
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
+ : "";
901
1133
  const userMessage = `component_need: ${input.component_need}
902
1134
  domain: ${input.domain}
903
1135
  framework: ${input.framework}
904
- existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}`;
1136
+ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${pastDecisionsBlock}${designSystemBlock}`;
905
1137
  // Diagnostic only, same pattern as the other stderr diagnostics in this
906
1138
  // file -- proves the memory lookup actually reached the prompt sent to
907
1139
  // the model, not just that it was read from disk successfully.
@@ -928,7 +1160,9 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
928
1160
  system: [
929
1161
  {
930
1162
  type: "text",
931
- text: buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
1163
+ text: designSystem
1164
+ ? buildDesignSystemSystemPrompt({ checklistProvided: checklistSource === "provided" })
1165
+ : buildSystemPrompt(SEARCH_BUDGET, { checklistProvided: checklistSource === "provided" }),
932
1166
  cache_control: { type: "ephemeral" },
933
1167
  },
934
1168
  ],
@@ -950,7 +1184,13 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
950
1184
  // where 0 Mobbin queries were attempted but a specific Mobbin
951
1185
  // URL was still returned. Figma Community gets the same
952
1186
  // treatment now that it's a second reference source.
953
- ...(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 } : {}),
954
1194
  },
955
1195
  {
956
1196
  type: "web_fetch_20250910",
@@ -968,11 +1208,18 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
968
1208
  // never more than once per source). Not reserved from the
969
1209
  // web_search budget above; this is a separate tool with its own
970
1210
  // separate cap.
971
- 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,
972
1217
  // Category/browse pages can be large, and all we need from them
973
1218
  // is a permalink, not the full page -- caps token cost of a
974
- // fetch that turns out not to have a deep link after all.
975
- max_content_tokens: 15000,
1219
+ // fetch that turns out not to have a deep link after all. See
1220
+ // FETCH_MAX_CONTENT_TOKENS above for why this is 12,000, not the
1221
+ // original 15,000, and why it isn't split per-step.
1222
+ max_content_tokens: FETCH_MAX_CONTENT_TOKENS,
976
1223
  },
977
1224
  ],
978
1225
  });
@@ -1092,6 +1339,38 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
1092
1339
  enforceCoverageRecount(parsed);
1093
1340
  enforceVerdictThreshold(parsed);
1094
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
+ }
1095
1374
  // Set server-side rather than trusted from the model -- deterministic
1096
1375
  // from whether input.checklist was actually supplied, same "never trust
1097
1376
  // the model where the server already knows the truth" policy as the
@@ -1323,6 +1602,363 @@ export function getPastDecisions(projectId) {
1323
1602
  const memory = readMemory();
1324
1603
  return memory[projectId] ?? [];
1325
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
+ }
1326
1962
  function hashConventions(existingStack) {
1327
1963
  if (!existingStack)
1328
1964
  return null;
@@ -1377,9 +2013,21 @@ function readLedgerLivenessRecords(ledgerEntryId) {
1377
2013
  }
1378
2014
  return records;
1379
2015
  }
2016
+ // Deliberately not a sort-then-take-first: readLedgerLivenessRecords
2017
+ // returns records in file/append order (oldest first), and a descending
2018
+ // sort by timestamp is NOT tie-safe -- JS's stable sort preserves the
2019
+ // original relative order among equal timestamps, so on a tie (two
2020
+ // records appended within the same millisecond, which sweepLedgerLiveness
2021
+ // does routinely -- a per-entry check followed immediately by a
2022
+ // dangling-cluster append for the same entry) it would silently return
2023
+ // the OLDER of the two. reduce with >= walks forward through true append
2024
+ // order and lets each later-appended tied record win, which is what
2025
+ // "latest" actually means here.
1380
2026
  function latestLiveness(ledgerEntryId) {
1381
- const records = readLedgerLivenessRecords(ledgerEntryId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1382
- return records[0] ?? null;
2027
+ const records = readLedgerLivenessRecords(ledgerEntryId);
2028
+ if (records.length === 0)
2029
+ return null;
2030
+ return records.reduce((latest, r) => (new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime() ? r : latest));
1383
2031
  }
1384
2032
  function withLatestLiveness(entry) {
1385
2033
  const latest = latestLiveness(entry.id);
@@ -1387,6 +2035,59 @@ function withLatestLiveness(entry) {
1387
2035
  return entry;
1388
2036
  return { ...entry, live_status: latest.live_status, last_verified_live: latest.timestamp };
1389
2037
  }
2038
+ // Feature 2 P3's overlay -- same append-only/latest-wins convention as
2039
+ // ledger_liveness.jsonl above, kept as a fully separate file/function pair
2040
+ // rather than folded into the liveness overlay: these two overlays answer
2041
+ // unrelated questions (is the file still there vs. what commit was this
2042
+ // judged against) and happen to share only their storage shape, not their
2043
+ // meaning.
2044
+ const SNAPSHOT_BACKFILL_PATH = process.env.PATTERN_SNAPSHOT_BACKFILL_PATH ?? join(homedir(), ".pattern", "snapshot_backfill.jsonl");
2045
+ function appendSnapshotBackfillRecord(record) {
2046
+ mkdirSync(dirname(SNAPSHOT_BACKFILL_PATH), { recursive: true });
2047
+ appendFileSync(SNAPSHOT_BACKFILL_PATH, JSON.stringify(record) + "\n", "utf8");
2048
+ }
2049
+ function readSnapshotBackfillRecords(ledgerEntryId) {
2050
+ let raw;
2051
+ try {
2052
+ raw = readFileSync(SNAPSHOT_BACKFILL_PATH, "utf8");
2053
+ }
2054
+ catch {
2055
+ return [];
2056
+ }
2057
+ const records = [];
2058
+ for (const line of raw.split("\n")) {
2059
+ if (!line.trim())
2060
+ continue;
2061
+ try {
2062
+ const parsed = JSON.parse(line);
2063
+ if (parsed && typeof parsed === "object" && parsed.ledger_entry_id === ledgerEntryId) {
2064
+ records.push(parsed);
2065
+ }
2066
+ }
2067
+ catch {
2068
+ // skip malformed line
2069
+ }
2070
+ }
2071
+ return records;
2072
+ }
2073
+ // Same tie-safety reasoning as latestLiveness above.
2074
+ function latestSnapshotBackfill(ledgerEntryId) {
2075
+ const records = readSnapshotBackfillRecords(ledgerEntryId);
2076
+ if (records.length === 0)
2077
+ return null;
2078
+ return records.reduce((latest, r) => (new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime() ? r : latest));
2079
+ }
2080
+ // Only overlays onto entries that actually need it -- an entry with a
2081
+ // real snapshot_ref never consults the backfill overlay at all, so a
2082
+ // stray/stale backfill record can never shadow a genuine captured value.
2083
+ function withReconstructedSnapshotRef(entry) {
2084
+ if (entry.snapshot_ref)
2085
+ return entry;
2086
+ const latest = latestSnapshotBackfill(entry.id);
2087
+ if (!latest)
2088
+ return entry;
2089
+ return { ...entry, reconstructed_snapshot_ref: latest.reconstructed_snapshot_ref };
2090
+ }
1390
2091
  // Feature 1 / Referential Integrity, P1: the single-entry live-check.
1391
2092
  // Orphaned when file_path is set but the file no longer exists; live when
1392
2093
  // the file exists and (best-effort) still mentions chosen_candidate;
@@ -1394,9 +2095,10 @@ function withLatestLiveness(entry) {
1394
2095
  // resolveWithinRoot), or exists but the candidate name can't be confirmed
1395
2096
  // in its content -- conservative on purpose, per the spec's own risk
1396
2097
  // mitigation (a false "orphaned" is worse than a lingering "unknown").
1397
- // "dangling" (an entry only cross-referenced by other ledger entries, no
1398
- // live anchor anywhere) is graph-level analysis across the whole ledger,
1399
- // not a single-entry check -- Feature 1 P3, not built here.
2098
+ // "dangling" (a cluster of entries with no live anchor anywhere among
2099
+ // them) is graph-level analysis across a whole project's entries, not a
2100
+ // single-entry check -- see detectDanglingClusters, part of
2101
+ // sweep_ledger_liveness (Feature 1 P2/P3), not this function.
1400
2102
  function checkFileLiveStatus(entry) {
1401
2103
  if (!entry.file_path)
1402
2104
  return "unknown";
@@ -1428,11 +2130,12 @@ function checkLedgerEntryLiveness(entry) {
1428
2130
  return record;
1429
2131
  }
1430
2132
  // check_ledger_liveness tool: on-demand invocation of the live-check above
1431
- // (the design's "on demand via an MCP call" case -- a scheduled/batch
1432
- // sweep is Feature 1 P2, not built here). Entries with no file_path are
1433
- // reported but never checked/recorded -- their status is permanently
1434
- // "unknown" by construction, so re-checking them on every call would only
1435
- // grow ledger_liveness.jsonl without ever learning anything new.
2133
+ // (the design's "on demand via an MCP call" case -- see
2134
+ // sweepLedgerLiveness below for the scheduled/batch case, Feature 1 P2).
2135
+ // Entries with no file_path are reported but never checked/recorded --
2136
+ // their status is permanently "unknown" by construction, so re-checking
2137
+ // them on every call would only grow ledger_liveness.jsonl without ever
2138
+ // learning anything new.
1436
2139
  function checkLedgerLiveness(input) {
1437
2140
  const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
1438
2141
  const results = entries.map((e) => {
@@ -1462,6 +2165,37 @@ function checkLedgerLiveness(input) {
1462
2165
  results,
1463
2166
  };
1464
2167
  }
2168
+ // backfill_ledger_snapshot_ref tool (Feature 2 P3): attempts
2169
+ // reconstructSnapshotRef for every entry in a project that's missing a
2170
+ // real snapshot_ref, and persists each attempt to snapshot_backfill.jsonl
2171
+ // regardless of outcome -- a documented "we tried, here's what we found"
2172
+ // audit trail, not just a cache, since a failed reconstruction is itself
2173
+ // meaningful information (this project's git history doesn't reach back
2174
+ // that far, or PROJECT_ROOT isn't a git repo at all). Entries that
2175
+ // already have a real snapshot_ref are reported but never touched --
2176
+ // backfill only ever fills a gap, never second-guesses a captured value.
2177
+ function backfillLedgerSnapshotRefs(input) {
2178
+ const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
2179
+ const results = entries.map((e) => {
2180
+ if (e.snapshot_ref) {
2181
+ return { ledger_entry_id: e.id, already_had_snapshot_ref: true, reconstructed_snapshot_ref: null };
2182
+ }
2183
+ const reconstructed = reconstructSnapshotRef(PROJECT_ROOT, e.timestamp);
2184
+ appendSnapshotBackfillRecord({
2185
+ id: randomUUID(),
2186
+ timestamp: new Date().toISOString(),
2187
+ ledger_entry_id: e.id,
2188
+ project_id: e.project_id,
2189
+ reconstructed_snapshot_ref: reconstructed,
2190
+ });
2191
+ return { ledger_entry_id: e.id, already_had_snapshot_ref: false, reconstructed_snapshot_ref: reconstructed };
2192
+ });
2193
+ return {
2194
+ attempted: results.filter((r) => !r.already_had_snapshot_ref).length,
2195
+ reconstructed: results.filter((r) => r.reconstructed_snapshot_ref !== null).length,
2196
+ results,
2197
+ };
2198
+ }
1465
2199
  // Same "missing/malformed collapses to empty" philosophy as readMemory,
1466
2200
  // but line-oriented (JSONL) rather than whole-file JSON -- a single
1467
2201
  // corrupted line (e.g. a hand-edited file, or a write that got cut off)
@@ -1492,8 +2226,9 @@ function readLedgerEntries(projectId) {
1492
2226
  snapshot_ref: rawEntry.snapshot_ref ?? null,
1493
2227
  last_verified_live: rawEntry.last_verified_live ?? null,
1494
2228
  live_status: rawEntry.live_status ?? "unknown",
2229
+ reconstructed_snapshot_ref: rawEntry.reconstructed_snapshot_ref ?? null,
1495
2230
  };
1496
- entries.push(withLatestLiveness(normalized));
2231
+ entries.push(withReconstructedSnapshotRef(withLatestLiveness(normalized)));
1497
2232
  }
1498
2233
  }
1499
2234
  catch {
@@ -1502,6 +2237,109 @@ function readLedgerEntries(projectId) {
1502
2237
  }
1503
2238
  return entries;
1504
2239
  }
2240
+ // sweep_ledger_liveness (Feature 1 P2) needs every project_id present in
2241
+ // the ledger when none is specified -- readLedgerEntries always filters
2242
+ // to one project_id, so this is the one place that reads every line
2243
+ // unfiltered. Same "missing/malformed collapses to empty" tolerance as
2244
+ // readLedgerEntries itself.
2245
+ function listAllProjectIds() {
2246
+ let raw;
2247
+ try {
2248
+ raw = readFileSync(LEDGER_PATH, "utf8");
2249
+ }
2250
+ catch {
2251
+ return [];
2252
+ }
2253
+ const ids = new Set();
2254
+ for (const line of raw.split("\n")) {
2255
+ if (!line.trim())
2256
+ continue;
2257
+ try {
2258
+ const parsed = JSON.parse(line);
2259
+ if (parsed && typeof parsed === "object" && typeof parsed.project_id === "string") {
2260
+ ids.add(parsed.project_id);
2261
+ }
2262
+ }
2263
+ catch {
2264
+ // skip malformed line
2265
+ }
2266
+ }
2267
+ return [...ids];
2268
+ }
2269
+ // Feature 1 P3: the graph-level half of referential integrity that
2270
+ // checkFileLiveStatus's single-entry check can't do. Pattern's ledger has
2271
+ // no explicit entry-to-entry reference field (each line is an independent
2272
+ // judgment record) -- feature_id is the one real grouping construct that
2273
+ // already exists (deriveFeatureId), so a "cluster" here means every entry
2274
+ // sharing one feature_id, and "cross-linked with no live anchor" means
2275
+ // none of them resolved to live_status "live". A cluster of exactly one
2276
+ // entry is just an ordinary orphaned/unknown entry, not a cluster
2277
+ // phenomenon, so single-entry groups are never flagged.
2278
+ //
2279
+ // Must run after checkLedgerLiveness has updated live_status for the
2280
+ // same project -- otherwise this would be judging stale per-entry
2281
+ // statuses. sweepLedgerLiveness below enforces that ordering; this
2282
+ // function does not re-check individual entries itself.
2283
+ function detectDanglingClusters(projectId) {
2284
+ const entries = readLedgerEntries(projectId);
2285
+ const byFeature = new Map();
2286
+ for (const e of entries) {
2287
+ const group = byFeature.get(e.feature_id) ?? [];
2288
+ group.push(e);
2289
+ byFeature.set(e.feature_id, group);
2290
+ }
2291
+ const clusters = [];
2292
+ for (const [featureId, group] of byFeature) {
2293
+ if (group.length < 2)
2294
+ continue;
2295
+ if (group.some((e) => e.live_status === "live"))
2296
+ continue;
2297
+ clusters.push({ feature_id: featureId, entry_ids: group.map((e) => e.id) });
2298
+ for (const e of group) {
2299
+ appendLedgerLivenessRecord({
2300
+ id: randomUUID(),
2301
+ timestamp: new Date().toISOString(),
2302
+ ledger_entry_id: e.id,
2303
+ project_id: projectId,
2304
+ live_status: "dangling",
2305
+ checked_file_path: e.file_path,
2306
+ });
2307
+ }
2308
+ }
2309
+ return clusters;
2310
+ }
2311
+ // The MCP tool: batch-updates live_status across an entire ledger,
2312
+ // optionally scoped to one project_id, but sweeping every project_id
2313
+ // present when omitted -- the "on a schedule (project open or cron)" half
2314
+ // of the design that check_ledger_liveness's on-demand, single-project
2315
+ // call (P1) doesn't cover. Pattern has no daemon or background process of
2316
+ // its own to schedule this from (each server invocation is transient,
2317
+ // tied to its MCP host's lifecycle) -- this tool is meant to be invoked
2318
+ // by whatever external scheduler you already have (a cron job, a CI
2319
+ // step), not something Pattern triggers on its own.
2320
+ function sweepLedgerLiveness(input) {
2321
+ const projectIds = input.project_id ? [input.project_id] : listAllProjectIds();
2322
+ const perProject = [];
2323
+ const allDangling = [];
2324
+ for (const projectId of projectIds) {
2325
+ const liveness = checkLedgerLiveness({ project_id: projectId });
2326
+ const clusters = detectDanglingClusters(projectId);
2327
+ for (const c of clusters)
2328
+ allDangling.push({ project_id: projectId, ...c });
2329
+ perProject.push({
2330
+ project_id: projectId,
2331
+ checked: liveness.checked,
2332
+ total_entries: liveness.total_entries,
2333
+ dangling_clusters: clusters.length,
2334
+ });
2335
+ }
2336
+ return {
2337
+ projects_swept: projectIds.length,
2338
+ total_entries_checked: perProject.reduce((sum, p) => sum + p.checked, 0),
2339
+ dangling_clusters: allDangling,
2340
+ per_project: perProject,
2341
+ };
2342
+ }
1505
2343
  // The only entry point that writes ledger.jsonl. Validates every
1506
2344
  // candidate against the DistilledCandidate boundary before it ever touches
1507
2345
  // disk -- a raw object reaching here throws rather than silently
@@ -1556,6 +2394,24 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1556
2394
  entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1557
2395
  return entries.slice(0, limit);
1558
2396
  }
2397
+ // entry.reconstructed_snapshot_ref only ever gets consulted when
2398
+ // snapshot_ref itself is null (see withReconstructedSnapshotRef) -- this
2399
+ // still checks both explicitly, rather than assuming that invariant holds,
2400
+ // so the two can never be silently conflated even if that changes later.
2401
+ // A reconstructed value is always labeled as such: it's an approximation
2402
+ // (the commit HEAD probably pointed to at that timestamp), not the
2403
+ // original captured snapshot, and presenting it unlabeled would overstate
2404
+ // its reliability.
2405
+ function formatSnapshotLine(entry) {
2406
+ if (entry.snapshot_ref)
2407
+ return "`" + entry.snapshot_ref + "`";
2408
+ if (entry.reconstructed_snapshot_ref) {
2409
+ return ("`" +
2410
+ entry.reconstructed_snapshot_ref +
2411
+ "` (reconstructed via backfill -- best-effort approximation, not the original captured snapshot)");
2412
+ }
2413
+ return "not available (project root wasn't a git repository at judgment time)";
2414
+ }
1559
2415
  // Feature 2 / Decision Provenance, P1: renders one ledger entry as a
1560
2416
  // stable markdown block -- "stable" meaning a pure function of the entry
1561
2417
  // alone (never Date.now(), never anything read live off disk), so the
@@ -1563,8 +2419,8 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1563
2419
  // what makes verify-provenance-artifact.mjs's snapshot test meaningful:
1564
2420
  // a diff in the generated markdown for a fixed fixture means the format
1565
2421
  // changed, not that time passed. Markdown, not JSON, per the spec --
1566
- // PRs/issues render it natively (P2, not built here, attaches this to
1567
- // one).
2422
+ // PRs/issues render it natively (see export_ledger_provenance and
2423
+ // post_ledger_provenance_to_github, which attach this to one).
1568
2424
  export function formatProvenanceArtifact(entry) {
1569
2425
  const lines = [];
1570
2426
  lines.push(`## Pattern decision: ${entry.component_need}`);
@@ -1574,7 +2430,7 @@ export function formatProvenanceArtifact(entry) {
1574
2430
  lines.push(`- **Coverage:** ${entry.coverage ?? "n/a"}`);
1575
2431
  lines.push(`- **Domain:** ${entry.domain}`);
1576
2432
  lines.push(`- **Framework:** ${entry.framework}`);
1577
- lines.push(`- **Snapshot:** ${entry.snapshot_ref ? "`" + entry.snapshot_ref + "`" : "not available (project root wasn't a git repository at judgment time)"}`);
2433
+ lines.push(`- **Snapshot:** ${formatSnapshotLine(entry)}`);
1578
2434
  lines.push(`- **Judged at:** ${entry.timestamp}${entry.cache_hit ? " (served from ledger cache hit)" : ""}`);
1579
2435
  lines.push("");
1580
2436
  lines.push("### Requirements checked");
@@ -1599,11 +2455,99 @@ export function formatProvenanceArtifact(entry) {
1599
2455
  const chosen = c.name !== null && c.name === entry.chosen_candidate ? "✓" : "";
1600
2456
  lines.push(`| ${c.source ?? "n/a"} | ${c.name ?? "n/a"} | ${c.coverage_pct ?? "n/a"} | ${chosen} |`);
1601
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
+ }
1602
2467
  }
1603
2468
  lines.push("");
1604
2469
  lines.push(`_Generated by Pattern (\`export_ledger_provenance\`) from ledger entry \`${entry.id}\`._`);
1605
2470
  return lines.join("\n");
1606
2471
  }
2472
+ // Feature 2 / Decision Provenance, P2: posts an export_ledger_provenance
2473
+ // artifact as a real comment on a GitHub PR or issue. GitHub's REST API
2474
+ // treats a PR and an issue identically for comments (both are backed by
2475
+ // the same /issues/{number}/comments endpoint), so one input shape covers
2476
+ // both -- no separate "is this a PR" flag needed.
2477
+ //
2478
+ // This is the one tool in this server with a real, visible side effect on
2479
+ // a third-party service outside the caller's own machine -- every other
2480
+ // tool here only ever touches local files. The calling agent should
2481
+ // confirm with the user before invoking it, the same way it's expected to
2482
+ // confirm before running a suggested install_command (see SECURITY.md).
2483
+ //
2484
+ // Auth resolves the spec's own open question (personal token vs. GitHub
2485
+ // App) in favor of a personal token: reads GITHUB_TOKEN from the
2486
+ // environment, the same convention every GitHub Action and the `gh` CLI
2487
+ // itself use. A GitHub App needs a hosted installation flow and a webhook
2488
+ // receiver, which contradicts this project's "local npm package, no
2489
+ // hosted infrastructure" distribution model (see the README's Ledger
2490
+ // integrity section and the Pattern Primer's build-order principle) --
2491
+ // Pattern manages no GitHub credential of its own, the same way it
2492
+ // manages no git credential for computeSnapshotRef above.
2493
+ //
2494
+ // Idempotent by construction, not just by convention: every posted
2495
+ // comment is prefixed with a hidden HTML marker keyed to the ledger
2496
+ // entry's id, and a post first checks existing comments for that marker
2497
+ // -- a repeat call for the same entry returns posted: false instead of
2498
+ // creating a duplicate. Only checks the most recent 100 comments (one
2499
+ // page) -- a thread with more prior comments than that is an edge case
2500
+ // this pass doesn't handle; full pagination is a later concern, not built
2501
+ // here.
2502
+ const GITHUB_API_BASE = process.env.PATTERN_GITHUB_API_BASE ?? "https://api.github.com";
2503
+ function provenanceMarker(ledgerEntryId) {
2504
+ return `<!-- pattern-ledger-provenance:${ledgerEntryId} -->`;
2505
+ }
2506
+ async function postProvenanceToGitHub(input) {
2507
+ const token = process.env.GITHUB_TOKEN;
2508
+ if (!token) {
2509
+ throw new Error("GITHUB_TOKEN is not set. This tool posts a real comment to GitHub and needs a personal access token " +
2510
+ "with repo scope (the same one `gh auth login` or a GitHub Action would use) -- set the GITHUB_TOKEN " +
2511
+ "environment variable and retry.");
2512
+ }
2513
+ if (!/^[^/\s]+\/[^/\s]+$/.test(input.repo)) {
2514
+ throw new Error(`repo must be in "owner/repo" form, got: "${input.repo}"`);
2515
+ }
2516
+ const entry = readLedgerEntries(input.project_id).find((e) => e.id === input.ledger_entry_id);
2517
+ if (!entry) {
2518
+ throw new Error(`No ledger entry with id "${input.ledger_entry_id}" found for project_id "${input.project_id}". Use read_ledger to list entries and their ids.`);
2519
+ }
2520
+ const marker = provenanceMarker(entry.id);
2521
+ const headers = {
2522
+ Authorization: `Bearer ${token}`,
2523
+ Accept: "application/vnd.github+json",
2524
+ "Content-Type": "application/json",
2525
+ "User-Agent": "pattern-mcp",
2526
+ };
2527
+ const commentsUrl = `${GITHUB_API_BASE}/repos/${input.repo}/issues/${input.issue_number}/comments`;
2528
+ const listResponse = await fetch(`${commentsUrl}?per_page=100`, { headers });
2529
+ if (!listResponse.ok) {
2530
+ const errText = await listResponse.text();
2531
+ throw new Error(`GitHub API error ${listResponse.status} listing comments on ${input.repo}#${input.issue_number}: ${errText}`);
2532
+ }
2533
+ const existingComments = (await listResponse.json());
2534
+ const existing = existingComments.find((c) => c.body.includes(marker));
2535
+ if (existing) {
2536
+ return { posted: false, reason: "already_posted", comment_url: existing.html_url, comment_id: existing.id };
2537
+ }
2538
+ const body = `${marker}\n\n${formatProvenanceArtifact(entry)}`;
2539
+ const postResponse = await fetch(commentsUrl, {
2540
+ method: "POST",
2541
+ headers,
2542
+ body: JSON.stringify({ body }),
2543
+ });
2544
+ if (!postResponse.ok) {
2545
+ const errText = await postResponse.text();
2546
+ throw new Error(`GitHub API error ${postResponse.status} posting comment to ${input.repo}#${input.issue_number}: ${errText}`);
2547
+ }
2548
+ const created = (await postResponse.json());
2549
+ return { posted: true, comment_url: created.html_url, comment_id: created.id };
2550
+ }
1607
2551
  // report_build_cost (cost-attribution build plan, 1.3) -- self-reported
1608
2552
  // build cost, cheapest option first, since Pattern has no visibility into
1609
2553
  // what happens after judgeComponent returns a verdict (1.4's
@@ -1854,6 +2798,7 @@ function buildLedgerEntry(input, projectId, result, opts) {
1854
2798
  file_path: input.file_path ?? null,
1855
2799
  last_verified_live: null,
1856
2800
  live_status: "unknown",
2801
+ reconstructed_snapshot_ref: null,
1857
2802
  };
1858
2803
  }
1859
2804
  async function judgeComponent(input) {
@@ -2437,7 +3382,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2437
3382
  "the user after the call (e.g. 'that judgment cost ~$0.12'), the " +
2438
3383
  "same way install_command is shown before running -- it's real " +
2439
3384
  "spend against the user's own API key, not internal bookkeeping " +
2440
- "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.",
2441
3397
  inputSchema: INPUT_SCHEMA,
2442
3398
  },
2443
3399
  {
@@ -2549,9 +3505,76 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2549
3505
  "the same entry always produces the same markdown, nothing here " +
2550
3506
  "reads live system time or disk state. This only formats and " +
2551
3507
  "returns text; it does not post anything to GitHub or anywhere " +
2552
- "else -- that's a separate, not-yet-built action.",
3508
+ "else -- see post_ledger_provenance_to_github for that.",
2553
3509
  inputSchema: EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA,
2554
3510
  },
3511
+ {
3512
+ name: POST_LEDGER_PROVENANCE_TOOL_NAME,
3513
+ description: "Posts one ledger entry's provenance artifact (same content " +
3514
+ "export_ledger_provenance produces) as a real comment on a GitHub " +
3515
+ "PR or issue. This is the one tool in this server with a real, " +
3516
+ "visible side effect on a third-party service, not just your own " +
3517
+ "machine -- confirm with the user before calling this, the same " +
3518
+ "way you'd confirm before running a suggested install_command " +
3519
+ "(see SECURITY.md). Requires GITHUB_TOKEN (a personal access " +
3520
+ "token with repo scope) in the environment -- Pattern manages no " +
3521
+ "GitHub credential of its own. Idempotent: a repeat call for the " +
3522
+ "same ledger_entry_id/repo/issue_number detects the previously " +
3523
+ "posted comment (via a hidden marker) and returns posted: false " +
3524
+ "instead of creating a duplicate.",
3525
+ inputSchema: POST_LEDGER_PROVENANCE_INPUT_SCHEMA,
3526
+ },
3527
+ {
3528
+ name: SWEEP_LEDGER_LIVENESS_TOOL_NAME,
3529
+ description: "Batch version of check_ledger_liveness: updates live_status for " +
3530
+ "every file_path-bearing entry across an entire project (or, when " +
3531
+ "project_id is omitted, every project_id present in the ledger), " +
3532
+ "then flags dangling clusters -- groups of 2+ entries sharing a " +
3533
+ "feature_id where none of them resolved to live_status 'live'. " +
3534
+ "Pattern has no daemon or scheduler of its own (each server " +
3535
+ "invocation is transient, tied to its MCP host's lifecycle) -- " +
3536
+ "this tool is meant to be invoked by whatever external scheduler " +
3537
+ "you already have (a cron job, a CI step), not something Pattern " +
3538
+ "triggers automatically. Tested at 200 and 1,000 synthetic " +
3539
+ "entries without reintroducing search+score latency -- this is " +
3540
+ "fs stat calls, not API calls.",
3541
+ inputSchema: SWEEP_LEDGER_LIVENESS_INPUT_SCHEMA,
3542
+ },
3543
+ {
3544
+ name: BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME,
3545
+ description: "Best-effort reconstruction of snapshot_ref for ledger entries " +
3546
+ "written before that field existed (or written outside a git " +
3547
+ "repo): finds the commit that was HEAD at or just before each " +
3548
+ "entry's own timestamp. Always clearly distinguished from a real " +
3549
+ "captured snapshot_ref wherever it's rendered (export_ledger_provenance, " +
3550
+ "post_ledger_provenance_to_github) -- a rebase/force-push/history " +
3551
+ "rewrite since that time can make this approximation wrong, so " +
3552
+ "it's never presented as equivalent to a value actually captured " +
3553
+ "live. Entries that already have a real snapshot_ref are reported " +
3554
+ "but never touched. Persists every attempt (including failures) " +
3555
+ "for later lookup; never modifies ledger.jsonl itself.",
3556
+ inputSchema: BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA,
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
+ },
2555
3578
  ],
2556
3579
  }));
2557
3580
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -2717,6 +3740,70 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2717
3740
  };
2718
3741
  }
2719
3742
  }
3743
+ if (request.params.name === POST_LEDGER_PROVENANCE_TOOL_NAME) {
3744
+ const args = request.params.arguments;
3745
+ try {
3746
+ const result = await postProvenanceToGitHub(args);
3747
+ return {
3748
+ content: [{ type: "text", text: JSON.stringify(result) }],
3749
+ };
3750
+ }
3751
+ catch (err) {
3752
+ const message = err instanceof Error ? err.message : String(err);
3753
+ return {
3754
+ content: [{ type: "text", text: `Error: ${message}` }],
3755
+ isError: true,
3756
+ };
3757
+ }
3758
+ }
3759
+ if (request.params.name === SWEEP_LEDGER_LIVENESS_TOOL_NAME) {
3760
+ const args = request.params.arguments;
3761
+ try {
3762
+ const result = sweepLedgerLiveness(args);
3763
+ return {
3764
+ content: [{ type: "text", text: JSON.stringify(result) }],
3765
+ };
3766
+ }
3767
+ catch (err) {
3768
+ const message = err instanceof Error ? err.message : String(err);
3769
+ return {
3770
+ content: [{ type: "text", text: `Error: ${message}` }],
3771
+ isError: true,
3772
+ };
3773
+ }
3774
+ }
3775
+ if (request.params.name === BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME) {
3776
+ const args = request.params.arguments;
3777
+ try {
3778
+ const result = backfillLedgerSnapshotRefs(args);
3779
+ return {
3780
+ content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...result }) }],
3781
+ };
3782
+ }
3783
+ catch (err) {
3784
+ const message = err instanceof Error ? err.message : String(err);
3785
+ return {
3786
+ content: [{ type: "text", text: `Error: ${message}` }],
3787
+ isError: true,
3788
+ };
3789
+ }
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
+ }
2720
3807
  throw new Error(`Unknown tool: ${request.params.name}`);
2721
3808
  });
2722
3809
  async function main() {