pattern-mcp 0.3.0 → 0.5.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 +466 -5
- package/dist/index.js +762 -14
- package/dist/telemetry.js +222 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -30,10 +30,12 @@
|
|
|
30
30
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
31
31
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
32
32
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
33
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
34
|
-
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
36
|
import { homedir } from "node:os";
|
|
36
|
-
import { dirname, join } from "node:path";
|
|
37
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
38
|
+
import { captureApiError, captureRecommendation, printTelemetryNoticeOnce, shutdownTelemetry, } from "./telemetry.js";
|
|
37
39
|
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
38
40
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
39
41
|
// Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
|
|
@@ -128,6 +130,64 @@ const MAX_DECISIONS_PER_PROJECT = 50;
|
|
|
128
130
|
// call is about, only the caller-supplied project_id string.
|
|
129
131
|
const LEDGER_PATH = process.env.PATTERN_LEDGER_PATH ?? join(homedir(), ".pattern", "ledger.jsonl");
|
|
130
132
|
const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
|
|
133
|
+
// Ledger integrity + decision provenance
|
|
134
|
+
// (pattern-ledger-integrity-and-provenance-spec.md). This deliberately
|
|
135
|
+
// reverses the principle stated above report_outcome_proxy elsewhere in
|
|
136
|
+
// this file ("Pattern has no process.cwd()/repo-path concept and no
|
|
137
|
+
// filesystem access to a caller's repo at all") -- but narrowly: the only
|
|
138
|
+
// two things this grants are (1) checking whether one caller-supplied
|
|
139
|
+
// file_path still exists / still mentions a chosen_candidate
|
|
140
|
+
// (checkFileLiveStatus) and (2) reading the current commit SHA via
|
|
141
|
+
// `git rev-parse HEAD` (computeSnapshotRef). Both are read-only, both are
|
|
142
|
+
// scoped to PROJECT_ROOT (see resolveWithinRoot's traversal guard), and
|
|
143
|
+
// neither ever runs an arbitrary shell command. report_build_cost/
|
|
144
|
+
// report_outcome_proxy remain self-reported by design -- rework rate and
|
|
145
|
+
// time-to-merge need real git *history*, a materially bigger and more
|
|
146
|
+
// failure-prone surface than "does this one file exist right now" or
|
|
147
|
+
// "what commit is HEAD."
|
|
148
|
+
//
|
|
149
|
+
// Defaults to process.cwd() -- for a locally-run stdio MCP server, that's
|
|
150
|
+
// normally the consuming repo's root, since MCP hosts typically launch
|
|
151
|
+
// the server with the project directory as its working directory. When
|
|
152
|
+
// that assumption doesn't hold (or for tests), override with
|
|
153
|
+
// PATTERN_PROJECT_ROOT.
|
|
154
|
+
const PROJECT_ROOT = process.env.PATTERN_PROJECT_ROOT ?? process.cwd();
|
|
155
|
+
// Belt-and-suspenders guard against a file_path (ultimately caller-
|
|
156
|
+
// supplied, see recommend_component's input schema) that's absolute or
|
|
157
|
+
// escapes PROJECT_ROOT via "../" -- the calling agent already has real fs
|
|
158
|
+
// access to its own machine regardless, but a stray path should degrade
|
|
159
|
+
// to "unknown" rather than silently stat-ing something outside the
|
|
160
|
+
// project. Returns null (never throws) on anything that doesn't resolve
|
|
161
|
+
// cleanly inside root.
|
|
162
|
+
function resolveWithinRoot(root, relPath) {
|
|
163
|
+
if (!relPath || isAbsolute(relPath))
|
|
164
|
+
return null;
|
|
165
|
+
const resolved = resolve(root, relPath);
|
|
166
|
+
const rel = relative(root, resolved);
|
|
167
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
168
|
+
return null;
|
|
169
|
+
return resolved;
|
|
170
|
+
}
|
|
171
|
+
// Feature 2 / Decision Provenance, P0: best-effort commit SHA at
|
|
172
|
+
// ledger-write time. Never throws -- not being in a git repo, git not
|
|
173
|
+
// being installed, or the call simply timing out all degrade to null
|
|
174
|
+
// rather than failing the judgment call that triggered this write (see
|
|
175
|
+
// buildLedgerEntry). Read-only: `git rev-parse HEAD` never touches repo
|
|
176
|
+
// state.
|
|
177
|
+
function computeSnapshotRef(root) {
|
|
178
|
+
try {
|
|
179
|
+
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
180
|
+
cwd: root,
|
|
181
|
+
encoding: "utf8",
|
|
182
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
183
|
+
timeout: 2000,
|
|
184
|
+
}).trim();
|
|
185
|
+
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
131
191
|
// Kill switch for the cache-hit short-circuit specifically -- does NOT
|
|
132
192
|
// disable the ledger itself. Entries still get written and read_ledger
|
|
133
193
|
// still works either way; this only controls whether judgeComponent is
|
|
@@ -227,12 +287,16 @@ export function computeBreakdownMs(t) {
|
|
|
227
287
|
};
|
|
228
288
|
}
|
|
229
289
|
function buildMeta(timings, usage) {
|
|
290
|
+
const fresh = usage.input_tokens ?? 0;
|
|
291
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0;
|
|
292
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
230
293
|
return {
|
|
231
294
|
total_ms: timings.scoreEndMs - timings.requestStartMs,
|
|
232
295
|
breakdown_ms: computeBreakdownMs(timings),
|
|
233
296
|
tokens_used: {
|
|
234
|
-
input:
|
|
297
|
+
input: fresh + cacheWrite + cacheRead,
|
|
235
298
|
output: usage.output_tokens ?? 0,
|
|
299
|
+
input_breakdown: { fresh, cache_write: cacheWrite, cache_read: cacheRead },
|
|
236
300
|
},
|
|
237
301
|
estimated_cost_usd: estimateCostUsd(usage, MODEL),
|
|
238
302
|
};
|
|
@@ -375,6 +439,9 @@ const TOOL_NAME = "recommend_component";
|
|
|
375
439
|
const RECORD_DECISION_TOOL_NAME = "record_component_decision";
|
|
376
440
|
const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
|
|
377
441
|
const READ_LEDGER_TOOL_NAME = "read_ledger";
|
|
442
|
+
const REPORT_BUILD_COST_TOOL_NAME = "report_build_cost";
|
|
443
|
+
const REPORT_OUTCOME_PROXY_TOOL_NAME = "report_outcome_proxy";
|
|
444
|
+
const CHECK_LEDGER_LIVENESS_TOOL_NAME = "check_ledger_liveness";
|
|
378
445
|
const INPUT_SCHEMA = {
|
|
379
446
|
type: "object",
|
|
380
447
|
properties: {
|
|
@@ -424,6 +491,26 @@ const INPUT_SCHEMA = {
|
|
|
424
491
|
"today's default behavior: recommend_component extracts its own " +
|
|
425
492
|
"checklist internally, unchanged.",
|
|
426
493
|
},
|
|
494
|
+
feature_id: {
|
|
495
|
+
type: "string",
|
|
496
|
+
description: "Optional. A stable identifier for the feature this component need " +
|
|
497
|
+
"belongs to (e.g. a ticket id or branch name), used to roll up this " +
|
|
498
|
+
"call's cost with a later report_build_cost call for the same " +
|
|
499
|
+
"feature. Omit to have one derived deterministically from " +
|
|
500
|
+
"project_id+component_need -- repeat calls for the same feature " +
|
|
501
|
+
"then land under the same id automatically, with no coordination " +
|
|
502
|
+
"needed between calls. Only meaningful together with project_id.",
|
|
503
|
+
},
|
|
504
|
+
file_path: {
|
|
505
|
+
type: "string",
|
|
506
|
+
description: "Optional. Path (relative to the project root) where this component " +
|
|
507
|
+
"decision is expected to be implemented, if already known -- usually " +
|
|
508
|
+
"not known yet at this call, since the decision typically precedes " +
|
|
509
|
+
"the file existing. When provided, it's stored on the resulting " +
|
|
510
|
+
"ledger entry and check_ledger_liveness can later confirm the file " +
|
|
511
|
+
"still exists and still references chosen_candidate. Omit if unknown; " +
|
|
512
|
+
"it cannot currently be attached to an entry after the fact.",
|
|
513
|
+
},
|
|
427
514
|
},
|
|
428
515
|
required: ["component_need", "domain", "framework"],
|
|
429
516
|
};
|
|
@@ -497,6 +584,96 @@ const READ_LEDGER_INPUT_SCHEMA = {
|
|
|
497
584
|
type: "number",
|
|
498
585
|
description: "Optional. Maximum number of entries to return, most recent first. Defaults to 20.",
|
|
499
586
|
},
|
|
587
|
+
feature_id: {
|
|
588
|
+
type: "string",
|
|
589
|
+
description: "Optional. Instead of the usual keyword listing, returns the full " +
|
|
590
|
+
"cost rollup for this one feature_id -- every verdict-time ledger " +
|
|
591
|
+
"entry (fresh judgments and $0 ledger cache hits) plus every " +
|
|
592
|
+
"report_build_cost record for it, with a summed total_cost_usd. " +
|
|
593
|
+
"When provided, component_need and limit are ignored.",
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
required: ["project_id"],
|
|
597
|
+
};
|
|
598
|
+
const REPORT_BUILD_COST_INPUT_SCHEMA = {
|
|
599
|
+
type: "object",
|
|
600
|
+
properties: {
|
|
601
|
+
feature_id: {
|
|
602
|
+
type: "string",
|
|
603
|
+
description: "The feature_id this build belongs to -- either one you explicitly " +
|
|
604
|
+
"passed to an earlier recommend_component call for this feature, " +
|
|
605
|
+
"or (if you didn't) the same value recommend_component would " +
|
|
606
|
+
"derive on its own: sha256(project_id + '::' + component_need, " +
|
|
607
|
+
"lowercased/trimmed) truncated to 8 hex chars. When in doubt, call " +
|
|
608
|
+
"read_ledger with just project_id and copy the feature_id off the " +
|
|
609
|
+
"relevant entry rather than re-deriving it by hand.",
|
|
610
|
+
},
|
|
611
|
+
project_id: {
|
|
612
|
+
type: "string",
|
|
613
|
+
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.",
|
|
614
|
+
},
|
|
615
|
+
tokens_used: {
|
|
616
|
+
type: "number",
|
|
617
|
+
description: "Optional. Total tokens spent building this feature, if you have a real number (e.g. from your own session accounting).",
|
|
618
|
+
},
|
|
619
|
+
cost_usd: {
|
|
620
|
+
type: "number",
|
|
621
|
+
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).",
|
|
622
|
+
},
|
|
623
|
+
outcome: {
|
|
624
|
+
type: "string",
|
|
625
|
+
enum: ["shipped", "abandoned", "replaced_with_existing"],
|
|
626
|
+
description: "What actually happened to this build: 'shipped' it went out, " +
|
|
627
|
+
"'abandoned' the build was dropped before shipping, " +
|
|
628
|
+
"'replaced_with_existing' you started a custom build but swapped " +
|
|
629
|
+
"in an existing component instead (or vice versa).",
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
required: ["feature_id", "cost_usd", "outcome"],
|
|
633
|
+
};
|
|
634
|
+
const REPORT_OUTCOME_PROXY_INPUT_SCHEMA = {
|
|
635
|
+
type: "object",
|
|
636
|
+
properties: {
|
|
637
|
+
feature_id: {
|
|
638
|
+
type: "string",
|
|
639
|
+
description: "The feature_id this outcome data belongs to -- same value used in the feature's recommend_component/report_build_cost calls.",
|
|
640
|
+
},
|
|
641
|
+
project_id: {
|
|
642
|
+
type: "string",
|
|
643
|
+
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.",
|
|
644
|
+
},
|
|
645
|
+
reworked: {
|
|
646
|
+
type: "boolean",
|
|
647
|
+
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.",
|
|
648
|
+
},
|
|
649
|
+
days_to_rework: {
|
|
650
|
+
type: "number",
|
|
651
|
+
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.",
|
|
652
|
+
},
|
|
653
|
+
time_to_merge_hours: {
|
|
654
|
+
type: "number",
|
|
655
|
+
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.",
|
|
656
|
+
},
|
|
657
|
+
status_at_30d: {
|
|
658
|
+
type: "string",
|
|
659
|
+
enum: ["kept", "replaced", "removed"],
|
|
660
|
+
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.",
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
required: ["feature_id"],
|
|
664
|
+
};
|
|
665
|
+
const CHECK_LEDGER_LIVENESS_INPUT_SCHEMA = {
|
|
666
|
+
type: "object",
|
|
667
|
+
properties: {
|
|
668
|
+
project_id: {
|
|
669
|
+
type: "string",
|
|
670
|
+
description: "The project_id used in the recommend_component call(s) whose ledger entries you want live-checked.",
|
|
671
|
+
},
|
|
672
|
+
ledger_entry_id: {
|
|
673
|
+
type: "string",
|
|
674
|
+
description: "Optional. Check just this one entry (its id, from read_ledger) " +
|
|
675
|
+
"instead of every entry for project_id that has a file_path set.",
|
|
676
|
+
},
|
|
500
677
|
},
|
|
501
678
|
required: ["project_id"],
|
|
502
679
|
};
|
|
@@ -544,6 +721,25 @@ coverage >= 80% -> verdict "use_existing", confidence "high"
|
|
|
544
721
|
coverage 40-79% -> verdict "use_existing", confidence "low" (list the missing fields)
|
|
545
722
|
coverage < 40% -> verdict "custom_build"
|
|
546
723
|
|
|
724
|
+
Before finalizing a "high" confidence use_existing verdict, check for an OVERSIZED MATCH: a
|
|
725
|
+
candidate can satisfy every checklist item and still be the wrong call if its real capabilities
|
|
726
|
+
(dependency footprint, feature surface -- e.g. virtualization, multi-column sort/group/pivot,
|
|
727
|
+
complex range logic) substantially exceed what the stated project scope actually needs. This is a
|
|
728
|
+
distinct check from coverage -- a component can be 100% covered and still be an Oversized Match.
|
|
729
|
+
Weigh it against what the component_need and domain actually state about scale (e.g. "no need for
|
|
730
|
+
column reordering, grouping, or pivoting," a stated row/item count, "starter tier"): a virtualized,
|
|
731
|
+
sortable/groupable/pivotable data-grid system recommended for a plain list of a few thousand rows or
|
|
732
|
+
fewer is an Oversized Match; the same system recommended for a need that actually states large or
|
|
733
|
+
unbounded scale is not.
|
|
734
|
+
|
|
735
|
+
Report this via two top-level fields, "oversized_match" (boolean) and "oversized_match_note" (string,
|
|
736
|
+
required when true): set oversized_match true and name the specific excess capability in the note
|
|
737
|
+
(e.g. "ships with row virtualization and multi-column grouping/pivoting, neither needed here"), not a
|
|
738
|
+
vague "this may be more than needed." Do this regardless of what you also write for "confidence" below
|
|
739
|
+
-- the server derives the actual confidence cap from oversized_match deterministically, the same way
|
|
740
|
+
it recomputes coverage itself rather than trusting your arithmetic, so don't rely on your own
|
|
741
|
+
"confidence" value alone to carry this signal.
|
|
742
|
+
|
|
547
743
|
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.
|
|
548
744
|
|
|
549
745
|
"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.
|
|
@@ -581,6 +777,8 @@ Respond with ONLY a single JSON object, no prose before or after, no markdown co
|
|
|
581
777
|
"computed_at": "<today's date, ISO format>",
|
|
582
778
|
"requirements_checked": [ { "requirement": "string", "met": true|false, "evidence": "string" } ] | null,
|
|
583
779
|
"coverage": "string like '5/7 (71%)'" | null,
|
|
780
|
+
"oversized_match": true|false | omit if verdict is not use_existing,
|
|
781
|
+
"oversized_match_note": "string, required when oversized_match is true" | omit otherwise,
|
|
584
782
|
"recommendation": {
|
|
585
783
|
"source": "string or null",
|
|
586
784
|
"install_command": "string or null",
|
|
@@ -1115,6 +1313,140 @@ function hashConventions(existingStack) {
|
|
|
1115
1313
|
return null;
|
|
1116
1314
|
return createHash("sha256").update(existingStack).digest("hex").slice(0, 16);
|
|
1117
1315
|
}
|
|
1316
|
+
// Stable id for rolling up cost across recommend_component (verdict) and
|
|
1317
|
+
// report_build_cost (build) records for the "same" feature. A
|
|
1318
|
+
// caller-supplied id always wins (their own tracking -- a ticket id,
|
|
1319
|
+
// branch name, whatever is stable on their side); otherwise derive
|
|
1320
|
+
// deterministically from project_id+component_need so repeat calls for the
|
|
1321
|
+
// same feature land under the same key across sessions with no
|
|
1322
|
+
// coordination required between recommend_component and report_build_cost.
|
|
1323
|
+
function deriveFeatureId(componentNeed, projectId, provided) {
|
|
1324
|
+
if (provided && provided.trim())
|
|
1325
|
+
return provided.trim();
|
|
1326
|
+
return createHash("sha256")
|
|
1327
|
+
.update(`${projectId}::${componentNeed.trim().toLowerCase()}`)
|
|
1328
|
+
.digest("hex")
|
|
1329
|
+
.slice(0, 8);
|
|
1330
|
+
}
|
|
1331
|
+
// Overlay store for live-check results, same "append-only, latest-value-
|
|
1332
|
+
// per-key wins at read time, never mutate the source-of-truth line"
|
|
1333
|
+
// convention as outcome_proxies.jsonl/latestOutcomeProxy above -- a check
|
|
1334
|
+
// is a new observation, not a correction of the original ledger entry, so
|
|
1335
|
+
// ledger.jsonl itself stays untouched by it.
|
|
1336
|
+
const LEDGER_LIVENESS_PATH = process.env.PATTERN_LEDGER_LIVENESS_PATH ?? join(homedir(), ".pattern", "ledger_liveness.jsonl");
|
|
1337
|
+
function appendLedgerLivenessRecord(record) {
|
|
1338
|
+
mkdirSync(dirname(LEDGER_LIVENESS_PATH), { recursive: true });
|
|
1339
|
+
appendFileSync(LEDGER_LIVENESS_PATH, JSON.stringify(record) + "\n", "utf8");
|
|
1340
|
+
}
|
|
1341
|
+
function readLedgerLivenessRecords(ledgerEntryId) {
|
|
1342
|
+
let raw;
|
|
1343
|
+
try {
|
|
1344
|
+
raw = readFileSync(LEDGER_LIVENESS_PATH, "utf8");
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
return [];
|
|
1348
|
+
}
|
|
1349
|
+
const records = [];
|
|
1350
|
+
for (const line of raw.split("\n")) {
|
|
1351
|
+
if (!line.trim())
|
|
1352
|
+
continue;
|
|
1353
|
+
try {
|
|
1354
|
+
const parsed = JSON.parse(line);
|
|
1355
|
+
if (parsed && typeof parsed === "object" && parsed.ledger_entry_id === ledgerEntryId) {
|
|
1356
|
+
records.push(parsed);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
catch {
|
|
1360
|
+
// skip malformed line
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return records;
|
|
1364
|
+
}
|
|
1365
|
+
function latestLiveness(ledgerEntryId) {
|
|
1366
|
+
const records = readLedgerLivenessRecords(ledgerEntryId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1367
|
+
return records[0] ?? null;
|
|
1368
|
+
}
|
|
1369
|
+
function withLatestLiveness(entry) {
|
|
1370
|
+
const latest = latestLiveness(entry.id);
|
|
1371
|
+
if (!latest)
|
|
1372
|
+
return entry;
|
|
1373
|
+
return { ...entry, live_status: latest.live_status, last_verified_live: latest.timestamp };
|
|
1374
|
+
}
|
|
1375
|
+
// Feature 1 / Referential Integrity, P1: the single-entry live-check.
|
|
1376
|
+
// Orphaned when file_path is set but the file no longer exists; live when
|
|
1377
|
+
// the file exists and (best-effort) still mentions chosen_candidate;
|
|
1378
|
+
// unknown when file_path was never supplied, escapes PROJECT_ROOT (see
|
|
1379
|
+
// resolveWithinRoot), or exists but the candidate name can't be confirmed
|
|
1380
|
+
// in its content -- conservative on purpose, per the spec's own risk
|
|
1381
|
+
// mitigation (a false "orphaned" is worse than a lingering "unknown").
|
|
1382
|
+
// "dangling" (an entry only cross-referenced by other ledger entries, no
|
|
1383
|
+
// live anchor anywhere) is graph-level analysis across the whole ledger,
|
|
1384
|
+
// not a single-entry check -- Feature 1 P3, not built here.
|
|
1385
|
+
function checkFileLiveStatus(entry) {
|
|
1386
|
+
if (!entry.file_path)
|
|
1387
|
+
return "unknown";
|
|
1388
|
+
const abs = resolveWithinRoot(PROJECT_ROOT, entry.file_path);
|
|
1389
|
+
if (!abs)
|
|
1390
|
+
return "unknown";
|
|
1391
|
+
if (!existsSync(abs))
|
|
1392
|
+
return "orphaned";
|
|
1393
|
+
if (!entry.chosen_candidate)
|
|
1394
|
+
return "live";
|
|
1395
|
+
try {
|
|
1396
|
+
const content = readFileSync(abs, "utf8");
|
|
1397
|
+
return content.toLowerCase().includes(entry.chosen_candidate.toLowerCase()) ? "live" : "unknown";
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return "unknown";
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function checkLedgerEntryLiveness(entry) {
|
|
1404
|
+
const record = {
|
|
1405
|
+
id: randomUUID(),
|
|
1406
|
+
timestamp: new Date().toISOString(),
|
|
1407
|
+
ledger_entry_id: entry.id,
|
|
1408
|
+
project_id: entry.project_id,
|
|
1409
|
+
live_status: checkFileLiveStatus(entry),
|
|
1410
|
+
checked_file_path: entry.file_path,
|
|
1411
|
+
};
|
|
1412
|
+
appendLedgerLivenessRecord(record);
|
|
1413
|
+
return record;
|
|
1414
|
+
}
|
|
1415
|
+
// check_ledger_liveness tool: on-demand invocation of the live-check above
|
|
1416
|
+
// (the design's "on demand via an MCP call" case -- a scheduled/batch
|
|
1417
|
+
// sweep is Feature 1 P2, not built here). Entries with no file_path are
|
|
1418
|
+
// reported but never checked/recorded -- their status is permanently
|
|
1419
|
+
// "unknown" by construction, so re-checking them on every call would only
|
|
1420
|
+
// grow ledger_liveness.jsonl without ever learning anything new.
|
|
1421
|
+
function checkLedgerLiveness(input) {
|
|
1422
|
+
const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
|
|
1423
|
+
const results = entries.map((e) => {
|
|
1424
|
+
if (!e.file_path) {
|
|
1425
|
+
return {
|
|
1426
|
+
ledger_entry_id: e.id,
|
|
1427
|
+
component_need: e.component_need,
|
|
1428
|
+
file_path: null,
|
|
1429
|
+
live_status: "unknown",
|
|
1430
|
+
checked_at: null,
|
|
1431
|
+
note: "no file_path recorded on this entry -- nothing to check",
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
const record = checkLedgerEntryLiveness(e);
|
|
1435
|
+
return {
|
|
1436
|
+
ledger_entry_id: e.id,
|
|
1437
|
+
component_need: e.component_need,
|
|
1438
|
+
file_path: e.file_path,
|
|
1439
|
+
live_status: record.live_status,
|
|
1440
|
+
checked_at: record.timestamp,
|
|
1441
|
+
note: null,
|
|
1442
|
+
};
|
|
1443
|
+
});
|
|
1444
|
+
return {
|
|
1445
|
+
checked: results.filter((r) => r.checked_at !== null).length,
|
|
1446
|
+
total_entries: results.length,
|
|
1447
|
+
results,
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1118
1450
|
// Same "missing/malformed collapses to empty" philosophy as readMemory,
|
|
1119
1451
|
// but line-oriented (JSONL) rather than whole-file JSON -- a single
|
|
1120
1452
|
// corrupted line (e.g. a hand-edited file, or a write that got cut off)
|
|
@@ -1134,7 +1466,19 @@ function readLedgerEntries(projectId) {
|
|
|
1134
1466
|
try {
|
|
1135
1467
|
const parsed = JSON.parse(line);
|
|
1136
1468
|
if (parsed && typeof parsed === "object" && parsed.project_id === projectId) {
|
|
1137
|
-
entries
|
|
1469
|
+
// Backward-compatible defaults for entries written before the
|
|
1470
|
+
// ledger integrity/provenance fields existed -- a missing key
|
|
1471
|
+
// (not merely a null one) falls back to these rather than
|
|
1472
|
+
// `undefined` leaking into the returned shape.
|
|
1473
|
+
const rawEntry = parsed;
|
|
1474
|
+
const normalized = {
|
|
1475
|
+
...rawEntry,
|
|
1476
|
+
file_path: rawEntry.file_path ?? null,
|
|
1477
|
+
snapshot_ref: rawEntry.snapshot_ref ?? null,
|
|
1478
|
+
last_verified_live: rawEntry.last_verified_live ?? null,
|
|
1479
|
+
live_status: rawEntry.live_status ?? "unknown",
|
|
1480
|
+
};
|
|
1481
|
+
entries.push(withLatestLiveness(normalized));
|
|
1138
1482
|
}
|
|
1139
1483
|
}
|
|
1140
1484
|
catch {
|
|
@@ -1197,6 +1541,167 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
|
|
|
1197
1541
|
entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
1198
1542
|
return entries.slice(0, limit);
|
|
1199
1543
|
}
|
|
1544
|
+
// report_build_cost (cost-attribution build plan, 1.3) -- self-reported
|
|
1545
|
+
// build cost, cheapest option first, since Pattern has no visibility into
|
|
1546
|
+
// what happens after judgeComponent returns a verdict (1.4's
|
|
1547
|
+
// session-correlation fallback is a research spike only, not built here).
|
|
1548
|
+
// Stored as a second, separate JSONL file rather than mixed into
|
|
1549
|
+
// ledger.jsonl's LedgerEntry shape -- a BuildRecord has none of
|
|
1550
|
+
// LedgerEntry's verdict/coverage/candidate fields, and keeping the file
|
|
1551
|
+
// single-shape keeps read_ledger's existing output stable. Joined to
|
|
1552
|
+
// verdict records purely by feature_id, per the build plan's data model.
|
|
1553
|
+
const BUILD_LEDGER_PATH = process.env.PATTERN_BUILD_LEDGER_PATH ?? join(homedir(), ".pattern", "build_ledger.jsonl");
|
|
1554
|
+
function appendBuildRecord(record) {
|
|
1555
|
+
mkdirSync(dirname(BUILD_LEDGER_PATH), { recursive: true });
|
|
1556
|
+
appendFileSync(BUILD_LEDGER_PATH, JSON.stringify(record) + "\n", "utf8");
|
|
1557
|
+
}
|
|
1558
|
+
// Same "missing/malformed collapses to empty, one bad line skipped not
|
|
1559
|
+
// fatal" philosophy as readLedgerEntries.
|
|
1560
|
+
function readBuildRecords(featureId) {
|
|
1561
|
+
let raw;
|
|
1562
|
+
try {
|
|
1563
|
+
raw = readFileSync(BUILD_LEDGER_PATH, "utf8");
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
return [];
|
|
1567
|
+
}
|
|
1568
|
+
const records = [];
|
|
1569
|
+
for (const line of raw.split("\n")) {
|
|
1570
|
+
if (!line.trim())
|
|
1571
|
+
continue;
|
|
1572
|
+
try {
|
|
1573
|
+
const parsed = JSON.parse(line);
|
|
1574
|
+
if (parsed && typeof parsed === "object" && parsed.feature_id === featureId) {
|
|
1575
|
+
records.push(parsed);
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
// skip malformed line
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
return records;
|
|
1583
|
+
}
|
|
1584
|
+
function recordBuildCost(input) {
|
|
1585
|
+
const record = {
|
|
1586
|
+
id: randomUUID(),
|
|
1587
|
+
timestamp: new Date().toISOString(),
|
|
1588
|
+
project_id: input.project_id,
|
|
1589
|
+
feature_id: input.feature_id,
|
|
1590
|
+
tokens_used: typeof input.tokens_used === "number" && Number.isFinite(input.tokens_used) ? input.tokens_used : null,
|
|
1591
|
+
cost_usd: input.cost_usd,
|
|
1592
|
+
outcome: input.outcome,
|
|
1593
|
+
};
|
|
1594
|
+
appendBuildRecord(record);
|
|
1595
|
+
return record;
|
|
1596
|
+
}
|
|
1597
|
+
// The "total cost per feature is queryable" rollup task 1.5 validates
|
|
1598
|
+
// against a hand total: every verdict-time ledger entry for this
|
|
1599
|
+
// project_id+feature_id (fresh judgments and $0 cache hits alike) plus
|
|
1600
|
+
// every self-reported build record for the same feature_id. project_id is
|
|
1601
|
+
// required, same as every other read here, so this never falls back to a
|
|
1602
|
+
// shared/global bucket across projects.
|
|
1603
|
+
// report_outcome_proxy (cost-attribution build plan Phase 2, 2.1-2.3) --
|
|
1604
|
+
// self-reported, same reasoning as report_build_cost: rework-rate and
|
|
1605
|
+
// time-to-merge both require real git history, and Pattern has no
|
|
1606
|
+
// process.cwd()/repo-path concept and no filesystem access to a caller's
|
|
1607
|
+
// repo at all (see project judgment ledger's own design notes) -- rather
|
|
1608
|
+
// than giving Pattern a new git-shelling-out capability, the calling
|
|
1609
|
+
// agent (which already has real repo access) computes these off its own
|
|
1610
|
+
// `git log`/`git blame` and reports the result here. This also makes
|
|
1611
|
+
// 2.4's exclusion check true by construction: nothing on this path ever
|
|
1612
|
+
// reads coverage_pct, confidence, or any other Pattern-produced field --
|
|
1613
|
+
// there simply isn't a code path from a verdict into an outcome proxy.
|
|
1614
|
+
// Append-only like every other record here: a feature can get multiple
|
|
1615
|
+
// proxy reports over time (time_to_merge_hours right after merge,
|
|
1616
|
+
// reworked/days_to_rework on a later re-check, status_at_30d once the
|
|
1617
|
+
// horizon passes) -- readers take the latest report per field via
|
|
1618
|
+
// latestOutcomeProxy below, not a running mutation of one row.
|
|
1619
|
+
const OUTCOME_PROXY_PATH = process.env.PATTERN_OUTCOME_PROXY_PATH ?? join(homedir(), ".pattern", "outcome_proxies.jsonl");
|
|
1620
|
+
function appendOutcomeProxyRecord(record) {
|
|
1621
|
+
mkdirSync(dirname(OUTCOME_PROXY_PATH), { recursive: true });
|
|
1622
|
+
appendFileSync(OUTCOME_PROXY_PATH, JSON.stringify(record) + "\n", "utf8");
|
|
1623
|
+
}
|
|
1624
|
+
function readOutcomeProxyRecords(featureId) {
|
|
1625
|
+
let raw;
|
|
1626
|
+
try {
|
|
1627
|
+
raw = readFileSync(OUTCOME_PROXY_PATH, "utf8");
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
return [];
|
|
1631
|
+
}
|
|
1632
|
+
const records = [];
|
|
1633
|
+
for (const line of raw.split("\n")) {
|
|
1634
|
+
if (!line.trim())
|
|
1635
|
+
continue;
|
|
1636
|
+
try {
|
|
1637
|
+
const parsed = JSON.parse(line);
|
|
1638
|
+
if (parsed && typeof parsed === "object" && parsed.feature_id === featureId) {
|
|
1639
|
+
records.push(parsed);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
catch {
|
|
1643
|
+
// skip malformed line
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return records;
|
|
1647
|
+
}
|
|
1648
|
+
// Merges every report for a feature into one view, most recent value per
|
|
1649
|
+
// field wins (not most recent record wins) -- so a status_at_30d reported
|
|
1650
|
+
// today doesn't get lost behind an unrelated reworked update reported
|
|
1651
|
+
// yesterday, and vice versa. history is still returned in full for anyone
|
|
1652
|
+
// who wants the raw timeline rather than just the merged snapshot.
|
|
1653
|
+
function latestOutcomeProxy(featureId) {
|
|
1654
|
+
const records = readOutcomeProxyRecords(featureId).sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
1655
|
+
if (records.length === 0)
|
|
1656
|
+
return { merged: null, history: records };
|
|
1657
|
+
const merged = {};
|
|
1658
|
+
for (const r of records) {
|
|
1659
|
+
if (r.reworked !== undefined)
|
|
1660
|
+
merged.reworked = r.reworked;
|
|
1661
|
+
if (r.days_to_rework !== undefined)
|
|
1662
|
+
merged.days_to_rework = r.days_to_rework;
|
|
1663
|
+
if (r.time_to_merge_hours !== undefined)
|
|
1664
|
+
merged.time_to_merge_hours = r.time_to_merge_hours;
|
|
1665
|
+
if (r.status_at_30d !== undefined)
|
|
1666
|
+
merged.status_at_30d = r.status_at_30d;
|
|
1667
|
+
}
|
|
1668
|
+
return { merged, history: records };
|
|
1669
|
+
}
|
|
1670
|
+
function recordOutcomeProxy(input) {
|
|
1671
|
+
if (input.reworked === undefined &&
|
|
1672
|
+
input.days_to_rework === undefined &&
|
|
1673
|
+
input.time_to_merge_hours === undefined &&
|
|
1674
|
+
input.status_at_30d === undefined) {
|
|
1675
|
+
throw new Error("report_outcome_proxy requires at least one of reworked, days_to_rework, time_to_merge_hours, or status_at_30d.");
|
|
1676
|
+
}
|
|
1677
|
+
const record = {
|
|
1678
|
+
id: randomUUID(),
|
|
1679
|
+
timestamp: new Date().toISOString(),
|
|
1680
|
+
project_id: input.project_id,
|
|
1681
|
+
feature_id: input.feature_id,
|
|
1682
|
+
...(input.reworked !== undefined ? { reworked: input.reworked } : {}),
|
|
1683
|
+
...(input.days_to_rework !== undefined ? { days_to_rework: input.days_to_rework } : {}),
|
|
1684
|
+
...(input.time_to_merge_hours !== undefined ? { time_to_merge_hours: input.time_to_merge_hours } : {}),
|
|
1685
|
+
...(input.status_at_30d !== undefined ? { status_at_30d: input.status_at_30d } : {}),
|
|
1686
|
+
};
|
|
1687
|
+
appendOutcomeProxyRecord(record);
|
|
1688
|
+
return record;
|
|
1689
|
+
}
|
|
1690
|
+
function totalFeatureCost(projectId, featureId) {
|
|
1691
|
+
const verdictEntries = readLedgerEntries(projectId).filter((e) => e.feature_id === featureId);
|
|
1692
|
+
const buildRecords = readBuildRecords(featureId).filter((r) => !r.project_id || r.project_id === projectId);
|
|
1693
|
+
const total = verdictEntries.reduce((sum, e) => sum + (e.cost_usd ?? 0), 0) +
|
|
1694
|
+
buildRecords.reduce((sum, r) => sum + (r.cost_usd ?? 0), 0);
|
|
1695
|
+
const { merged, history } = latestOutcomeProxy(featureId);
|
|
1696
|
+
return {
|
|
1697
|
+
feature_id: featureId,
|
|
1698
|
+
verdict_entries: verdictEntries,
|
|
1699
|
+
build_records: buildRecords,
|
|
1700
|
+
total_cost_usd: Math.round(total * 10000) / 10000,
|
|
1701
|
+
outcome_proxy: merged,
|
|
1702
|
+
outcome_proxy_history: history,
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1200
1705
|
// Orchestrates the ensemble: run once, and only pay for 2 more full
|
|
1201
1706
|
// pipeline passes when the single-run result landed close enough to a
|
|
1202
1707
|
// verdict threshold that a single item's judgment swinging could flip
|
|
@@ -1224,15 +1729,32 @@ function aggregateMeta(passes) {
|
|
|
1224
1729
|
tokens_used: {
|
|
1225
1730
|
input: metas.reduce((sum, m) => sum + m.tokens_used.input, 0),
|
|
1226
1731
|
output: metas.reduce((sum, m) => sum + m.tokens_used.output, 0),
|
|
1732
|
+
// Only present if every pass has it -- all passes go through the same
|
|
1733
|
+
// buildMeta call site in practice, so a mix would mean something else
|
|
1734
|
+
// changed; safer to omit than to silently sum a partial set.
|
|
1735
|
+
...(metas.every((m) => m.tokens_used.input_breakdown)
|
|
1736
|
+
? {
|
|
1737
|
+
input_breakdown: {
|
|
1738
|
+
fresh: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.fresh ?? 0), 0),
|
|
1739
|
+
cache_write: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.cache_write ?? 0), 0),
|
|
1740
|
+
cache_read: metas.reduce((sum, m) => sum + (m.tokens_used.input_breakdown?.cache_read ?? 0), 0),
|
|
1741
|
+
},
|
|
1742
|
+
}
|
|
1743
|
+
: {}),
|
|
1227
1744
|
},
|
|
1228
1745
|
estimated_cost_usd: Math.round(metas.reduce((sum, m) => sum + m.estimated_cost_usd, 0) * 10000) / 10000,
|
|
1229
1746
|
};
|
|
1230
1747
|
}
|
|
1231
|
-
// Builds the LedgerEntry appended after a fresh (non-cache-hit)
|
|
1748
|
+
// Builds the LedgerEntry appended after a judgment -- fresh (non-cache-hit)
|
|
1749
|
+
// or a ledger cache hit, distinguished by opts.cacheHit/opts.costUsd (a
|
|
1750
|
+
// cache hit is always real $0, a fresh call carries its own
|
|
1751
|
+
// _meta.estimated_cost_usd; callers pass that in rather than this function
|
|
1752
|
+
// reaching into result._meta itself, since the cache-hit path's synthetic
|
|
1753
|
+
// _meta shouldn't be treated as equivalent to a real one).
|
|
1232
1754
|
// checklist/checklist_source come from the result itself, not input.checklist
|
|
1233
1755
|
// -- that field captures what was actually scored regardless of whether the
|
|
1234
1756
|
// caller pre-supplied it or this call extracted it internally.
|
|
1235
|
-
function buildLedgerEntry(input, projectId, result) {
|
|
1757
|
+
function buildLedgerEntry(input, projectId, result, opts) {
|
|
1236
1758
|
const candidate = distillCandidate(result);
|
|
1237
1759
|
const checklist = Array.isArray(result.requirements_checked)
|
|
1238
1760
|
? result.requirements_checked.map((r) => r.requirement).filter((r) => !!r)
|
|
@@ -1241,6 +1763,7 @@ function buildLedgerEntry(input, projectId, result) {
|
|
|
1241
1763
|
id: randomUUID(),
|
|
1242
1764
|
timestamp: new Date().toISOString(),
|
|
1243
1765
|
project_id: projectId,
|
|
1766
|
+
feature_id: deriveFeatureId(input.component_need, projectId, opts.featureId ?? input.feature_id),
|
|
1244
1767
|
component_need: input.component_need,
|
|
1245
1768
|
domain: input.domain,
|
|
1246
1769
|
framework: input.framework,
|
|
@@ -1252,7 +1775,22 @@ function buildLedgerEntry(input, projectId, result) {
|
|
|
1252
1775
|
confidence: result.confidence,
|
|
1253
1776
|
reason: result.reason,
|
|
1254
1777
|
coverage: result.coverage ?? null,
|
|
1778
|
+
cost_usd: opts.costUsd,
|
|
1779
|
+
cache_hit: opts.cacheHit,
|
|
1255
1780
|
project_conventions_snapshot: hashConventions(input.existing_stack),
|
|
1781
|
+
// Feature 2 P0: captured fresh for every entry (cache hits included),
|
|
1782
|
+
// not inherited from a matched ledger_cache_hit -- this reflects the
|
|
1783
|
+
// codebase state at the moment *this line* was written, not the
|
|
1784
|
+
// moment the original judgment ran (see PROJECT_ROOT above).
|
|
1785
|
+
snapshot_ref: computeSnapshotRef(PROJECT_ROOT),
|
|
1786
|
+
// Feature 1 P0: caller-supplied at write time (recommend_component's
|
|
1787
|
+
// optional file_path), null when not yet known -- typically the case,
|
|
1788
|
+
// since the decision is usually made before the file exists. Always
|
|
1789
|
+
// starts "unknown"/unchecked; check_ledger_liveness fills these in
|
|
1790
|
+
// later via the ledger_liveness.jsonl overlay (see withLatestLiveness).
|
|
1791
|
+
file_path: input.file_path ?? null,
|
|
1792
|
+
last_verified_live: null,
|
|
1793
|
+
live_status: "unknown",
|
|
1256
1794
|
};
|
|
1257
1795
|
}
|
|
1258
1796
|
async function judgeComponent(input) {
|
|
@@ -1295,6 +1833,27 @@ async function judgeComponent(input) {
|
|
|
1295
1833
|
estimated_cost_usd: 0,
|
|
1296
1834
|
},
|
|
1297
1835
|
};
|
|
1836
|
+
captureRecommendation({
|
|
1837
|
+
projectId: input.project_id,
|
|
1838
|
+
verdict: result.verdict,
|
|
1839
|
+
confidence: result.confidence,
|
|
1840
|
+
reason: result.reason,
|
|
1841
|
+
ensembleTriggered: false,
|
|
1842
|
+
estimatedCostUsd: 0,
|
|
1843
|
+
servedFromLedger: true,
|
|
1844
|
+
});
|
|
1845
|
+
// Cost-attribution build plan, 1.1: log feature_id on every ledger
|
|
1846
|
+
// write, cache hit included -- not just fresh judgments -- so a
|
|
1847
|
+
// feature's total cost rolls up correctly even when most of its later
|
|
1848
|
+
// calls cost $0 via this exact short-circuit. Inherits the matched
|
|
1849
|
+
// entry's feature_id unless this call explicitly supplies its own.
|
|
1850
|
+
if (input.project_id) {
|
|
1851
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, result, {
|
|
1852
|
+
costUsd: 0,
|
|
1853
|
+
cacheHit: true,
|
|
1854
|
+
featureId: input.feature_id ?? ledgerCacheHit.feature_id,
|
|
1855
|
+
}));
|
|
1856
|
+
}
|
|
1298
1857
|
return JSON.stringify(result);
|
|
1299
1858
|
}
|
|
1300
1859
|
// Session cap and local logging both apply only to calls that actually
|
|
@@ -1319,7 +1878,21 @@ async function judgeComponent(input) {
|
|
|
1319
1878
|
if (reachesApi)
|
|
1320
1879
|
logCall(input, first.result);
|
|
1321
1880
|
if (reachesApi && input.project_id && (first.result.reason === "scored" || first.result.reason === "no_candidates_found")) {
|
|
1322
|
-
appendLedgerEntry(buildLedgerEntry(input, input.project_id, first.result
|
|
1881
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, first.result, {
|
|
1882
|
+
costUsd: first.result._meta?.estimated_cost_usd ?? 0,
|
|
1883
|
+
cacheHit: false,
|
|
1884
|
+
}));
|
|
1885
|
+
}
|
|
1886
|
+
if (reachesApi) {
|
|
1887
|
+
captureRecommendation({
|
|
1888
|
+
projectId: input.project_id,
|
|
1889
|
+
verdict: first.result.verdict,
|
|
1890
|
+
confidence: first.result.confidence,
|
|
1891
|
+
reason: first.result.reason,
|
|
1892
|
+
ensembleTriggered: false,
|
|
1893
|
+
estimatedCostUsd: first.result._meta?.estimated_cost_usd ?? null,
|
|
1894
|
+
servedFromLedger: false,
|
|
1895
|
+
});
|
|
1323
1896
|
}
|
|
1324
1897
|
return JSON.stringify(first.result);
|
|
1325
1898
|
}
|
|
@@ -1328,13 +1901,37 @@ async function judgeComponent(input) {
|
|
|
1328
1901
|
reason: first.result.reason,
|
|
1329
1902
|
coverage: first.result.coverage,
|
|
1330
1903
|
}));
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1904
|
+
// Adaptive escalation: run only a 2nd pass first. A binary verdict
|
|
1905
|
+
// (use_existing | custom_build) can only tie at 2 passes, never at 3 --
|
|
1906
|
+
// so we escalate to a 3rd pass ONLY on that 1/1 tie, which is exactly
|
|
1907
|
+
// the case that actually needs a tie-break. When the 2nd pass agrees
|
|
1908
|
+
// with the 1st, that agreement is itself the answer and a 3rd pass
|
|
1909
|
+
// would just spend real API cost confirming what's already settled.
|
|
1910
|
+
// This does not touch the correctness guarantee for genuine
|
|
1911
|
+
// disagreement -- it still always resolves via an odd-numbered
|
|
1912
|
+
// majority vote, same as the flat 3-run version this replaces.
|
|
1913
|
+
const second = await runSinglePass(input);
|
|
1914
|
+
let passes = [first, second].filter((p) => p.ok);
|
|
1915
|
+
let verdicts = passes.map((p) => p.result.verdict);
|
|
1916
|
+
let counts = new Map();
|
|
1335
1917
|
for (const v of verdicts)
|
|
1336
1918
|
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
1337
|
-
|
|
1919
|
+
let sortedCounts = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
1920
|
+
const isTwoWayTie = passes.length === 2 && sortedCounts.length === 2 && sortedCounts[0][1] === sortedCounts[1][1];
|
|
1921
|
+
if (isTwoWayTie) {
|
|
1922
|
+
console.error(JSON.stringify({
|
|
1923
|
+
diagnostic: "ensemble_tie_escalated",
|
|
1924
|
+
runs: verdicts,
|
|
1925
|
+
}));
|
|
1926
|
+
const third = await runSinglePass(input);
|
|
1927
|
+
passes = [first, second, third].filter((p) => p.ok);
|
|
1928
|
+
verdicts = passes.map((p) => p.result.verdict);
|
|
1929
|
+
counts = new Map();
|
|
1930
|
+
for (const v of verdicts)
|
|
1931
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
1932
|
+
sortedCounts = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
1933
|
+
}
|
|
1934
|
+
const [majorityVerdict, majorityCount] = sortedCounts[0];
|
|
1338
1935
|
const agreement = `${majorityCount}/${passes.length}`;
|
|
1339
1936
|
// Use a pass whose own verdict already matches the majority as the base
|
|
1340
1937
|
// for everything else in the response (recommendation, coverage,
|
|
@@ -1379,8 +1976,20 @@ async function judgeComponent(input) {
|
|
|
1379
1976
|
// reachesApi === true here, no guard needed.
|
|
1380
1977
|
logCall(input, base);
|
|
1381
1978
|
if (input.project_id && (base.reason === "scored" || base.reason === "no_candidates_found")) {
|
|
1382
|
-
appendLedgerEntry(buildLedgerEntry(input, input.project_id, base
|
|
1979
|
+
appendLedgerEntry(buildLedgerEntry(input, input.project_id, base, {
|
|
1980
|
+
costUsd: base._meta?.estimated_cost_usd ?? 0,
|
|
1981
|
+
cacheHit: false,
|
|
1982
|
+
}));
|
|
1383
1983
|
}
|
|
1984
|
+
captureRecommendation({
|
|
1985
|
+
projectId: input.project_id,
|
|
1986
|
+
verdict: base.verdict,
|
|
1987
|
+
confidence: base.confidence,
|
|
1988
|
+
reason: base.reason,
|
|
1989
|
+
ensembleTriggered: true,
|
|
1990
|
+
estimatedCostUsd: base._meta?.estimated_cost_usd ?? null,
|
|
1991
|
+
servedFromLedger: false,
|
|
1992
|
+
});
|
|
1384
1993
|
return JSON.stringify(base);
|
|
1385
1994
|
}
|
|
1386
1995
|
// The model's stated `coverage` string doesn't always match its own
|
|
@@ -1477,7 +2086,26 @@ export function enforceVerdictThreshold(parsed) {
|
|
|
1477
2086
|
let correctConfidence;
|
|
1478
2087
|
if (pct >= 80) {
|
|
1479
2088
|
correctVerdict = "use_existing";
|
|
1480
|
-
|
|
2089
|
+
// Oversized Match overrides the coverage-only threshold -- a candidate
|
|
2090
|
+
// can satisfy every requirement and still be the wrong call if it's
|
|
2091
|
+
// disproportionate to the stated scope (see step 5's Oversized Match
|
|
2092
|
+
// check and the JudgmentResult.oversized_match comment). Deliberately
|
|
2093
|
+
// keyed off the model's own oversized_match flag, not its "confidence"
|
|
2094
|
+
// field -- confirmed live that the model can correctly reason through
|
|
2095
|
+
// an Oversized Match in oversized_match_note and still leave
|
|
2096
|
+
// "confidence": "high" unchanged, so that field alone can't be trusted
|
|
2097
|
+
// to carry this signal.
|
|
2098
|
+
if (parsed.oversized_match === true) {
|
|
2099
|
+
correctConfidence = "low";
|
|
2100
|
+
console.error(JSON.stringify({
|
|
2101
|
+
diagnostic: "oversized_match_confidence_capped",
|
|
2102
|
+
coverage: parsed.coverage,
|
|
2103
|
+
note: parsed.oversized_match_note ?? null,
|
|
2104
|
+
}));
|
|
2105
|
+
}
|
|
2106
|
+
else {
|
|
2107
|
+
correctConfidence = "high";
|
|
2108
|
+
}
|
|
1481
2109
|
}
|
|
1482
2110
|
else if (pct >= 40) {
|
|
1483
2111
|
correctVerdict = "use_existing";
|
|
@@ -1798,6 +2426,57 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1798
2426
|
"instead of a fresh search+score).",
|
|
1799
2427
|
inputSchema: READ_LEDGER_INPUT_SCHEMA,
|
|
1800
2428
|
},
|
|
2429
|
+
{
|
|
2430
|
+
name: REPORT_BUILD_COST_TOOL_NAME,
|
|
2431
|
+
description: "Self-reports the end-to-end build cost for one feature -- call this " +
|
|
2432
|
+
"once when the build a recommend_component verdict fed into is " +
|
|
2433
|
+
"actually complete (shipped, abandoned, or replaced), not on every " +
|
|
2434
|
+
"verdict. Pattern only ever sees the cost of judging what to use; " +
|
|
2435
|
+
"everything past that -- the actual scaffold, install, or custom " +
|
|
2436
|
+
"build -- happens outside Pattern entirely, so this is the only way " +
|
|
2437
|
+
"that cost gets attributed back to the feature. Pass the same " +
|
|
2438
|
+
"feature_id you used (or that recommend_component derived) for this " +
|
|
2439
|
+
"feature's judgment call(s), so read_ledger's feature_id rollup can " +
|
|
2440
|
+
"join this record to them. This only appends a local record; it " +
|
|
2441
|
+
"never re-runs any judgment and never calls the Anthropic API.",
|
|
2442
|
+
inputSchema: REPORT_BUILD_COST_INPUT_SCHEMA,
|
|
2443
|
+
},
|
|
2444
|
+
{
|
|
2445
|
+
name: REPORT_OUTCOME_PROXY_TOOL_NAME,
|
|
2446
|
+
description: "Self-reports a value signal for one feature that is deliberately " +
|
|
2447
|
+
"independent of Pattern's own verdict -- never derive any of these " +
|
|
2448
|
+
"fields from coverage_pct, confidence, or anything else Pattern " +
|
|
2449
|
+
"returned; they only mean something if they could contradict the " +
|
|
2450
|
+
"verdict. Compute reworked/days_to_rework and time_to_merge_hours " +
|
|
2451
|
+
"from your own repo's real git history (e.g. `git log --follow` " +
|
|
2452
|
+
"against the files this feature's build touched) -- never guess " +
|
|
2453
|
+
"them. Report status_at_30d only once a real ~30-day-post-merge " +
|
|
2454
|
+
"horizon has actually passed. Safe to call more than once for the " +
|
|
2455
|
+
"same feature_id as more signal becomes available over time (e.g. " +
|
|
2456
|
+
"time_to_merge_hours right after merge, reworked on a later check, " +
|
|
2457
|
+
"status_at_30d at the 30-day mark) -- read_ledger's feature_id " +
|
|
2458
|
+
"rollup merges every report into one latest-value-per-field view. " +
|
|
2459
|
+
"This only appends a local record; it never calls the Anthropic API.",
|
|
2460
|
+
inputSchema: REPORT_OUTCOME_PROXY_INPUT_SCHEMA,
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
name: CHECK_LEDGER_LIVENESS_TOOL_NAME,
|
|
2464
|
+
description: "Checks whether recommend_component ledger entries for a project_id " +
|
|
2465
|
+
"are still 'live' -- the file_path recorded on the entry (if any) " +
|
|
2466
|
+
"still exists and still mentions chosen_candidate. Requires real, " +
|
|
2467
|
+
"read-only filesystem access to PROJECT_ROOT (defaults to this " +
|
|
2468
|
+
"server's working directory; override with PATTERN_PROJECT_ROOT) -- " +
|
|
2469
|
+
"this is the one exception to Pattern otherwise having no " +
|
|
2470
|
+
"filesystem access to a caller's repo (see report_build_cost/" +
|
|
2471
|
+
"report_outcome_proxy above). Entries with no file_path are listed " +
|
|
2472
|
+
"but not checked -- their status is permanently 'unknown' since " +
|
|
2473
|
+
"there's nothing to check. Never writes to your repo, never runs " +
|
|
2474
|
+
"an arbitrary git/shell command beyond `git rev-parse HEAD` " +
|
|
2475
|
+
"elsewhere in this server. Results are also layered onto " +
|
|
2476
|
+
"read_ledger's live_status/last_verified_live fields for the same " +
|
|
2477
|
+
"entries afterward.",
|
|
2478
|
+
inputSchema: CHECK_LEDGER_LIVENESS_INPUT_SCHEMA,
|
|
2479
|
+
},
|
|
1801
2480
|
],
|
|
1802
2481
|
}));
|
|
1803
2482
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1811,6 +2490,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1811
2490
|
}
|
|
1812
2491
|
catch (err) {
|
|
1813
2492
|
const message = err instanceof Error ? err.message : String(err);
|
|
2493
|
+
if (/Anthropic API error \d+/.test(message)) {
|
|
2494
|
+
captureApiError({ tool: TOOL_NAME, message, projectId: args.project_id });
|
|
2495
|
+
}
|
|
1814
2496
|
return {
|
|
1815
2497
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1816
2498
|
isError: true,
|
|
@@ -1839,6 +2521,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1839
2521
|
}
|
|
1840
2522
|
catch (err) {
|
|
1841
2523
|
const message = err instanceof Error ? err.message : String(err);
|
|
2524
|
+
if (/Anthropic API error \d+/.test(message)) {
|
|
2525
|
+
captureApiError({ tool: EXTRACT_REQUIREMENTS_TOOL_NAME, message });
|
|
2526
|
+
}
|
|
1842
2527
|
return {
|
|
1843
2528
|
content: [{ type: "text", text: `Error: ${message}` }],
|
|
1844
2529
|
isError: true,
|
|
@@ -1869,6 +2554,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1869
2554
|
if (request.params.name === READ_LEDGER_TOOL_NAME) {
|
|
1870
2555
|
const args = request.params.arguments;
|
|
1871
2556
|
try {
|
|
2557
|
+
if (args.feature_id) {
|
|
2558
|
+
const rollup = totalFeatureCost(args.project_id, args.feature_id);
|
|
2559
|
+
return {
|
|
2560
|
+
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...rollup }) }],
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
1872
2563
|
const entries = findLedgerMatches(args.project_id, args.component_need, args.limit);
|
|
1873
2564
|
return {
|
|
1874
2565
|
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, entries }) }],
|
|
@@ -1882,11 +2573,68 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1882
2573
|
};
|
|
1883
2574
|
}
|
|
1884
2575
|
}
|
|
2576
|
+
if (request.params.name === REPORT_BUILD_COST_TOOL_NAME) {
|
|
2577
|
+
const args = request.params.arguments;
|
|
2578
|
+
try {
|
|
2579
|
+
const record = recordBuildCost(args);
|
|
2580
|
+
return {
|
|
2581
|
+
content: [{ type: "text", text: JSON.stringify({ status: "recorded", record }) }],
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
catch (err) {
|
|
2585
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2586
|
+
return {
|
|
2587
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2588
|
+
isError: true,
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
if (request.params.name === REPORT_OUTCOME_PROXY_TOOL_NAME) {
|
|
2593
|
+
const args = request.params.arguments;
|
|
2594
|
+
try {
|
|
2595
|
+
const record = recordOutcomeProxy(args);
|
|
2596
|
+
return {
|
|
2597
|
+
content: [{ type: "text", text: JSON.stringify({ status: "recorded", record }) }],
|
|
2598
|
+
};
|
|
2599
|
+
}
|
|
2600
|
+
catch (err) {
|
|
2601
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2602
|
+
return {
|
|
2603
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2604
|
+
isError: true,
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
if (request.params.name === CHECK_LEDGER_LIVENESS_TOOL_NAME) {
|
|
2609
|
+
const args = request.params.arguments;
|
|
2610
|
+
try {
|
|
2611
|
+
const result = checkLedgerLiveness(args);
|
|
2612
|
+
return {
|
|
2613
|
+
content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...result }) }],
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
catch (err) {
|
|
2617
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2618
|
+
return {
|
|
2619
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
2620
|
+
isError: true,
|
|
2621
|
+
};
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
1885
2624
|
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
1886
2625
|
});
|
|
1887
2626
|
async function main() {
|
|
2627
|
+
printTelemetryNoticeOnce();
|
|
1888
2628
|
const transport = new StdioServerTransport();
|
|
1889
2629
|
await server.connect(transport);
|
|
2630
|
+
// Best-effort telemetry drain on clean shutdown -- no-op when telemetry
|
|
2631
|
+
// was never enabled (see src/telemetry.ts).
|
|
2632
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
2633
|
+
process.on(signal, async () => {
|
|
2634
|
+
await shutdownTelemetry();
|
|
2635
|
+
process.exit(0);
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
1890
2638
|
}
|
|
1891
2639
|
// Guard exists so verification scripts (e.g. verify-ledger-boundary.mjs)
|
|
1892
2640
|
// can import this module's exported pure functions (distillCandidate,
|