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/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: (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0),
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.push(parsed);
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) judgment.
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
- const [second, third] = await Promise.all([runSinglePass(input), runSinglePass(input)]);
1332
- const passes = [first, second, third].filter((p) => p.ok);
1333
- const verdicts = passes.map((p) => p.result.verdict);
1334
- const counts = new Map();
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
- const [majorityVerdict, majorityCount] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
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
- correctConfidence = "high";
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,