pattern-mcp 0.2.0 → 0.4.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 +503 -27
- package/dist/index.js +874 -37
- package/dist/telemetry.js +222 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -2,26 +2,39 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Pattern
|
|
4
4
|
*
|
|
5
|
-
* MCP server exposing
|
|
5
|
+
* MCP server exposing tools built around one judgment: whether a UI
|
|
6
6
|
* component need should be met with an existing shadcn/ui, 21st.dev, or
|
|
7
7
|
* ReUI (reui.io) component, or requires a custom build guided by a
|
|
8
|
-
* real-app reference from Mobbin.
|
|
9
|
-
* confirmed decision to local per-project memory (see MEMORY_PATH below),
|
|
10
|
-
* which recommend_component can optionally read back (via project_id) as
|
|
11
|
-
* consistency context for a future call -- never as a cached verdict;
|
|
12
|
-
* coverage is still scored fresh every time.
|
|
8
|
+
* real-app reference from Mobbin.
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* Two separate local stores back this, with two different rules:
|
|
11
|
+
* - `record_component_decision` appends a confirmed decision to local
|
|
12
|
+
* per-project memory (see MEMORY_PATH below), which recommend_component
|
|
13
|
+
* can optionally read back (via project_id) as consistency context for
|
|
14
|
+
* a future call -- never as a cached verdict; coverage is still scored
|
|
15
|
+
* fresh every time. Unchanged, still true.
|
|
16
|
+
* - Every recommend_component call that reaches the API instead appends
|
|
17
|
+
* to a per-project ledger (see LEDGER_PATH below). Unlike memory.json,
|
|
18
|
+
* a high-confidence ledger entry CAN be served directly on a later,
|
|
19
|
+
* matching call instead of a fresh search+score -- the one deliberate
|
|
20
|
+
* exception to "always fresh," bounded by exact component_need/domain/
|
|
21
|
+
* framework/conventions match and a staleness TTL, and always flagged
|
|
22
|
+
* via `served_from_ledger: true` in the response so nothing is silently
|
|
23
|
+
* passed off as freshly verified. See findLedgerCacheHit.
|
|
24
|
+
*
|
|
25
|
+
* The judgment logic itself (extract requirements -> search -> score real
|
|
26
|
+
* code -> threshold into a verdict) is delegated to a single Anthropic API
|
|
27
|
+
* call with the server-side web_search tool enabled, so the same reasoning
|
|
17
28
|
* this project validated by hand in conversation is what runs here.
|
|
18
29
|
*/
|
|
19
30
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
32
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
33
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
22
34
|
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
35
|
import { homedir } from "node:os";
|
|
24
36
|
import { dirname, join } from "node:path";
|
|
37
|
+
import { captureApiError, captureRecommendation, printTelemetryNoticeOnce, shutdownTelemetry, } from "./telemetry.js";
|
|
25
38
|
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
26
39
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
27
40
|
// Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
|
|
@@ -100,6 +113,30 @@ const LOG_PATH = process.env.PATTERN_LOG_PATH ?? join(homedir(), ".pattern", "ca
|
|
|
100
113
|
// of what's in this file (see README's "no verdict caching" rule).
|
|
101
114
|
const MEMORY_PATH = process.env.PATTERN_MEMORY_PATH ?? join(homedir(), ".pattern", "memory.json");
|
|
102
115
|
const MAX_DECISIONS_PER_PROJECT = 50;
|
|
116
|
+
// Per-project judgment ledger -- distinct from both LOG_PATH and
|
|
117
|
+
// MEMORY_PATH above. Every recommend_component call that reaches the API
|
|
118
|
+
// with a project_id and lands on reason "scored" or "no_candidates_found"
|
|
119
|
+
// appends one line here (see appendLedgerEntry), unlike MEMORY_PATH which
|
|
120
|
+
// only gains an entry when record_component_decision is explicitly called.
|
|
121
|
+
// Unlike MEMORY_PATH, this file's entries CAN produce a cached verdict on a
|
|
122
|
+
// later call (see findLedgerCacheHit) -- the one deliberate exception to
|
|
123
|
+
// this project's "coverage is scored fresh every time" rule, bounded by
|
|
124
|
+
// exact component_need/domain/framework/conventions match, confidence
|
|
125
|
+
// "high", and LEDGER_TTL_DAYS staleness, and always flagged in the
|
|
126
|
+
// response via served_from_ledger so nothing is silently passed off as
|
|
127
|
+
// fresh. Same homedir/project_id-keyed convention as LOG_PATH/MEMORY_PATH,
|
|
128
|
+
// not a repo-root file -- this server has no concept of "which repo" a
|
|
129
|
+
// call is about, only the caller-supplied project_id string.
|
|
130
|
+
const LEDGER_PATH = process.env.PATTERN_LEDGER_PATH ?? join(homedir(), ".pattern", "ledger.jsonl");
|
|
131
|
+
const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
|
|
132
|
+
// Kill switch for the cache-hit short-circuit specifically -- does NOT
|
|
133
|
+
// disable the ledger itself. Entries still get written and read_ledger
|
|
134
|
+
// still works either way; this only controls whether judgeComponent is
|
|
135
|
+
// allowed to skip a fresh search+score on a matching entry. Set
|
|
136
|
+
// PATTERN_NO_LEDGER_CACHE_HIT (any truthy value) to revert to "every
|
|
137
|
+
// recommend_component call always scores fresh" without removing any
|
|
138
|
+
// ledger code -- flip it back off (unset the var) to re-enable.
|
|
139
|
+
const LEDGER_CACHE_HIT_ENABLED = !process.env.PATTERN_NO_LEDGER_CACHE_HIT;
|
|
103
140
|
// $/1M tokens, checked against the Anthropic pricing page rather than
|
|
104
141
|
// recalled from training data (rates drift). Both current and legacy
|
|
105
142
|
// Haiku 4.5 model-id spellings are listed since PATTERN_MODEL is
|
|
@@ -191,12 +228,16 @@ export function computeBreakdownMs(t) {
|
|
|
191
228
|
};
|
|
192
229
|
}
|
|
193
230
|
function buildMeta(timings, usage) {
|
|
231
|
+
const fresh = usage.input_tokens ?? 0;
|
|
232
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
233
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
194
234
|
return {
|
|
195
235
|
total_ms: timings.scoreEndMs - timings.requestStartMs,
|
|
196
236
|
breakdown_ms: computeBreakdownMs(timings),
|
|
197
237
|
tokens_used: {
|
|
198
|
-
input:
|
|
238
|
+
input: fresh + cacheWrite + cacheRead,
|
|
199
239
|
output: usage.output_tokens ?? 0,
|
|
240
|
+
input_breakdown: { fresh, cache_write: cacheWrite, cache_read: cacheRead },
|
|
200
241
|
},
|
|
201
242
|
estimated_cost_usd: estimateCostUsd(usage, MODEL),
|
|
202
243
|
};
|
|
@@ -338,6 +379,9 @@ async function streamAnthropicMessage(body) {
|
|
|
338
379
|
const TOOL_NAME = "recommend_component";
|
|
339
380
|
const RECORD_DECISION_TOOL_NAME = "record_component_decision";
|
|
340
381
|
const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
|
|
382
|
+
const READ_LEDGER_TOOL_NAME = "read_ledger";
|
|
383
|
+
const REPORT_BUILD_COST_TOOL_NAME = "report_build_cost";
|
|
384
|
+
const REPORT_OUTCOME_PROXY_TOOL_NAME = "report_outcome_proxy";
|
|
341
385
|
const INPUT_SCHEMA = {
|
|
342
386
|
type: "object",
|
|
343
387
|
properties: {
|
|
@@ -367,8 +411,14 @@ const INPUT_SCHEMA = {
|
|
|
367
411
|
"belongs to. When provided, past decisions confirmed via " +
|
|
368
412
|
"record_component_decision for this same project_id are surfaced to " +
|
|
369
413
|
"the model as a consistency signal (never a rule -- a genuinely " +
|
|
370
|
-
"better match found in this search still wins).
|
|
371
|
-
"
|
|
414
|
+
"better match found in this search still wins). Separately, this call " +
|
|
415
|
+
"may also be served directly from a recent, high-confidence prior " +
|
|
416
|
+
"recommend_component judgment for this same project_id/component_need/" +
|
|
417
|
+
"domain/framework/existing_stack, skipping search+score entirely -- " +
|
|
418
|
+
"check the response for served_from_ledger: true, which is always set " +
|
|
419
|
+
"when this happens; see read_ledger to inspect what's stored. Omit " +
|
|
420
|
+
"project_id to skip both lookups entirely; neither ever falls back to " +
|
|
421
|
+
"a shared/global bucket.",
|
|
372
422
|
},
|
|
373
423
|
checklist: {
|
|
374
424
|
type: "array",
|
|
@@ -381,6 +431,16 @@ const INPUT_SCHEMA = {
|
|
|
381
431
|
"today's default behavior: recommend_component extracts its own " +
|
|
382
432
|
"checklist internally, unchanged.",
|
|
383
433
|
},
|
|
434
|
+
feature_id: {
|
|
435
|
+
type: "string",
|
|
436
|
+
description: "Optional. A stable identifier for the feature this component need " +
|
|
437
|
+
"belongs to (e.g. a ticket id or branch name), used to roll up this " +
|
|
438
|
+
"call's cost with a later report_build_cost call for the same " +
|
|
439
|
+
"feature. Omit to have one derived deterministically from " +
|
|
440
|
+
"project_id+component_need -- repeat calls for the same feature " +
|
|
441
|
+
"then land under the same id automatically, with no coordination " +
|
|
442
|
+
"needed between calls. Only meaningful together with project_id.",
|
|
443
|
+
},
|
|
384
444
|
},
|
|
385
445
|
required: ["component_need", "domain", "framework"],
|
|
386
446
|
};
|
|
@@ -428,9 +488,110 @@ const RECORD_DECISION_INPUT_SCHEMA = {
|
|
|
428
488
|
type: "string",
|
|
429
489
|
description: "Optional. ISO 8601 timestamp of the decision. Defaults to the current time if omitted.",
|
|
430
490
|
},
|
|
491
|
+
time_saved_minutes: {
|
|
492
|
+
type: "number",
|
|
493
|
+
description: "Optional. Your own estimate, in minutes, of the time this decision saved you by having " +
|
|
494
|
+
"Pattern's verdict instead of researching candidates and judging fit yourself from scratch. " +
|
|
495
|
+
"This is self-reported by the calling agent -- Pattern has no way to measure a counterfactual, " +
|
|
496
|
+
"so it never computes this itself (unlike _meta, which is Pattern's own real cost/latency). " +
|
|
497
|
+
"Omit if you don't have a meaningful estimate; never guess a number just to fill the field.",
|
|
498
|
+
},
|
|
431
499
|
},
|
|
432
500
|
required: ["project_id", "component_need", "action", "source"],
|
|
433
501
|
};
|
|
502
|
+
const READ_LEDGER_INPUT_SCHEMA = {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
project_id: {
|
|
506
|
+
type: "string",
|
|
507
|
+
description: "The project_id used in prior recommend_component calls whose ledger entries you want to inspect.",
|
|
508
|
+
},
|
|
509
|
+
component_need: {
|
|
510
|
+
type: "string",
|
|
511
|
+
description: "Optional. Filters entries by simple keyword match against their component_need. Omit to list all entries for the project.",
|
|
512
|
+
},
|
|
513
|
+
limit: {
|
|
514
|
+
type: "number",
|
|
515
|
+
description: "Optional. Maximum number of entries to return, most recent first. Defaults to 20.",
|
|
516
|
+
},
|
|
517
|
+
feature_id: {
|
|
518
|
+
type: "string",
|
|
519
|
+
description: "Optional. Instead of the usual keyword listing, returns the full " +
|
|
520
|
+
"cost rollup for this one feature_id -- every verdict-time ledger " +
|
|
521
|
+
"entry (fresh judgments and $0 ledger cache hits) plus every " +
|
|
522
|
+
"report_build_cost record for it, with a summed total_cost_usd. " +
|
|
523
|
+
"When provided, component_need and limit are ignored.",
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
required: ["project_id"],
|
|
527
|
+
};
|
|
528
|
+
const REPORT_BUILD_COST_INPUT_SCHEMA = {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
feature_id: {
|
|
532
|
+
type: "string",
|
|
533
|
+
description: "The feature_id this build belongs to -- either one you explicitly " +
|
|
534
|
+
"passed to an earlier recommend_component call for this feature, " +
|
|
535
|
+
"or (if you didn't) the same value recommend_component would " +
|
|
536
|
+
"derive on its own: sha256(project_id + '::' + component_need, " +
|
|
537
|
+
"lowercased/trimmed) truncated to 8 hex chars. When in doubt, call " +
|
|
538
|
+
"read_ledger with just project_id and copy the feature_id off the " +
|
|
539
|
+
"relevant entry rather than re-deriving it by hand.",
|
|
540
|
+
},
|
|
541
|
+
project_id: {
|
|
542
|
+
type: "string",
|
|
543
|
+
description: "Optional but recommended. The same project_id used in the recommend_component call(s) for this feature, so read_ledger's feature_id rollup can find this record.",
|
|
544
|
+
},
|
|
545
|
+
tokens_used: {
|
|
546
|
+
type: "number",
|
|
547
|
+
description: "Optional. Total tokens spent building this feature, if you have a real number (e.g. from your own session accounting).",
|
|
548
|
+
},
|
|
549
|
+
cost_usd: {
|
|
550
|
+
type: "number",
|
|
551
|
+
description: "Total real spend, in USD, for building this feature end to end -- your own best number, not Pattern's (Pattern has no visibility past the verdict it returned).",
|
|
552
|
+
},
|
|
553
|
+
outcome: {
|
|
554
|
+
type: "string",
|
|
555
|
+
enum: ["shipped", "abandoned", "replaced_with_existing"],
|
|
556
|
+
description: "What actually happened to this build: 'shipped' it went out, " +
|
|
557
|
+
"'abandoned' the build was dropped before shipping, " +
|
|
558
|
+
"'replaced_with_existing' you started a custom build but swapped " +
|
|
559
|
+
"in an existing component instead (or vice versa).",
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
required: ["feature_id", "cost_usd", "outcome"],
|
|
563
|
+
};
|
|
564
|
+
const REPORT_OUTCOME_PROXY_INPUT_SCHEMA = {
|
|
565
|
+
type: "object",
|
|
566
|
+
properties: {
|
|
567
|
+
feature_id: {
|
|
568
|
+
type: "string",
|
|
569
|
+
description: "The feature_id this outcome data belongs to -- same value used in the feature's recommend_component/report_build_cost calls.",
|
|
570
|
+
},
|
|
571
|
+
project_id: {
|
|
572
|
+
type: "string",
|
|
573
|
+
description: "Optional but recommended. The same project_id used in this feature's other calls, so read_ledger's feature_id rollup can find this record.",
|
|
574
|
+
},
|
|
575
|
+
reworked: {
|
|
576
|
+
type: "boolean",
|
|
577
|
+
description: "Whether any of the files this feature's build touched have been modified again since the original merge -- computed by you from your own repo's git history (e.g. `git log --follow` against the file list), never guessed. Re-report this on a later check if the answer changes.",
|
|
578
|
+
},
|
|
579
|
+
days_to_rework: {
|
|
580
|
+
type: "number",
|
|
581
|
+
description: "Optional. Days between the original merge and the first rework commit, if reworked is true and you have a real date to compute from.",
|
|
582
|
+
},
|
|
583
|
+
time_to_merge_hours: {
|
|
584
|
+
type: "number",
|
|
585
|
+
description: "Hours between the first commit touching this feature's files and the commit/PR that merged it, computed from your own repo's git metadata.",
|
|
586
|
+
},
|
|
587
|
+
status_at_30d: {
|
|
588
|
+
type: "string",
|
|
589
|
+
enum: ["kept", "replaced", "removed"],
|
|
590
|
+
description: "At a ~30-day horizon post-merge: whether the component Pattern recommended still exists in the codebase, unchanged in kind ('kept'), was swapped for a different approach ('replaced'), or was deleted entirely ('removed'). Only report this once the horizon has actually passed.",
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
required: ["feature_id"],
|
|
594
|
+
};
|
|
434
595
|
// Shared between buildSystemPrompt's own step 2 and
|
|
435
596
|
// buildExtractionSystemPrompt (the extract_requirements tool's standalone
|
|
436
597
|
// prompt) -- the extraction *instructions* are one piece of text reused
|
|
@@ -466,13 +627,34 @@ Search shadcn/ui, 21st.dev, and ReUI (reui.io) for components matching the need,
|
|
|
466
627
|
If search returns zero real candidates -- not just weak matches, but nothing relevant at all (e.g. only vendor policy pages, unrelated components) -- 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.
|
|
467
628
|
|
|
468
629
|
4. SCORE COVERAGE AGAINST THE CHECKLIST
|
|
469
|
-
For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
|
|
630
|
+
For each real candidate, evaluate against the checklist using actual evidence you can find about the component's real props/structure/code -- not just its marketing description, since descriptions can claim functionality the component doesn't actually have. Mark each requirement met or not-met with a one-line reason. Compute coverage = (requirements met) / (total requirements) for the best-fitting candidate.
|
|
631
|
+
|
|
632
|
+
Before finalizing that coverage score, fetch the best-fitting candidate's own real docs/source page ONCE with the web_fetch tool -- a reserved slot exists for exactly this, separate from step 6's reference-verification budget below, so using it here will not starve that reserved budget. Re-check every requirement against what that fetched page actually says, not just the web_search snippet/description you started with -- a search result can describe functionality a component doesn't actually have, or omit a real prop/feature it does have, and only the fetched page is real evidence either way. Only fetch a URL that a real search result in step 3 actually returned -- never construct or guess one. If the fetch fails, or there's no confirmed URL to fetch, score from the web_search evidence alone and say so in the affected items' evidence text. This one candidate-verification fetch is the only exception to "no web_fetch in steps 2-5" -- it remains reserved for step 6's reference deep-link check otherwise.
|
|
470
633
|
|
|
471
634
|
5. APPLY VERDICT THRESHOLDS
|
|
472
635
|
coverage >= 80% -> verdict "use_existing", confidence "high"
|
|
473
636
|
coverage 40-79% -> verdict "use_existing", confidence "low" (list the missing fields)
|
|
474
637
|
coverage < 40% -> verdict "custom_build"
|
|
475
638
|
|
|
639
|
+
Before finalizing a "high" confidence use_existing verdict, check for an OVERSIZED MATCH: a
|
|
640
|
+
candidate can satisfy every checklist item and still be the wrong call if its real capabilities
|
|
641
|
+
(dependency footprint, feature surface -- e.g. virtualization, multi-column sort/group/pivot,
|
|
642
|
+
complex range logic) substantially exceed what the stated project scope actually needs. This is a
|
|
643
|
+
distinct check from coverage -- a component can be 100% covered and still be an Oversized Match.
|
|
644
|
+
Weigh it against what the component_need and domain actually state about scale (e.g. "no need for
|
|
645
|
+
column reordering, grouping, or pivoting," a stated row/item count, "starter tier"): a virtualized,
|
|
646
|
+
sortable/groupable/pivotable data-grid system recommended for a plain list of a few thousand rows or
|
|
647
|
+
fewer is an Oversized Match; the same system recommended for a need that actually states large or
|
|
648
|
+
unbounded scale is not.
|
|
649
|
+
|
|
650
|
+
Report this via two top-level fields, "oversized_match" (boolean) and "oversized_match_note" (string,
|
|
651
|
+
required when true): set oversized_match true and name the specific excess capability in the note
|
|
652
|
+
(e.g. "ships with row virtualization and multi-column grouping/pivoting, neither needed here"), not a
|
|
653
|
+
vague "this may be more than needed." Do this regardless of what you also write for "confidence" below
|
|
654
|
+
-- the server derives the actual confidence cap from oversized_match deterministically, the same way
|
|
655
|
+
it recomputes coverage itself rather than trusting your arithmetic, so don't rely on your own
|
|
656
|
+
"confidence" value alone to carry this signal.
|
|
657
|
+
|
|
476
658
|
If the verdict is use_existing, include "component_description": 1-2 sentences of plain-language description of what the recommended component actually does and looks like, grounded in what you found during search -- specific enough that it could only come from reading the actual search result, not a generic guess at what a component like this probably looks like. E.g. "A 3-column pricing card with a highlighted middle tier, monthly/annual toggle at the top, and a CTA button pinned to the bottom of each card," not "A well-designed pricing component." Same grounding standard as reference_description below: base it on real evidence, not marketing copy or a template description.
|
|
477
659
|
|
|
478
660
|
"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.
|
|
@@ -510,6 +692,8 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
|
|
|
510
692
|
"computed_at": "<today's date, ISO format>",
|
|
511
693
|
"requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
|
|
512
694
|
"coverage": "string like '5/7 (71%)'" | null,
|
|
695
|
+
"oversized_match": true|false | omit if verdict is not use_existing,
|
|
696
|
+
"oversized_match_note": "string, required when oversized_match is true" | omit otherwise,
|
|
513
697
|
"recommendation": {
|
|
514
698
|
"source": "string or null",
|
|
515
699
|
"install_command": "string or null",
|
|
@@ -592,10 +776,13 @@ async function runSinglePass(input) {
|
|
|
592
776
|
};
|
|
593
777
|
}
|
|
594
778
|
// Coverage still computes fresh below regardless of what this finds --
|
|
595
|
-
// memory only ever adds context
|
|
596
|
-
// circuits search/scoring or gets
|
|
597
|
-
// project_id -> no lookup at all, not a
|
|
598
|
-
// getPastDecisions).
|
|
779
|
+
// memory (MEMORY_PATH/record_component_decision) only ever adds context
|
|
780
|
+
// to the user message, it never short-circuits search/scoring or gets
|
|
781
|
+
// treated as a cached verdict. No project_id -> no lookup at all, not a
|
|
782
|
+
// shared/global fallback (see getPastDecisions). This is distinct from
|
|
783
|
+
// the ledger cache-hit check in judgeComponent, which CAN skip this
|
|
784
|
+
// entire function on a matching high-confidence entry -- that check
|
|
785
|
+
// happens one level up, before runSinglePass is ever called.
|
|
599
786
|
const pastDecisions = input.project_id ? getPastDecisions(input.project_id) : [];
|
|
600
787
|
const pastDecisionsBlock = pastDecisions.length === 0
|
|
601
788
|
? ""
|
|
@@ -668,13 +855,20 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
668
855
|
{
|
|
669
856
|
type: "web_fetch_20250910",
|
|
670
857
|
name: "web_fetch",
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
|
|
858
|
+
// 3 reserved slots, same "reserve, don't let an earlier step
|
|
859
|
+
// starve a later one's budget" pattern as web_search's
|
|
860
|
+
// SEARCH_BUDGET + 2 above: 1 for step 4's single candidate-
|
|
861
|
+
// verification fetch (re-checking the best-fitting candidate's
|
|
862
|
+
// real docs against the checklist, added to catch evidence
|
|
863
|
+
// errors search-snippet-only scoring was producing -- confirmed
|
|
864
|
+
// live: an invented feature claim and a missed real one, both on
|
|
865
|
+
// the same case, both from trusting search snippets over the
|
|
866
|
+
// actual page), and 2 for step 6's Mobbin + Figma Community
|
|
867
|
+
// deep-link checks (exactly one fetch per reference source,
|
|
868
|
+
// never more than once per source). Not reserved from the
|
|
869
|
+
// web_search budget above; this is a separate tool with its own
|
|
870
|
+
// separate cap.
|
|
871
|
+
max_uses: 3,
|
|
678
872
|
// Category/browse pages can be large, and all we need from them
|
|
679
873
|
// is a permalink, not the full page -- caps token cost of a
|
|
680
874
|
// fetch that turns out not to have a deep link after all.
|
|
@@ -804,6 +998,7 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
|
|
|
804
998
|
// other enforce* functions above.
|
|
805
999
|
parsed.checklist_source = checklistSource;
|
|
806
1000
|
parsed._meta = buildMeta(data.timings, data.usage);
|
|
1001
|
+
parsed._meta.scoring_fetch = findScoringFetch(fetchCallDetails);
|
|
807
1002
|
// Same "server-side, not just prompt instruction" policy as the rest of
|
|
808
1003
|
// this file: a past_decision_signal is only trusted when this call
|
|
809
1004
|
// actually had past-decision context to consider. Strips a fabricated
|
|
@@ -1007,6 +1202,12 @@ function recordDecision(input) {
|
|
|
1007
1202
|
action: input.action,
|
|
1008
1203
|
source: input.source,
|
|
1009
1204
|
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
1205
|
+
// Finite-number guard only -- no range/sanity clamp, since a caller's
|
|
1206
|
+
// own estimate isn't Pattern's to second-guess. NaN/Infinity would
|
|
1207
|
+
// corrupt memory.json's JSON on write, so those alone are rejected.
|
|
1208
|
+
time_saved_minutes: typeof input.time_saved_minutes === "number" && Number.isFinite(input.time_saved_minutes)
|
|
1209
|
+
? input.time_saved_minutes
|
|
1210
|
+
: undefined,
|
|
1010
1211
|
};
|
|
1011
1212
|
const memory = readMemory();
|
|
1012
1213
|
const existing = memory[input.project_id] ?? [];
|
|
@@ -1022,6 +1223,269 @@ export function getPastDecisions(projectId) {
|
|
|
1022
1223
|
const memory = readMemory();
|
|
1023
1224
|
return memory[projectId] ?? [];
|
|
1024
1225
|
}
|
|
1226
|
+
function hashConventions(existingStack) {
|
|
1227
|
+
if (!existingStack)
|
|
1228
|
+
return null;
|
|
1229
|
+
return createHash("sha256").update(existingStack).digest("hex").slice(0, 16);
|
|
1230
|
+
}
|
|
1231
|
+
// Stable id for rolling up cost across recommend_component (verdict) and
|
|
1232
|
+
// report_build_cost (build) records for the "same" feature. A
|
|
1233
|
+
// caller-supplied id always wins (their own tracking -- a ticket id,
|
|
1234
|
+
// branch name, whatever is stable on their side); otherwise derive
|
|
1235
|
+
// deterministically from project_id+component_need so repeat calls for the
|
|
1236
|
+
// same feature land under the same key across sessions with no
|
|
1237
|
+
// coordination required between recommend_component and report_build_cost.
|
|
1238
|
+
function deriveFeatureId(componentNeed, projectId, provided) {
|
|
1239
|
+
if (provided && provided.trim())
|
|
1240
|
+
return provided.trim();
|
|
1241
|
+
return createHash("sha256")
|
|
1242
|
+
.update(`${projectId}::${componentNeed.trim().toLowerCase()}`)
|
|
1243
|
+
.digest("hex")
|
|
1244
|
+
.slice(0, 8);
|
|
1245
|
+
}
|
|
1246
|
+
// Same "missing/malformed collapses to empty" philosophy as readMemory,
|
|
1247
|
+
// but line-oriented (JSONL) rather than whole-file JSON -- a single
|
|
1248
|
+
// corrupted line (e.g. a hand-edited file, or a write that got cut off)
|
|
1249
|
+
// is skipped rather than failing the whole read.
|
|
1250
|
+
function readLedgerEntries(projectId) {
|
|
1251
|
+
let raw;
|
|
1252
|
+
try {
|
|
1253
|
+
raw = readFileSync(LEDGER_PATH, "utf8");
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
return [];
|
|
1257
|
+
}
|
|
1258
|
+
const entries = [];
|
|
1259
|
+
for (const line of raw.split("\n")) {
|
|
1260
|
+
if (!line.trim())
|
|
1261
|
+
continue;
|
|
1262
|
+
try {
|
|
1263
|
+
const parsed = JSON.parse(line);
|
|
1264
|
+
if (parsed && typeof parsed === "object" && parsed.project_id === projectId) {
|
|
1265
|
+
entries.push(parsed);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
catch {
|
|
1269
|
+
// skip malformed line
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return entries;
|
|
1273
|
+
}
|
|
1274
|
+
// The only entry point that writes ledger.jsonl. Validates every
|
|
1275
|
+
// candidate against the DistilledCandidate boundary before it ever touches
|
|
1276
|
+
// disk -- a raw object reaching here throws rather than silently
|
|
1277
|
+
// persisting (see assertDistilledCandidateShape).
|
|
1278
|
+
function appendLedgerEntry(entry) {
|
|
1279
|
+
for (const candidate of entry.candidates_evaluated) {
|
|
1280
|
+
assertDistilledCandidateShape(candidate);
|
|
1281
|
+
}
|
|
1282
|
+
mkdirSync(dirname(LEDGER_PATH), { recursive: true });
|
|
1283
|
+
appendFileSync(LEDGER_PATH, JSON.stringify(entry) + "\n", "utf8");
|
|
1284
|
+
}
|
|
1285
|
+
// Verdict-serving match: deliberately stricter than findLedgerMatches
|
|
1286
|
+
// below (exact component_need/domain/framework, not keyword overlap)
|
|
1287
|
+
// since this decides whether a fresh API call gets skipped entirely, not
|
|
1288
|
+
// just what gets listed back to a caller browsing history.
|
|
1289
|
+
function findLedgerCacheHit(input, entries) {
|
|
1290
|
+
const snapshot = hashConventions(input.existing_stack);
|
|
1291
|
+
const needLower = input.component_need.trim().toLowerCase();
|
|
1292
|
+
const ttlMs = LEDGER_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
1293
|
+
const now = Date.now();
|
|
1294
|
+
const eligible = entries.filter((e) => {
|
|
1295
|
+
if (e.component_need.trim().toLowerCase() !== needLower)
|
|
1296
|
+
return false;
|
|
1297
|
+
if (e.domain !== input.domain)
|
|
1298
|
+
return false;
|
|
1299
|
+
if (e.framework !== input.framework)
|
|
1300
|
+
return false;
|
|
1301
|
+
if (e.project_conventions_snapshot !== snapshot)
|
|
1302
|
+
return false;
|
|
1303
|
+
if (e.confidence !== "high")
|
|
1304
|
+
return false;
|
|
1305
|
+
if (e.reason !== "scored" && e.reason !== "no_candidates_found")
|
|
1306
|
+
return false;
|
|
1307
|
+
const age = now - new Date(e.timestamp).getTime();
|
|
1308
|
+
if (!Number.isFinite(age) || age > ttlMs)
|
|
1309
|
+
return false;
|
|
1310
|
+
return true;
|
|
1311
|
+
});
|
|
1312
|
+
if (eligible.length === 0)
|
|
1313
|
+
return null;
|
|
1314
|
+
return eligible.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
|
|
1315
|
+
}
|
|
1316
|
+
// Broader listing for the read_ledger tool itself -- simple keyword match
|
|
1317
|
+
// on component_need (no embeddings, per the build plan's explicit v1
|
|
1318
|
+
// scope), not the strict exact match findLedgerCacheHit needs.
|
|
1319
|
+
function findLedgerMatches(projectId, componentNeed, limit = 20) {
|
|
1320
|
+
let entries = readLedgerEntries(projectId);
|
|
1321
|
+
if (componentNeed && componentNeed.trim()) {
|
|
1322
|
+
const needle = componentNeed.trim().toLowerCase();
|
|
1323
|
+
entries = entries.filter((e) => e.component_need.toLowerCase().includes(needle));
|
|
1324
|
+
}
|
|
1325
|
+
entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1326
|
+
return entries.slice(0, limit);
|
|
1327
|
+
}
|
|
1328
|
+
// report_build_cost (cost-attribution build plan, 1.3) -- self-reported
|
|
1329
|
+
// build cost, cheapest option first, since Pattern has no visibility into
|
|
1330
|
+
// what happens after judgeComponent returns a verdict (1.4's
|
|
1331
|
+
// session-correlation fallback is a research spike only, not built here).
|
|
1332
|
+
// Stored as a second, separate JSONL file rather than mixed into
|
|
1333
|
+
// ledger.jsonl's LedgerEntry shape -- a BuildRecord has none of
|
|
1334
|
+
// LedgerEntry's verdict/coverage/candidate fields, and keeping the file
|
|
1335
|
+
// single-shape keeps read_ledger's existing output stable. Joined to
|
|
1336
|
+
// verdict records purely by feature_id, per the build plan's data model.
|
|
1337
|
+
const BUILD_LEDGER_PATH = process.env.PATTERN_BUILD_LEDGER_PATH ?? join(homedir(), ".pattern", "build_ledger.jsonl");
|
|
1338
|
+
function appendBuildRecord(record) {
|
|
1339
|
+
mkdirSync(dirname(BUILD_LEDGER_PATH), { recursive: true });
|
|
1340
|
+
appendFileSync(BUILD_LEDGER_PATH, JSON.stringify(record) + "\n", "utf8");
|
|
1341
|
+
}
|
|
1342
|
+
// Same "missing/malformed collapses to empty, one bad line skipped not
|
|
1343
|
+
// fatal" philosophy as readLedgerEntries.
|
|
1344
|
+
function readBuildRecords(featureId) {
|
|
1345
|
+
let raw;
|
|
1346
|
+
try {
|
|
1347
|
+
raw = readFileSync(BUILD_LEDGER_PATH, "utf8");
|
|
1348
|
+
}
|
|
1349
|
+
catch {
|
|
1350
|
+
return [];
|
|
1351
|
+
}
|
|
1352
|
+
const records = [];
|
|
1353
|
+
for (const line of raw.split("\n")) {
|
|
1354
|
+
if (!line.trim())
|
|
1355
|
+
continue;
|
|
1356
|
+
try {
|
|
1357
|
+
const parsed = JSON.parse(line);
|
|
1358
|
+
if (parsed && typeof parsed === "object" && parsed.feature_id === featureId) {
|
|
1359
|
+
records.push(parsed);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
catch {
|
|
1363
|
+
// skip malformed line
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return records;
|
|
1367
|
+
}
|
|
1368
|
+
function recordBuildCost(input) {
|
|
1369
|
+
const record = {
|
|
1370
|
+
id: randomUUID(),
|
|
1371
|
+
timestamp: new Date().toISOString(),
|
|
1372
|
+
project_id: input.project_id,
|
|
1373
|
+
feature_id: input.feature_id,
|
|
1374
|
+
tokens_used: typeof input.tokens_used === "number" && Number.isFinite(input.tokens_used) ? input.tokens_used : null,
|
|
1375
|
+
cost_usd: input.cost_usd,
|
|
1376
|
+
outcome: input.outcome,
|
|
1377
|
+
};
|
|
1378
|
+
appendBuildRecord(record);
|
|
1379
|
+
return record;
|
|
1380
|
+
}
|
|
1381
|
+
// The "total cost per feature is queryable" rollup task 1.5 validates
|
|
1382
|
+
// against a hand total: every verdict-time ledger entry for this
|
|
1383
|
+
// project_id+feature_id (fresh judgments and $0 cache hits alike) plus
|
|
1384
|
+
// every self-reported build record for the same feature_id. project_id is
|
|
1385
|
+
// required, same as every other read here, so this never falls back to a
|
|
1386
|
+
// shared/global bucket across projects.
|
|
1387
|
+
// report_outcome_proxy (cost-attribution build plan Phase 2, 2.1-2.3) --
|
|
1388
|
+
// self-reported, same reasoning as report_build_cost: rework-rate and
|
|
1389
|
+
// time-to-merge both require real git history, and Pattern has no
|
|
1390
|
+
// process.cwd()/repo-path concept and no filesystem access to a caller's
|
|
1391
|
+
// repo at all (see project judgment ledger's own design notes) -- rather
|
|
1392
|
+
// than giving Pattern a new git-shelling-out capability, the calling
|
|
1393
|
+
// agent (which already has real repo access) computes these off its own
|
|
1394
|
+
// `git log`/`git blame` and reports the result here. This also makes
|
|
1395
|
+
// 2.4's exclusion check true by construction: nothing on this path ever
|
|
1396
|
+
// reads coverage_pct, confidence, or any other Pattern-produced field --
|
|
1397
|
+
// there simply isn't a code path from a verdict into an outcome proxy.
|
|
1398
|
+
// Append-only like every other record here: a feature can get multiple
|
|
1399
|
+
// proxy reports over time (time_to_merge_hours right after merge,
|
|
1400
|
+
// reworked/days_to_rework on a later re-check, status_at_30d once the
|
|
1401
|
+
// horizon passes) -- readers take the latest report per field via
|
|
1402
|
+
// latestOutcomeProxy below, not a running mutation of one row.
|
|
1403
|
+
const OUTCOME_PROXY_PATH = process.env.PATTERN_OUTCOME_PROXY_PATH ?? join(homedir(), ".pattern", "outcome_proxies.jsonl");
|
|
1404
|
+
function appendOutcomeProxyRecord(record) {
|
|
1405
|
+
mkdirSync(dirname(OUTCOME_PROXY_PATH), { recursive: true });
|
|
1406
|
+
appendFileSync(OUTCOME_PROXY_PATH, JSON.stringify(record) + "\n", "utf8");
|
|
1407
|
+
}
|
|
1408
|
+
function readOutcomeProxyRecords(featureId) {
|
|
1409
|
+
let raw;
|
|
1410
|
+
try {
|
|
1411
|
+
raw = readFileSync(OUTCOME_PROXY_PATH, "utf8");
|
|
1412
|
+
}
|
|
1413
|
+
catch {
|
|
1414
|
+
return [];
|
|
1415
|
+
}
|
|
1416
|
+
const records = [];
|
|
1417
|
+
for (const line of raw.split("\n")) {
|
|
1418
|
+
if (!line.trim())
|
|
1419
|
+
continue;
|
|
1420
|
+
try {
|
|
1421
|
+
const parsed = JSON.parse(line);
|
|
1422
|
+
if (parsed && typeof parsed === "object" && parsed.feature_id === featureId) {
|
|
1423
|
+
records.push(parsed);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// skip malformed line
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return records;
|
|
1431
|
+
}
|
|
1432
|
+
// Merges every report for a feature into one view, most recent value per
|
|
1433
|
+
// field wins (not most recent record wins) -- so a status_at_30d reported
|
|
1434
|
+
// today doesn't get lost behind an unrelated reworked update reported
|
|
1435
|
+
// yesterday, and vice versa. history is still returned in full for anyone
|
|
1436
|
+
// who wants the raw timeline rather than just the merged snapshot.
|
|
1437
|
+
function latestOutcomeProxy(featureId) {
|
|
1438
|
+
const records = readOutcomeProxyRecords(featureId).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
1439
|
+
if (records.length === 0)
|
|
1440
|
+
return { merged: null, history: records };
|
|
1441
|
+
const merged = {};
|
|
1442
|
+
for (const r of records) {
|
|
1443
|
+
if (r.reworked !== undefined)
|
|
1444
|
+
merged.reworked = r.reworked;
|
|
1445
|
+
if (r.days_to_rework !== undefined)
|
|
1446
|
+
merged.days_to_rework = r.days_to_rework;
|
|
1447
|
+
if (r.time_to_merge_hours !== undefined)
|
|
1448
|
+
merged.time_to_merge_hours = r.time_to_merge_hours;
|
|
1449
|
+
if (r.status_at_30d !== undefined)
|
|
1450
|
+
merged.status_at_30d = r.status_at_30d;
|
|
1451
|
+
}
|
|
1452
|
+
return { merged, history: records };
|
|
1453
|
+
}
|
|
1454
|
+
function recordOutcomeProxy(input) {
|
|
1455
|
+
if (input.reworked === undefined &&
|
|
1456
|
+
input.days_to_rework === undefined &&
|
|
1457
|
+
input.time_to_merge_hours === undefined &&
|
|
1458
|
+
input.status_at_30d === undefined) {
|
|
1459
|
+
throw new Error("report_outcome_proxy requires at least one of reworked, days_to_rework, time_to_merge_hours, or status_at_30d.");
|
|
1460
|
+
}
|
|
1461
|
+
const record = {
|
|
1462
|
+
id: randomUUID(),
|
|
1463
|
+
timestamp: new Date().toISOString(),
|
|
1464
|
+
project_id: input.project_id,
|
|
1465
|
+
feature_id: input.feature_id,
|
|
1466
|
+
...(input.reworked !== undefined ? { reworked: input.reworked } : {}),
|
|
1467
|
+
...(input.days_to_rework !== undefined ? { days_to_rework: input.days_to_rework } : {}),
|
|
1468
|
+
...(input.time_to_merge_hours !== undefined ? { time_to_merge_hours: input.time_to_merge_hours } : {}),
|
|
1469
|
+
...(input.status_at_30d !== undefined ? { status_at_30d: input.status_at_30d } : {}),
|
|
1470
|
+
};
|
|
1471
|
+
appendOutcomeProxyRecord(record);
|
|
1472
|
+
return record;
|
|
1473
|
+
}
|
|
1474
|
+
function totalFeatureCost(projectId, featureId) {
|
|
1475
|
+
const verdictEntries = readLedgerEntries(projectId).filter((e) => e.feature_id === featureId);
|
|
1476
|
+
const buildRecords = readBuildRecords(featureId).filter((r) => !r.project_id || r.project_id === projectId);
|
|
1477
|
+
const total = verdictEntries.reduce((sum, e) => sum + (e.cost_usd ?? 0), 0) +
|
|
1478
|
+
buildRecords.reduce((sum, r) => sum + (r.cost_usd ?? 0), 0);
|
|
1479
|
+
const { merged, history } = latestOutcomeProxy(featureId);
|
|
1480
|
+
return {
|
|
1481
|
+
feature_id: featureId,
|
|
1482
|
+
verdict_entries: verdictEntries,
|
|
1483
|
+
build_records: buildRecords,
|
|
1484
|
+
total_cost_usd: Math.round(total * 10000) / 10000,
|
|
1485
|
+
outcome_proxy: merged,
|
|
1486
|
+
outcome_proxy_history: history,
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1025
1489
|
// Orchestrates the ensemble: run once, and only pay for 2 more full
|
|
1026
1490
|
// pipeline passes when the single-run result landed close enough to a
|
|
1027
1491
|
// verdict threshold that a single item's judgment swinging could flip
|
|
@@ -1049,11 +1513,120 @@ function aggregateMeta(passes) {
|
|
|
1049
1513
|
tokens_used: {
|
|
1050
1514
|
input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
|
|
1051
1515
|
output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
|
|
1516
|
+
// Only present if every pass has it -- all passes go through the same
|
|
1517
|
+
// buildMeta call site in practice, so a mix would mean something else
|
|
1518
|
+
// changed; safer to omit than to silently sum a partial set.
|
|
1519
|
+
...(metas.every((m) => m.tokens_used.input_breakdown)
|
|
1520
|
+
? {
|
|
1521
|
+
input_breakdown: {
|
|
1522
|
+
fresh: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.fresh ?? 0), 0),
|
|
1523
|
+
cache_write: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.cache_write ?? 0), 0),
|
|
1524
|
+
cache_read: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.cache_read ?? 0), 0),
|
|
1525
|
+
},
|
|
1526
|
+
}
|
|
1527
|
+
: {}),
|
|
1052
1528
|
},
|
|
1053
1529
|
estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
|
|
1054
1530
|
};
|
|
1055
1531
|
}
|
|
1532
|
+
// Builds the LedgerEntry appended after a judgment -- fresh (non-cache-hit)
|
|
1533
|
+
// or a ledger cache hit, distinguished by opts.cacheHit/opts.costUsd (a
|
|
1534
|
+
// cache hit is always real $0, a fresh call carries its own
|
|
1535
|
+
// _meta.estimated_cost_usd; callers pass that in rather than this function
|
|
1536
|
+
// reaching into result._meta itself, since the cache-hit path's synthetic
|
|
1537
|
+
// _meta shouldn't be treated as equivalent to a real one).
|
|
1538
|
+
// checklist/checklist_source come from the result itself, not input.checklist
|
|
1539
|
+
// -- that field captures what was actually scored regardless of whether the
|
|
1540
|
+
// caller pre-supplied it or this call extracted it internally.
|
|
1541
|
+
function buildLedgerEntry(input, projectId, result, opts) {
|
|
1542
|
+
const candidate = distillCandidate(result);
|
|
1543
|
+
const checklist = Array.isArray(result.requirements_checked)
|
|
1544
|
+
? result.requirements_checked.map((r) => r.requirement).filter((r) => !!r)
|
|
1545
|
+
: [];
|
|
1546
|
+
return {
|
|
1547
|
+
id: randomUUID(),
|
|
1548
|
+
timestamp: new Date().toISOString(),
|
|
1549
|
+
project_id: projectId,
|
|
1550
|
+
feature_id: deriveFeatureId(input.component_need, projectId, opts.featureId ?? input.feature_id),
|
|
1551
|
+
component_need: input.component_need,
|
|
1552
|
+
domain: input.domain,
|
|
1553
|
+
framework: input.framework,
|
|
1554
|
+
checklist,
|
|
1555
|
+
checklist_source: result.checklist_source ?? "extracted",
|
|
1556
|
+
candidates_evaluated: candidate ? [candidate] : [],
|
|
1557
|
+
verdict: result.verdict,
|
|
1558
|
+
chosen_candidate: candidate?.name ?? null,
|
|
1559
|
+
confidence: result.confidence,
|
|
1560
|
+
reason: result.reason,
|
|
1561
|
+
coverage: result.coverage ?? null,
|
|
1562
|
+
cost_usd: opts.costUsd,
|
|
1563
|
+
cache_hit: opts.cacheHit,
|
|
1564
|
+
project_conventions_snapshot: hashConventions(input.existing_stack),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1056
1567
|
async function judgeComponent(input) {
|
|
1568
|
+
// The one deliberate exception to "coverage is scored fresh every time"
|
|
1569
|
+
// (see file header and runSinglePass's memory-lookup comment) -- bounded
|
|
1570
|
+
// by exact component_need/domain/framework/conventions match, confidence
|
|
1571
|
+
// "high", and LEDGER_TTL_DAYS staleness. Checked before the skip-list
|
|
1572
|
+
// fast-path so a skip-list primitive never bothers with a ledger read.
|
|
1573
|
+
// Gated by LEDGER_CACHE_HIT_ENABLED (PATTERN_NO_LEDGER_CACHE_HIT) so the
|
|
1574
|
+
// "always fresh" behavior can be restored without removing this code.
|
|
1575
|
+
const ledgerCacheHit = LEDGER_CACHE_HIT_ENABLED && !isSkipListMatch(input.component_need) && input.project_id
|
|
1576
|
+
? findLedgerCacheHit(input, readLedgerEntries(input.project_id))
|
|
1577
|
+
: null;
|
|
1578
|
+
if (ledgerCacheHit) {
|
|
1579
|
+
console.error(JSON.stringify({
|
|
1580
|
+
diagnostic: "ledger_cache_hit",
|
|
1581
|
+
project_id: input.project_id,
|
|
1582
|
+
ledger_entry_id: ledgerCacheHit.id,
|
|
1583
|
+
original_timestamp: ledgerCacheHit.timestamp,
|
|
1584
|
+
}));
|
|
1585
|
+
const candidate = ledgerCacheHit.candidates_evaluated[0] ?? null;
|
|
1586
|
+
const result = {
|
|
1587
|
+
verdict: ledgerCacheHit.verdict,
|
|
1588
|
+
confidence: ledgerCacheHit.confidence,
|
|
1589
|
+
reason: "ledger_cache_hit",
|
|
1590
|
+
coverage: ledgerCacheHit.coverage,
|
|
1591
|
+
requirements_checked: null,
|
|
1592
|
+
recommendation: candidate
|
|
1593
|
+
? { source: candidate.source, install_command: null, component_description: candidate.name, reference: null }
|
|
1594
|
+
: null,
|
|
1595
|
+
ensemble: { triggered: false },
|
|
1596
|
+
checklist_source: ledgerCacheHit.checklist_source,
|
|
1597
|
+
served_from_ledger: true,
|
|
1598
|
+
ledger_entry_id: ledgerCacheHit.id,
|
|
1599
|
+
original_verdict_timestamp: ledgerCacheHit.timestamp,
|
|
1600
|
+
_meta: {
|
|
1601
|
+
total_ms: 1,
|
|
1602
|
+
breakdown_ms: { extract: 1, search: 0, score: 0 },
|
|
1603
|
+
tokens_used: { input: 0, output: 0 },
|
|
1604
|
+
estimated_cost_usd: 0,
|
|
1605
|
+
},
|
|
1606
|
+
};
|
|
1607
|
+
captureRecommendation({
|
|
1608
|
+
projectId: input.project_id,
|
|
1609
|
+
verdict: result.verdict,
|
|
1610
|
+
confidence: result.confidence,
|
|
1611
|
+
reason: result.reason,
|
|
1612
|
+
ensembleTriggered: false,
|
|
1613
|
+
estimatedCostUsd: 0,
|
|
1614
|
+
servedFromLedger: true,
|
|
1615
|
+
});
|
|
1616
|
+
// Cost-attribution build plan, 1.1: log feature_id on every ledger
|
|
1617
|
+
// write, cache hit included -- not just fresh judgments -- so a
|
|
1618
|
+
// feature's total cost rolls up correctly even when most of its later
|
|
1619
|
+
// calls cost $0 via this exact short-circuit. Inherits the matched
|
|
1620
|
+
// entry's feature_id unless this call explicitly supplies its own.
|
|
1621
|
+
if (input.project_id) {
|
|
1622
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, result, {
|
|
1623
|
+
costUsd: 0,
|
|
1624
|
+
cacheHit: true,
|
|
1625
|
+
featureId: input.feature_id ?? ledgerCacheHit.feature_id,
|
|
1626
|
+
}));
|
|
1627
|
+
}
|
|
1628
|
+
return JSON.stringify(result);
|
|
1629
|
+
}
|
|
1057
1630
|
// Session cap and local logging both apply only to calls that actually
|
|
1058
1631
|
// reach the API -- skip-list hits never do, so both are excluded here
|
|
1059
1632
|
// on the same condition rather than counted/logged and refunded.
|
|
@@ -1075,6 +1648,23 @@ async function judgeComponent(input) {
|
|
|
1075
1648
|
first.result.ensemble = { triggered: false };
|
|
1076
1649
|
if (reachesApi)
|
|
1077
1650
|
logCall(input, first.result);
|
|
1651
|
+
if (reachesApi && input.project_id && (first.result.reason === "scored" || first.result.reason === "no_candidates_found")) {
|
|
1652
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, first.result, {
|
|
1653
|
+
costUsd: first.result._meta?.estimated_cost_usd ?? 0,
|
|
1654
|
+
cacheHit: false,
|
|
1655
|
+
}));
|
|
1656
|
+
}
|
|
1657
|
+
if (reachesApi) {
|
|
1658
|
+
captureRecommendation({
|
|
1659
|
+
projectId: input.project_id,
|
|
1660
|
+
verdict: first.result.verdict,
|
|
1661
|
+
confidence: first.result.confidence,
|
|
1662
|
+
reason: first.result.reason,
|
|
1663
|
+
ensembleTriggered: false,
|
|
1664
|
+
estimatedCostUsd: first.result._meta?.estimated_cost_usd ?? null,
|
|
1665
|
+
servedFromLedger: false,
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1078
1668
|
return JSON.stringify(first.result);
|
|
1079
1669
|
}
|
|
1080
1670
|
console.error(JSON.stringify({
|
|
@@ -1082,13 +1672,37 @@ async function judgeComponent(input) {
|
|
|
1082
1672
|
reason: first.result.reason,
|
|
1083
1673
|
coverage: first.result.coverage,
|
|
1084
1674
|
}));
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1675
|
+
// Adaptive escalation: run only a 2nd pass first. A binary verdict
|
|
1676
|
+
// (use_existing | custom_build) can only tie at 2 passes, never at 3 --
|
|
1677
|
+
// so we escalate to a 3rd pass ONLY on that 1/1 tie, which is exactly
|
|
1678
|
+
// the case that actually needs a tie-break. When the 2nd pass agrees
|
|
1679
|
+
// with the 1st, that agreement is itself the answer and a 3rd pass
|
|
1680
|
+
// would just spend real API cost confirming what's already settled.
|
|
1681
|
+
// This does not touch the correctness guarantee for genuine
|
|
1682
|
+
// disagreement -- it still always resolves via an odd-numbered
|
|
1683
|
+
// majority vote, same as the flat 3-run version this replaces.
|
|
1684
|
+
const second = await runSinglePass(input);
|
|
1685
|
+
let passes = [first, second].filter((p) => p.ok);
|
|
1686
|
+
let verdicts = passes.map((p) => p.result.verdict);
|
|
1687
|
+
let counts = new Map();
|
|
1089
1688
|
for (const v of verdicts)
|
|
1090
1689
|
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
1091
|
-
|
|
1690
|
+
let sortedCounts = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
1691
|
+
const isTwoWayTie = passes.length === 2 && sortedCounts.length === 2 && sortedCounts[0][1] === sortedCounts[1][1];
|
|
1692
|
+
if (isTwoWayTie) {
|
|
1693
|
+
console.error(JSON.stringify({
|
|
1694
|
+
diagnostic: "ensemble_tie_escalated",
|
|
1695
|
+
runs: verdicts,
|
|
1696
|
+
}));
|
|
1697
|
+
const third = await runSinglePass(input);
|
|
1698
|
+
passes = [first, second, third].filter((p) => p.ok);
|
|
1699
|
+
verdicts = passes.map((p) => p.result.verdict);
|
|
1700
|
+
counts = new Map();
|
|
1701
|
+
for (const v of verdicts)
|
|
1702
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
1703
|
+
sortedCounts = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
1704
|
+
}
|
|
1705
|
+
const [majorityVerdict, majorityCount] = sortedCounts[0];
|
|
1092
1706
|
const agreement = `${majorityCount}/${passes.length}`;
|
|
1093
1707
|
// Use a pass whose own verdict already matches the majority as the base
|
|
1094
1708
|
// for everything else in the response (recommendation, coverage,
|
|
@@ -1111,7 +1725,16 @@ async function judgeComponent(input) {
|
|
|
1111
1725
|
if (majorityCount < passes.length)
|
|
1112
1726
|
base.confidence = "low";
|
|
1113
1727
|
base.ensemble = { triggered: true, runs: verdicts, agreement };
|
|
1728
|
+
// Captured before aggregateMeta overwrites base._meta (same object as
|
|
1729
|
+
// winningPass.result._meta) with a fresh summed-across-passes object --
|
|
1730
|
+
// scoring_fetch isn't summed like cost/tokens, it describes whichever
|
|
1731
|
+
// single pass's evidence actually became requirements_checked/
|
|
1732
|
+
// recommendation below, so it must come from the winning pass
|
|
1733
|
+
// specifically, not be dropped by aggregateMeta not knowing about it.
|
|
1734
|
+
const winningScoringFetch = winningPass.result._meta?.scoring_fetch;
|
|
1114
1735
|
base._meta = aggregateMeta(passes) ?? base._meta;
|
|
1736
|
+
if (base._meta)
|
|
1737
|
+
base._meta.scoring_fetch = winningScoringFetch;
|
|
1115
1738
|
console.error(JSON.stringify({
|
|
1116
1739
|
diagnostic: "ensemble_decision",
|
|
1117
1740
|
runs: verdicts,
|
|
@@ -1123,6 +1746,21 @@ async function judgeComponent(input) {
|
|
|
1123
1746
|
// reachable for calls that passed the skip-list check above -- always
|
|
1124
1747
|
// reachesApi === true here, no guard needed.
|
|
1125
1748
|
logCall(input, base);
|
|
1749
|
+
if (input.project_id && (base.reason === "scored" || base.reason === "no_candidates_found")) {
|
|
1750
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, base, {
|
|
1751
|
+
costUsd: base._meta?.estimated_cost_usd ?? 0,
|
|
1752
|
+
cacheHit: false,
|
|
1753
|
+
}));
|
|
1754
|
+
}
|
|
1755
|
+
captureRecommendation({
|
|
1756
|
+
projectId: input.project_id,
|
|
1757
|
+
verdict: base.verdict,
|
|
1758
|
+
confidence: base.confidence,
|
|
1759
|
+
reason: base.reason,
|
|
1760
|
+
ensembleTriggered: true,
|
|
1761
|
+
estimatedCostUsd: base._meta?.estimated_cost_usd ?? null,
|
|
1762
|
+
servedFromLedger: false,
|
|
1763
|
+
});
|
|
1126
1764
|
return JSON.stringify(base);
|
|
1127
1765
|
}
|
|
1128
1766
|
// The model's stated `coverage` string doesn't always match its own
|
|
@@ -1177,6 +1815,38 @@ export function parseCoveragePercent(coverage) {
|
|
|
1177
1815
|
}
|
|
1178
1816
|
return null;
|
|
1179
1817
|
}
|
|
1818
|
+
const ALLOWED_DISTILLED_CANDIDATE_KEYS = new Set(["source", "name", "url", "coverage_pct"]);
|
|
1819
|
+
// Throws rather than silently stripping unknown keys -- a raw object
|
|
1820
|
+
// reaching this function is a bug (some caller skipped distillCandidate),
|
|
1821
|
+
// and failing loudly is what makes "Pattern never persists scraped source"
|
|
1822
|
+
// a checkable claim rather than a hopeful one.
|
|
1823
|
+
export function assertDistilledCandidateShape(value) {
|
|
1824
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1825
|
+
throw new Error("DistilledCandidate must be a plain object");
|
|
1826
|
+
}
|
|
1827
|
+
const keys = Object.keys(value);
|
|
1828
|
+
const extra = keys.filter((k) => !ALLOWED_DISTILLED_CANDIDATE_KEYS.has(k));
|
|
1829
|
+
if (extra.length > 0) {
|
|
1830
|
+
throw new Error(`DistilledCandidate has disallowed key(s): ${extra.join(", ")}`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
// Only ever called for verdict "use_existing" with a populated
|
|
1834
|
+
// recommendation -- custom_build has no existing candidate to distill, so
|
|
1835
|
+
// candidates_evaluated/chosen_candidate stay empty/null in the ledger for
|
|
1836
|
+
// those. `url` reuses the already fetch-verified scoring_fetch URL
|
|
1837
|
+
// (see JudgmentResult._meta.scoring_fetch) rather than inventing a second
|
|
1838
|
+
// notion of "the candidate's real page" -- if that fetch didn't happen or
|
|
1839
|
+
// failed, url is null rather than falling back to an unverified guess.
|
|
1840
|
+
export function distillCandidate(result) {
|
|
1841
|
+
if (result.verdict !== "use_existing" || !result.recommendation)
|
|
1842
|
+
return null;
|
|
1843
|
+
return {
|
|
1844
|
+
source: result.recommendation.source ?? null,
|
|
1845
|
+
name: result.recommendation.component_description ?? null,
|
|
1846
|
+
url: result._meta?.scoring_fetch?.succeeded ? result._meta.scoring_fetch.url ?? null : null,
|
|
1847
|
+
coverage_pct: parseCoveragePercent(result.coverage),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1180
1850
|
export function enforceVerdictThreshold(parsed) {
|
|
1181
1851
|
if (parsed.reason !== "scored")
|
|
1182
1852
|
return;
|
|
@@ -1187,7 +1857,26 @@ export function enforceVerdictThreshold(parsed) {
|
|
|
1187
1857
|
let correctConfidence;
|
|
1188
1858
|
if (pct >= 80) {
|
|
1189
1859
|
correctVerdict = "use_existing";
|
|
1190
|
-
|
|
1860
|
+
// Oversized Match overrides the coverage-only threshold -- a candidate
|
|
1861
|
+
// can satisfy every requirement and still be the wrong call if it's
|
|
1862
|
+
// disproportionate to the stated scope (see step 5's Oversized Match
|
|
1863
|
+
// check and the JudgmentResult.oversized_match comment). Deliberately
|
|
1864
|
+
// keyed off the model's own oversized_match flag, not its "confidence"
|
|
1865
|
+
// field -- confirmed live that the model can correctly reason through
|
|
1866
|
+
// an Oversized Match in oversized_match_note and still leave
|
|
1867
|
+
// "confidence": "high" unchanged, so that field alone can't be trusted
|
|
1868
|
+
// to carry this signal.
|
|
1869
|
+
if (parsed.oversized_match === true) {
|
|
1870
|
+
correctConfidence = "low";
|
|
1871
|
+
console.error(JSON.stringify({
|
|
1872
|
+
diagnostic: "oversized_match_confidence_capped",
|
|
1873
|
+
coverage: parsed.coverage,
|
|
1874
|
+
note: parsed.oversized_match_note ?? null,
|
|
1875
|
+
}));
|
|
1876
|
+
}
|
|
1877
|
+
else {
|
|
1878
|
+
correctConfidence = "high";
|
|
1879
|
+
}
|
|
1191
1880
|
}
|
|
1192
1881
|
else if (pct >= 40) {
|
|
1193
1882
|
correctVerdict = "use_existing";
|
|
@@ -1275,6 +1964,20 @@ export const DOMAIN_FOR_SOURCE_KEYWORD = {
|
|
|
1275
1964
|
mobbin: "mobbin.com",
|
|
1276
1965
|
figma: "figma.com",
|
|
1277
1966
|
};
|
|
1967
|
+
// Distinguishes step 4's single candidate-verification fetch from step 6's
|
|
1968
|
+
// Mobbin/Figma reference fetches -- both use the same web_fetch tool and
|
|
1969
|
+
// the same reserved budget's underlying diagnostics, so this identifies
|
|
1970
|
+
// step 4's fetch as whichever call (if any) targets a domain that ISN'T a
|
|
1971
|
+
// reference source. Diagnostic only, feeding _meta.scoring_fetch -- never
|
|
1972
|
+
// used to correct or invalidate individual requirement judgments (see that
|
|
1973
|
+
// field's own comment for why there's no safe fallback to correct to).
|
|
1974
|
+
export function findScoringFetch(fetchCallDetails) {
|
|
1975
|
+
const referenceDomains = Object.values(DOMAIN_FOR_SOURCE_KEYWORD);
|
|
1976
|
+
const candidateFetch = fetchCallDetails.find((d) => d.url && !referenceDomains.some((domain) => d.url.includes(domain)));
|
|
1977
|
+
if (!candidateFetch)
|
|
1978
|
+
return { attempted: false, succeeded: false, url: null };
|
|
1979
|
+
return { attempted: true, succeeded: candidateFetch.succeeded, url: candidateFetch.url ?? null };
|
|
1980
|
+
}
|
|
1278
1981
|
// Figma Community's own URL structure makes a "/community/file/<id>/<slug>"
|
|
1279
1982
|
// URL inherently specific to one file -- unlike Mobbin's "/explore/..."
|
|
1280
1983
|
// category pages, there's no browse-vs-specific gap to resolve here.
|
|
@@ -1438,7 +2141,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1438
2141
|
"Omit it to keep today's default behavior unchanged. The response " +
|
|
1439
2142
|
"always includes checklist_source ('extracted' | 'provided') and " +
|
|
1440
2143
|
"an internal _meta block (timing/token/cost accounting) -- neither " +
|
|
1441
|
-
"affects the verdict itself."
|
|
2144
|
+
"affects the verdict itself. Surface _meta.estimated_cost_usd to " +
|
|
2145
|
+
"the user after the call (e.g. 'that judgment cost ~$0.12'), the " +
|
|
2146
|
+
"same way install_command is shown before running -- it's real " +
|
|
2147
|
+
"spend against the user's own API key, not internal bookkeeping " +
|
|
2148
|
+
"to keep from them.",
|
|
1442
2149
|
inputSchema: INPUT_SCHEMA,
|
|
1443
2150
|
},
|
|
1444
2151
|
{
|
|
@@ -1454,7 +2161,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1454
2161
|
"heuristic based on how specific component_need is, not a " +
|
|
1455
2162
|
"calibrated signal -- treat 'low' as a hint to reread the input, " +
|
|
1456
2163
|
"not a hard error. Cheaper and faster than recommend_component " +
|
|
1457
|
-
"since it makes no search calls at all."
|
|
2164
|
+
"since it makes no search calls at all. Also returns an internal " +
|
|
2165
|
+
"_meta block -- surface _meta.estimated_cost_usd to the user " +
|
|
2166
|
+
"after the call, same as recommend_component.",
|
|
1458
2167
|
inputSchema: EXTRACT_REQUIREMENTS_INPUT_SCHEMA,
|
|
1459
2168
|
},
|
|
1460
2169
|
{
|
|
@@ -1467,9 +2176,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1467
2176
|
"calls with the same project_id will see this decision as a " +
|
|
1468
2177
|
"consistency signal, not a binding rule. Use a stable project_id " +
|
|
1469
2178
|
"(e.g. the project's directory path or name) so decisions are " +
|
|
1470
|
-
"grouped correctly and never mixed with another project's."
|
|
2179
|
+
"grouped correctly and never mixed with another project's. Pass " +
|
|
2180
|
+
"time_saved_minutes (optional) if you have a genuine estimate of how " +
|
|
2181
|
+
"much time this decision saved you -- this is your own self-reported " +
|
|
2182
|
+
"number, never computed or verified by Pattern.",
|
|
1471
2183
|
inputSchema: RECORD_DECISION_INPUT_SCHEMA,
|
|
1472
2184
|
},
|
|
2185
|
+
{
|
|
2186
|
+
name: READ_LEDGER_TOOL_NAME,
|
|
2187
|
+
description: "Lists past recommend_component judgment entries for a project_id -- " +
|
|
2188
|
+
"every call that reached the API and produced a verdict, not just " +
|
|
2189
|
+
"ones you explicitly confirmed via record_component_decision. Each " +
|
|
2190
|
+
"entry holds only distilled fields (verdict, confidence, coverage, " +
|
|
2191
|
+
"chosen candidate's source/name/url) -- never the original " +
|
|
2192
|
+
"per-requirement evidence text. Useful for auditing what Pattern has " +
|
|
2193
|
+
"already judged for a project, or for understanding why a later " +
|
|
2194
|
+
"call came back with served_from_ledger: true (see recommend_component " +
|
|
2195
|
+
"-- a high-confidence entry here, matching on component_need/domain/" +
|
|
2196
|
+
"framework/existing_stack and recent enough, can be served directly " +
|
|
2197
|
+
"instead of a fresh search+score).",
|
|
2198
|
+
inputSchema: READ_LEDGER_INPUT_SCHEMA,
|
|
2199
|
+
},
|
|
2200
|
+
{
|
|
2201
|
+
name: REPORT_BUILD_COST_TOOL_NAME,
|
|
2202
|
+
description: "Self-reports the end-to-end build cost for one feature -- call this " +
|
|
2203
|
+
"once when the build a recommend_component verdict fed into is " +
|
|
2204
|
+
"actually complete (shipped, abandoned, or replaced), not on every " +
|
|
2205
|
+
"verdict. Pattern only ever sees the cost of judging what to use; " +
|
|
2206
|
+
"everything past that -- the actual scaffold, install, or custom " +
|
|
2207
|
+
"build -- happens outside Pattern entirely, so this is the only way " +
|
|
2208
|
+
"that cost gets attributed back to the feature. Pass the same " +
|
|
2209
|
+
"feature_id you used (or that recommend_component derived) for this " +
|
|
2210
|
+
"feature's judgment call(s), so read_ledger's feature_id rollup can " +
|
|
2211
|
+
"join this record to them. This only appends a local record; it " +
|
|
2212
|
+
"never re-runs any judgment and never calls the Anthropic API.",
|
|
2213
|
+
inputSchema: REPORT_BUILD_COST_INPUT_SCHEMA,
|
|
2214
|
+
},
|
|
2215
|
+
{
|
|
2216
|
+
name: REPORT_OUTCOME_PROXY_TOOL_NAME,
|
|
2217
|
+
description: "Self-reports a value signal for one feature that is deliberately " +
|
|
2218
|
+
"independent of Pattern's own verdict -- never derive any of these " +
|
|
2219
|
+
"fields from coverage_pct, confidence, or anything else Pattern " +
|
|
2220
|
+
"returned; they only mean something if they could contradict the " +
|
|
2221
|
+
"verdict. Compute reworked/days_to_rework and time_to_merge_hours " +
|
|
2222
|
+
"from your own repo's real git history (e.g. `git log --follow` " +
|
|
2223
|
+
"against the files this feature's build touched) -- never guess " +
|
|
2224
|
+
"them. Report status_at_30d only once a real ~30-day-post-merge " +
|
|
2225
|
+
"horizon has actually passed. Safe to call more than once for the " +
|
|
2226
|
+
"same feature_id as more signal becomes available over time (e.g. " +
|
|
2227
|
+
"time_to_merge_hours right after merge, reworked on a later check, " +
|
|
2228
|
+
"status_at_30d at the 30-day mark) -- read_ledger's feature_id " +
|
|
2229
|
+
"rollup merges every report into one latest-value-per-field view. " +
|
|
2230
|
+
"This only appends a local record; it never calls the Anthropic API.",
|
|
2231
|
+
inputSchema: REPORT_OUTCOME_PROXY_INPUT_SCHEMA,
|
|
2232
|
+
},
|
|
1473
2233
|
],
|
|
1474
2234
|
}));
|
|
1475
2235
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1483,6 +2243,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1483
2243
|
}
|
|
1484
2244
|
catch (err) {
|
|
1485
2245
|
const message = err instanceof Error ? err.message : String(err);
|
|
2246
|
+
if (/Anthropic API error \d+/.test(message)) {
|
|
2247
|
+
captureApiError({ tool: TOOL_NAME, message, projectId: args.project_id });
|
|
2248
|
+
}
|
|
1486
2249
|
return {
|
|
1487
2250
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1488
2251
|
isError: true,
|
|
@@ -1511,6 +2274,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1511
2274
|
}
|
|
1512
2275
|
catch (err) {
|
|
1513
2276
|
const message = err instanceof Error ? err.message : String(err);
|
|
2277
|
+
if (/Anthropic API error \d+/.test(message)) {
|
|
2278
|
+
captureApiError({ tool: EXTRACT_REQUIREMENTS_TOOL_NAME, message });
|
|
2279
|
+
}
|
|
1514
2280
|
return {
|
|
1515
2281
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1516
2282
|
isError: true,
|
|
@@ -1538,13 +2304,84 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1538
2304
|
};
|
|
1539
2305
|
}
|
|
1540
2306
|
}
|
|
2307
|
+
if (request.params.name === READ_LEDGER_TOOL_NAME) {
|
|
2308
|
+
const args = request.params.arguments;
|
|
2309
|
+
try {
|
|
2310
|
+
if (args.feature_id) {
|
|
2311
|
+
const rollup = totalFeatureCost(args.project_id, args.feature_id);
|
|
2312
|
+
return {
|
|
2313
|
+
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...rollup }) }],
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
const entries = findLedgerMatches(args.project_id, args.component_need, args.limit);
|
|
2317
|
+
return {
|
|
2318
|
+
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, entries }) }],
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
catch (err) {
|
|
2322
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2323
|
+
return {
|
|
2324
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2325
|
+
isError: true,
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
if (request.params.name === REPORT_BUILD_COST_TOOL_NAME) {
|
|
2330
|
+
const args = request.params.arguments;
|
|
2331
|
+
try {
|
|
2332
|
+
const record = recordBuildCost(args);
|
|
2333
|
+
return {
|
|
2334
|
+
content: [{ type: "text", text: JSON.stringify({ status: "recorded", record }) }],
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
catch (err) {
|
|
2338
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2339
|
+
return {
|
|
2340
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2341
|
+
isError: true,
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (request.params.name === REPORT_OUTCOME_PROXY_TOOL_NAME) {
|
|
2346
|
+
const args = request.params.arguments;
|
|
2347
|
+
try {
|
|
2348
|
+
const record = recordOutcomeProxy(args);
|
|
2349
|
+
return {
|
|
2350
|
+
content: [{ type: "text", text: JSON.stringify({ status: "recorded", record }) }],
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
catch (err) {
|
|
2354
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2355
|
+
return {
|
|
2356
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2357
|
+
isError: true,
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
1541
2361
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
1542
2362
|
});
|
|
1543
2363
|
async function main() {
|
|
2364
|
+
printTelemetryNoticeOnce();
|
|
1544
2365
|
const transport = new StdioServerTransport();
|
|
1545
2366
|
await server.connect(transport);
|
|
2367
|
+
// Best-effort telemetry drain on clean shutdown -- no-op when telemetry
|
|
2368
|
+
// was never enabled (see src/telemetry.ts).
|
|
2369
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
2370
|
+
process.on(signal, async () => {
|
|
2371
|
+
await shutdownTelemetry();
|
|
2372
|
+
process.exit(0);
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
// Guard exists so verification scripts (e.g. verify-ledger-boundary.mjs)
|
|
2377
|
+
// can import this module's exported pure functions (distillCandidate,
|
|
2378
|
+
// assertDistilledCandidateShape, parseCoveragePercent, etc.) without also
|
|
2379
|
+
// spinning up a stdio server that blocks on stdin. Real usage (the bin
|
|
2380
|
+
// entry point, `npx pattern-mcp`) never sets this, so autostart is
|
|
2381
|
+
// unaffected.
|
|
2382
|
+
if (!process.env.PATTERN_NO_AUTOSTART) {
|
|
2383
|
+
main().catch((err) => {
|
|
2384
|
+
console.error("Fatal error starting pattern-mcp:", err);
|
|
2385
|
+
process.exit(1);
|
|
2386
|
+
});
|
|
1546
2387
|
}
|
|
1547
|
-
main().catch((err) => {
|
|
1548
|
-
console.error("Fatal error starting pattern-mcp:", err);
|
|
1549
|
-
process.exit(1);
|
|
1550
|
-
});
|