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.
- package/README.md +464 -57
- package/dist/index.js +1143 -56
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1016
|
+
${PAST_DECISION_SIGNAL_INSTRUCTIONS}
|
|
785
1017
|
|
|
786
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1382
|
-
|
|
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" (
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
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 --
|
|
1432
|
-
//
|
|
1433
|
-
// reported but never checked/recorded --
|
|
1434
|
-
// "unknown" by construction, so re-checking
|
|
1435
|
-
// grow ledger_liveness.jsonl without ever
|
|
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 (
|
|
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
|
|
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 --
|
|
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() {
|