pattern-mcp 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +250 -6
  2. package/dist/index.js +362 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ whether to:
32
32
 
33
33
  Pattern is designed for agents to use **while they are building**.
34
34
 
35
- It exposes six tools:
35
+ It exposes eight tools:
36
36
 
37
37
  - `recommend_component` — evaluates a UI component need and returns a
38
38
  structured recommendation.
@@ -54,6 +54,13 @@ It exposes six tools:
54
54
  merge, kept-vs-replaced) for one feature, deliberately independent of
55
55
  Pattern's own verdict -- see [Outcome
56
56
  proxies](#outcome-proxies).
57
+ - `check_ledger_liveness` — checks whether a ledger entry's recorded
58
+ `file_path` still exists and still references its `chosen_candidate` --
59
+ see [Tool: `check_ledger_liveness`](#tool-check_ledger_liveness).
60
+ - `export_ledger_provenance` — formats one ledger entry as a stable
61
+ markdown block (checklist, candidates, verdict, `snapshot_ref`) you can
62
+ paste into a PR or issue by hand -- see [Tool:
63
+ `export_ledger_provenance`](#tool-export_ledger_provenance).
57
64
 
58
65
  ## How it works
59
66
 
@@ -428,6 +435,17 @@ Omit it to have one derived deterministically from `project_id` +
428
435
  `component_need`; only meaningful together with `project_id`. See
429
436
  [Feature cost attribution](#feature-cost-attribution).
430
437
 
438
+ #### `file_path`
439
+
440
+ `file_path` is optional -- path (relative to `PROJECT_ROOT`) where this
441
+ component decision is expected to be implemented, if already known.
442
+ Usually not known yet at call time, since the decision typically precedes
443
+ the file existing. When provided, it's stored on the resulting ledger
444
+ entry and [`check_ledger_liveness`](#tool-check_ledger_liveness) can later
445
+ confirm the file still exists and still references `chosen_candidate`. It
446
+ cannot currently be attached to an entry after the fact -- see [Ledger
447
+ integrity and decision provenance](#ledger-integrity-and-decision-provenance).
448
+
431
449
  **Is the checklist actually skipped, not just re-derived?** Checked, not
432
450
  assumed. `breakdown_ms.extract` for a `checklist`-provided call is smaller
433
451
  than the default path's, but not near-zero -- which raised the question of
@@ -717,12 +735,24 @@ came back with `served_from_ledger: true`.
717
735
  "coverage": "5/8 (62.5%)",
718
736
  "cost_usd": 0.087,
719
737
  "cache_hit": false,
720
- "project_conventions_snapshot": "9f3a1c7e2b0d4f5a"
738
+ "project_conventions_snapshot": "9f3a1c7e2b0d4f5a",
739
+ "file_path": null,
740
+ "snapshot_ref": "a1b2c3d4e5f6...",
741
+ "last_verified_live": null,
742
+ "live_status": "unknown"
721
743
  }
722
744
  ]
723
745
  }
724
746
  ```
725
747
 
748
+ `file_path`/`snapshot_ref`/`last_verified_live`/`live_status` are the
749
+ ledger integrity + decision provenance fields -- see [Ledger integrity and
750
+ decision provenance](#ledger-integrity-and-decision-provenance) and [Tool:
751
+ `check_ledger_liveness`](#tool-check_ledger_liveness). Entries written
752
+ before this feature shipped read back with `file_path`/`snapshot_ref`/
753
+ `last_verified_live` as `null` and `live_status` as `"unknown"` rather
754
+ than missing keys.
755
+
726
756
  Passing `feature_id` instead returns:
727
757
 
728
758
  ```json
@@ -814,10 +844,12 @@ Pattern's own verdict -- the whole point is a signal that could
814
844
  `coverage_pct`, `confidence`, or any other Pattern-produced field. Compute
815
845
  `reworked`/`days_to_rework` and `time_to_merge_hours` from your own repo's
816
846
  real git history (e.g. `git log --follow` against the files this
817
- feature's build touched) -- Pattern has no `process.cwd()`/repo-path
818
- concept and no filesystem access to your repo at all, so it can't compute
819
- these itself. Report `status_at_30d` only once a real ~30-day-post-merge
820
- horizon has actually passed.
847
+ feature's build touched) rather than relying on Pattern -- rework rate and
848
+ time-to-merge need real git *history*, a materially bigger surface than
849
+ the one narrow, read-only exception described in [Ledger integrity and
850
+ decision provenance](#ledger-integrity-and-decision-provenance) below.
851
+ Report `status_at_30d` only once a real ~30-day-post-merge horizon has
852
+ actually passed.
821
853
 
822
854
  Safe to call more than once for the same `feature_id` as more signal
823
855
  becomes available over time -- e.g. `time_to_merge_hours` right after
@@ -864,6 +896,122 @@ This only appends a local record to `~/.pattern/outcome_proxies.jsonl`
864
896
  (override with `PATTERN_OUTCOME_PROXY_PATH`) -- it never calls the
865
897
  Anthropic API.
866
898
 
899
+ ## Tool: `check_ledger_liveness`
900
+
901
+ Checks whether ledger entries for a `project_id` are still **live** --
902
+ does the `file_path` recorded on the entry (if any, see
903
+ [`file_path`](#tool-recommend_component)) still exist, and does it still
904
+ mention `chosen_candidate`. See [Ledger integrity and decision
905
+ provenance](#ledger-integrity-and-decision-provenance) for the full design
906
+ and its deliberate limits.
907
+
908
+ This is the **one exception** to Pattern otherwise having no filesystem
909
+ access to your repo (see [Outcome proxies](#outcome-proxies) above) --
910
+ scoped narrowly to read-only `fs.existsSync`/file-read calls against
911
+ `PROJECT_ROOT` (defaults to this server's own working directory; override
912
+ with `PATTERN_PROJECT_ROOT`). It never writes to your repo and never runs
913
+ an arbitrary shell command.
914
+
915
+ ### Input
916
+
917
+ ```json
918
+ {
919
+ "project_id": "my-booking-app",
920
+ "ledger_entry_id": "a1b2c3d4-..."
921
+ }
922
+ ```
923
+
924
+ - `project_id` is required.
925
+ - `ledger_entry_id` is optional -- check just that one entry instead of
926
+ every entry for `project_id` that has a `file_path` set.
927
+
928
+ ### Output
929
+
930
+ ```json
931
+ {
932
+ "project_id": "my-booking-app",
933
+ "checked": 1,
934
+ "total_entries": 2,
935
+ "results": [
936
+ {
937
+ "ledger_entry_id": "a1b2c3d4-...",
938
+ "component_need": "cancellation policy display with refund tiers by date",
939
+ "file_path": "src/components/CancellationPolicy.tsx",
940
+ "live_status": "live",
941
+ "checked_at": "2026-09-02T20:11:03.442Z",
942
+ "note": null
943
+ },
944
+ {
945
+ "ledger_entry_id": "e5f6a7b8-...",
946
+ "component_need": "gallery",
947
+ "file_path": null,
948
+ "live_status": "unknown",
949
+ "checked_at": null,
950
+ "note": "no file_path recorded on this entry -- nothing to check"
951
+ }
952
+ ]
953
+ }
954
+ ```
955
+
956
+ `live_status` is one of `"live"`, `"orphaned"`, `"unknown"`, or
957
+ (reserved, not yet produced -- see [Ledger integrity and decision
958
+ provenance](#ledger-integrity-and-decision-provenance)) `"dangling"`.
959
+ Entries with no `file_path` are listed but never checked or written to
960
+ `ledger_liveness.jsonl` -- their status is permanently `"unknown"` since
961
+ there's nothing to check. Results here are also layered onto
962
+ `read_ledger`'s `live_status`/`last_verified_live` fields for the same
963
+ entries afterward -- `check_ledger_liveness` is the only thing that
964
+ advances those fields past their write-time defaults.
965
+
966
+ ## Tool: `export_ledger_provenance`
967
+
968
+ Formats one ledger entry -- requirements checklist, candidates compared,
969
+ verdict, confidence, `snapshot_ref` -- as a single markdown block: a
970
+ stable, portable record of that decision you can paste into a PR
971
+ description or issue by hand. See [Ledger integrity and decision
972
+ provenance](#ledger-integrity-and-decision-provenance) for the full
973
+ design and its deliberate limits.
974
+
975
+ Pure and deterministic: the same entry always produces byte-identical
976
+ markdown, since the function reads nothing but its input (no live system
977
+ time, no disk state). This only formats and returns text -- it does not
978
+ post anything to GitHub or anywhere else; that's a separate action, not
979
+ yet built.
980
+
981
+ ### Input
982
+
983
+ ```json
984
+ {
985
+ "project_id": "my-booking-app",
986
+ "ledger_entry_id": "a1b2c3d4-..."
987
+ }
988
+ ```
989
+
990
+ Both fields are required -- unlike `check_ledger_liveness`, there's no
991
+ "every entry for this project" mode, since a provenance artifact is
992
+ inherently about one specific decision.
993
+
994
+ ### Output
995
+
996
+ ```json
997
+ {
998
+ "ledger_entry_id": "a1b2c3d4-...",
999
+ "markdown": "## Pattern decision: cancellation policy display with refund tiers by date\n\n- **Verdict:** use_existing (confidence: high)\n- **Reason:** scored\n- **Coverage:** 5/8 (62.5%)\n- **Domain:** Airbnb-style rental marketplace\n- **Framework:** React + Tailwind\n- **Snapshot:** `9f3a1c7e2b0d4f5a6b7c8d9e0f1a2b3c4d5e6f70`\n- **Judged at:** 2026-08-29T19:50:47.073Z\n\n### Requirements checked\n- ...\n\n### Candidates compared\n| Source | Name | Coverage | Chosen |\n| --- | --- | --- | --- |\n| ReUI (reui.io) | Timeline | 62.5 | ✓ |\n\n_Generated by Pattern (`export_ledger_provenance`) from ledger entry `a1b2c3d4-...`._"
1000
+ }
1001
+ ```
1002
+
1003
+ Errors (as `isError: true`, not a thrown exception) when `ledger_entry_id`
1004
+ doesn't match any entry for that `project_id` -- including when the id is
1005
+ real but belongs to a different project, since entries are always scoped
1006
+ per `project_id`.
1007
+
1008
+ For a `custom_build` verdict, the candidates section explains that gap in
1009
+ prose instead of an empty table -- Pattern doesn't persist the
1010
+ custom-build reference (Mobbin/Figma) to the ledger (see
1011
+ [`distillCandidate`](#data-minimization)), so it can't reproduce it here.
1012
+ A `null` `snapshot_ref` (project root wasn't a git repository at judgment
1013
+ time) renders as prose too, not the literal word `null`.
1014
+
867
1015
  ## Feature cost attribution
868
1016
 
869
1017
  Every `recommend_component` call that writes to the ledger -- a fresh
@@ -979,6 +1127,102 @@ convention: a raw or extended object throws rather than silently
979
1127
  persisting. Run `node scripts/verify-ledger-boundary.mjs` (after
980
1128
  `npm run build`) to check this boundary directly.
981
1129
 
1130
+ ## Ledger integrity and decision provenance
1131
+
1132
+ Two gaps in the ledger, surfaced from user feedback: it tracks that a
1133
+ decision was made, but not whether the thing it decided about is still
1134
+ live in your codebase, and it stores the checklist/verdict but not a
1135
+ version pin or an exportable artifact you can attach to a PR or issue.
1136
+ This section covers what's shipped so far -- **P0/P1 of both halves**, not
1137
+ the full spec. See `pattern-ledger-integrity-and-provenance-spec.md` for
1138
+ the complete phased plan; P2/P3 (a scheduled/batch sweep, dangling-cluster
1139
+ detection, the provenance-artifact exporter, and GitHub PR/issue posting)
1140
+ are not built yet.
1141
+
1142
+ **This is the one deliberate exception** to Pattern otherwise having [no
1143
+ filesystem/git access to your repo](#per-project-judgment-ledger) at all
1144
+ (the principle `report_build_cost`/`report_outcome_proxy` are built
1145
+ around). It's narrow on purpose:
1146
+
1147
+ - `git rev-parse HEAD` (read-only, never touches repo state) to capture
1148
+ `snapshot_ref` on every ledger write.
1149
+ - `fs.existsSync` plus a plain-text read of one file, only for a
1150
+ `file_path` you explicitly passed to `recommend_component`, only inside
1151
+ `PROJECT_ROOT` (see below), to answer `check_ledger_liveness`.
1152
+
1153
+ Nothing here runs an arbitrary git or shell command, and nothing writes to
1154
+ your repo.
1155
+
1156
+ ### `PROJECT_ROOT`
1157
+
1158
+ Defaults to `process.cwd()` -- for a locally-run stdio MCP server, that's
1159
+ normally the consuming repo's root, since MCP hosts typically launch the
1160
+ server with the project directory as its working directory. Override with
1161
+ `PATTERN_PROJECT_ROOT` if that assumption doesn't hold for your setup.
1162
+
1163
+ A `file_path` that's absolute or escapes `PROJECT_ROOT` via `../` resolves
1164
+ to `live_status: "unknown"` rather than being read -- belt-and-suspenders,
1165
+ since the calling agent already has real filesystem access to its own
1166
+ machine regardless.
1167
+
1168
+ ### Decision provenance: `snapshot_ref`
1169
+
1170
+ Every ledger entry -- fresh judgment or [ledger cache
1171
+ hit](#the-cache-hit-exception) -- now carries `snapshot_ref`: the commit
1172
+ SHA of `PROJECT_ROOT` at the moment that line was written, or `null` when
1173
+ `PROJECT_ROOT` isn't a git repo (or `git` isn't installed, or the call
1174
+ times out) -- this never fails the underlying `recommend_component` call.
1175
+ Entries written before this shipped read back with `snapshot_ref: null`.
1176
+
1177
+ A cache-hit entry's `snapshot_ref` reflects the codebase state *when that
1178
+ cache-hit line was written*, not the original judgment's -- to see the
1179
+ original judgment's snapshot, look up the entry named in its
1180
+ `ledger_entry_id`/`original_verdict_timestamp` fields instead.
1181
+
1182
+ [`export_ledger_provenance`](#tool-export_ledger_provenance) packages one
1183
+ entry's full record -- checklist, candidates, verdict, `snapshot_ref` --
1184
+ into a markdown block you can paste into a PR or issue by hand.
1185
+
1186
+ Not yet built (P2-P3 of Feature 2): the MCP action to post that artifact
1187
+ to a GitHub PR/issue automatically (blocked on an open question -- personal
1188
+ token vs. GitHub App -- see BACKLOG.md), and provenance backfill for
1189
+ entries that predate `snapshot_ref`.
1190
+
1191
+ ### Referential integrity: `file_path` / `live_status`
1192
+
1193
+ `recommend_component` optionally accepts `file_path` (see [Tool:
1194
+ `recommend_component`](#tool-recommend_component)) -- usually not known at
1195
+ call time, since the decision typically precedes the file existing. When
1196
+ set, [`check_ledger_liveness`](#tool-check_ledger_liveness) can later
1197
+ check whether that file still exists and still mentions
1198
+ `chosen_candidate`:
1199
+
1200
+ - **`live`** -- the file exists and mentions `chosen_candidate`.
1201
+ - **`orphaned`** -- `file_path` is set but the file no longer exists.
1202
+ - **`unknown`** -- no `file_path` was ever recorded, the path escapes
1203
+ `PROJECT_ROOT`, or the file exists but `chosen_candidate` can't be
1204
+ confirmed in it. Deliberately the default outcome for anything
1205
+ ambiguous: a false `"orphaned"` is worse than a lingering `"unknown"`.
1206
+ - **`dangling`** -- reserved, not yet produced. Feature 1's second
1207
+ staleness type (a cluster of entries that only reference each other,
1208
+ with no live anchor anywhere) is graph-level analysis across the whole
1209
+ ledger, not a single-entry check -- P3, not built here.
1210
+
1211
+ Checks are on-demand only right now (call `check_ledger_liveness`
1212
+ yourself, or on whatever schedule you want) -- there's no automatic
1213
+ sweep. `live_status`/`last_verified_live` start `"unknown"`/`null` on
1214
+ every entry at write time and only ever advance via a
1215
+ `check_ledger_liveness` call; results are stored append-only in
1216
+ `~/.pattern/ledger_liveness.jsonl` (override with
1217
+ `PATTERN_LEDGER_LIVENESS_PATH`, same "append, never mutate the source
1218
+ line, most recent record wins at read time" convention as
1219
+ `outcome_proxies.jsonl`, see [Outcome proxies](#outcome-proxies)) and
1220
+ layered onto `ledger.jsonl`'s own entries at read time -- the ledger line
1221
+ itself is never rewritten.
1222
+
1223
+ Not yet built (P2-P3 of Feature 1): a scheduled/batch sweep across an
1224
+ entire ledger, and dangling-cluster detection.
1225
+
982
1226
  ## Per-project decision memory
983
1227
 
984
1228
  Pattern stores confirmed decisions locally in:
package/dist/index.js CHANGED
@@ -30,10 +30,11 @@
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";
37
38
  import { captureApiError, captureRecommendation, printTelemetryNoticeOnce, shutdownTelemetry, } from "./telemetry.js";
38
39
  export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
39
40
  // Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
@@ -129,6 +130,64 @@ const MAX_DECISIONS_PER_PROJECT = 50;
129
130
  // call is about, only the caller-supplied project_id string.
130
131
  const LEDGER_PATH = process.env.PATTERN_LEDGER_PATH ?? join(homedir(), ".pattern", "ledger.jsonl");
131
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
+ }
132
191
  // Kill switch for the cache-hit short-circuit specifically -- does NOT
133
192
  // disable the ledger itself. Entries still get written and read_ledger
134
193
  // still works either way; this only controls whether judgeComponent is
@@ -382,6 +441,8 @@ const EXTRACT_REQUIREMENTS_TOOL_NAME = "extract_requirements";
382
441
  const READ_LEDGER_TOOL_NAME = "read_ledger";
383
442
  const REPORT_BUILD_COST_TOOL_NAME = "report_build_cost";
384
443
  const REPORT_OUTCOME_PROXY_TOOL_NAME = "report_outcome_proxy";
444
+ const CHECK_LEDGER_LIVENESS_TOOL_NAME = "check_ledger_liveness";
445
+ const EXPORT_LEDGER_PROVENANCE_TOOL_NAME = "export_ledger_provenance";
385
446
  const INPUT_SCHEMA = {
386
447
  type: "object",
387
448
  properties: {
@@ -441,6 +502,16 @@ const INPUT_SCHEMA = {
441
502
  "then land under the same id automatically, with no coordination " +
442
503
  "needed between calls. Only meaningful together with project_id.",
443
504
  },
505
+ file_path: {
506
+ type: "string",
507
+ description: "Optional. Path (relative to the project root) where this component " +
508
+ "decision is expected to be implemented, if already known -- usually " +
509
+ "not known yet at this call, since the decision typically precedes " +
510
+ "the file existing. When provided, it's stored on the resulting " +
511
+ "ledger entry and check_ledger_liveness can later confirm the file " +
512
+ "still exists and still references chosen_candidate. Omit if unknown; " +
513
+ "it cannot currently be attached to an entry after the fact.",
514
+ },
444
515
  },
445
516
  required: ["component_need", "domain", "framework"],
446
517
  };
@@ -592,6 +663,35 @@ const REPORT_OUTCOME_PROXY_INPUT_SCHEMA = {
592
663
  },
593
664
  required: ["feature_id"],
594
665
  };
666
+ const CHECK_LEDGER_LIVENESS_INPUT_SCHEMA = {
667
+ type: "object",
668
+ properties: {
669
+ project_id: {
670
+ type: "string",
671
+ description: "The project_id used in the recommend_component call(s) whose ledger entries you want live-checked.",
672
+ },
673
+ ledger_entry_id: {
674
+ type: "string",
675
+ description: "Optional. Check just this one entry (its id, from read_ledger) " +
676
+ "instead of every entry for project_id that has a file_path set.",
677
+ },
678
+ },
679
+ required: ["project_id"],
680
+ };
681
+ const EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA = {
682
+ type: "object",
683
+ properties: {
684
+ project_id: {
685
+ type: "string",
686
+ description: "The project_id used in the recommend_component call that produced this ledger entry.",
687
+ },
688
+ ledger_entry_id: {
689
+ type: "string",
690
+ description: "The specific entry to export, from read_ledger or check_ledger_liveness.",
691
+ },
692
+ },
693
+ required: ["project_id", "ledger_entry_id"],
694
+ };
595
695
  // Shared between buildSystemPrompt's own step 2 and
596
696
  // buildExtractionSystemPrompt (the extract_requirements tool's standalone
597
697
  // prompt) -- the extraction *instructions* are one piece of text reused
@@ -1243,6 +1343,125 @@ function deriveFeatureId(componentNeed, projectId, provided) {
1243
1343
  .digest("hex")
1244
1344
  .slice(0, 8);
1245
1345
  }
1346
+ // Overlay store for live-check results, same "append-only, latest-value-
1347
+ // per-key wins at read time, never mutate the source-of-truth line"
1348
+ // convention as outcome_proxies.jsonl/latestOutcomeProxy above -- a check
1349
+ // is a new observation, not a correction of the original ledger entry, so
1350
+ // ledger.jsonl itself stays untouched by it.
1351
+ const LEDGER_LIVENESS_PATH = process.env.PATTERN_LEDGER_LIVENESS_PATH ?? join(homedir(), ".pattern", "ledger_liveness.jsonl");
1352
+ function appendLedgerLivenessRecord(record) {
1353
+ mkdirSync(dirname(LEDGER_LIVENESS_PATH), { recursive: true });
1354
+ appendFileSync(LEDGER_LIVENESS_PATH, JSON.stringify(record) + "\n", "utf8");
1355
+ }
1356
+ function readLedgerLivenessRecords(ledgerEntryId) {
1357
+ let raw;
1358
+ try {
1359
+ raw = readFileSync(LEDGER_LIVENESS_PATH, "utf8");
1360
+ }
1361
+ catch {
1362
+ return [];
1363
+ }
1364
+ const records = [];
1365
+ for (const line of raw.split("\n")) {
1366
+ if (!line.trim())
1367
+ continue;
1368
+ try {
1369
+ const parsed = JSON.parse(line);
1370
+ if (parsed && typeof parsed === "object" && parsed.ledger_entry_id === ledgerEntryId) {
1371
+ records.push(parsed);
1372
+ }
1373
+ }
1374
+ catch {
1375
+ // skip malformed line
1376
+ }
1377
+ }
1378
+ return records;
1379
+ }
1380
+ function latestLiveness(ledgerEntryId) {
1381
+ const records = readLedgerLivenessRecords(ledgerEntryId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1382
+ return records[0] ?? null;
1383
+ }
1384
+ function withLatestLiveness(entry) {
1385
+ const latest = latestLiveness(entry.id);
1386
+ if (!latest)
1387
+ return entry;
1388
+ return { ...entry, live_status: latest.live_status, last_verified_live: latest.timestamp };
1389
+ }
1390
+ // Feature 1 / Referential Integrity, P1: the single-entry live-check.
1391
+ // Orphaned when file_path is set but the file no longer exists; live when
1392
+ // the file exists and (best-effort) still mentions chosen_candidate;
1393
+ // unknown when file_path was never supplied, escapes PROJECT_ROOT (see
1394
+ // resolveWithinRoot), or exists but the candidate name can't be confirmed
1395
+ // in its content -- conservative on purpose, per the spec's own risk
1396
+ // mitigation (a false "orphaned" is worse than a lingering "unknown").
1397
+ // "dangling" (an entry only cross-referenced by other ledger entries, no
1398
+ // live anchor anywhere) is graph-level analysis across the whole ledger,
1399
+ // not a single-entry check -- Feature 1 P3, not built here.
1400
+ function checkFileLiveStatus(entry) {
1401
+ if (!entry.file_path)
1402
+ return "unknown";
1403
+ const abs = resolveWithinRoot(PROJECT_ROOT, entry.file_path);
1404
+ if (!abs)
1405
+ return "unknown";
1406
+ if (!existsSync(abs))
1407
+ return "orphaned";
1408
+ if (!entry.chosen_candidate)
1409
+ return "live";
1410
+ try {
1411
+ const content = readFileSync(abs, "utf8");
1412
+ return content.toLowerCase().includes(entry.chosen_candidate.toLowerCase()) ? "live" : "unknown";
1413
+ }
1414
+ catch {
1415
+ return "unknown";
1416
+ }
1417
+ }
1418
+ function checkLedgerEntryLiveness(entry) {
1419
+ const record = {
1420
+ id: randomUUID(),
1421
+ timestamp: new Date().toISOString(),
1422
+ ledger_entry_id: entry.id,
1423
+ project_id: entry.project_id,
1424
+ live_status: checkFileLiveStatus(entry),
1425
+ checked_file_path: entry.file_path,
1426
+ };
1427
+ appendLedgerLivenessRecord(record);
1428
+ return record;
1429
+ }
1430
+ // check_ledger_liveness tool: on-demand invocation of the live-check above
1431
+ // (the design's "on demand via an MCP call" case -- a scheduled/batch
1432
+ // sweep is Feature 1 P2, not built here). Entries with no file_path are
1433
+ // reported but never checked/recorded -- their status is permanently
1434
+ // "unknown" by construction, so re-checking them on every call would only
1435
+ // grow ledger_liveness.jsonl without ever learning anything new.
1436
+ function checkLedgerLiveness(input) {
1437
+ const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
1438
+ const results = entries.map((e) => {
1439
+ if (!e.file_path) {
1440
+ return {
1441
+ ledger_entry_id: e.id,
1442
+ component_need: e.component_need,
1443
+ file_path: null,
1444
+ live_status: "unknown",
1445
+ checked_at: null,
1446
+ note: "no file_path recorded on this entry -- nothing to check",
1447
+ };
1448
+ }
1449
+ const record = checkLedgerEntryLiveness(e);
1450
+ return {
1451
+ ledger_entry_id: e.id,
1452
+ component_need: e.component_need,
1453
+ file_path: e.file_path,
1454
+ live_status: record.live_status,
1455
+ checked_at: record.timestamp,
1456
+ note: null,
1457
+ };
1458
+ });
1459
+ return {
1460
+ checked: results.filter((r) => r.checked_at !== null).length,
1461
+ total_entries: results.length,
1462
+ results,
1463
+ };
1464
+ }
1246
1465
  // Same "missing/malformed collapses to empty" philosophy as readMemory,
1247
1466
  // but line-oriented (JSONL) rather than whole-file JSON -- a single
1248
1467
  // corrupted line (e.g. a hand-edited file, or a write that got cut off)
@@ -1262,7 +1481,19 @@ function readLedgerEntries(projectId) {
1262
1481
  try {
1263
1482
  const parsed = JSON.parse(line);
1264
1483
  if (parsed && typeof parsed === "object" && parsed.project_id === projectId) {
1265
- entries.push(parsed);
1484
+ // Backward-compatible defaults for entries written before the
1485
+ // ledger integrity/provenance fields existed -- a missing key
1486
+ // (not merely a null one) falls back to these rather than
1487
+ // `undefined` leaking into the returned shape.
1488
+ const rawEntry = parsed;
1489
+ const normalized = {
1490
+ ...rawEntry,
1491
+ file_path: rawEntry.file_path ?? null,
1492
+ snapshot_ref: rawEntry.snapshot_ref ?? null,
1493
+ last_verified_live: rawEntry.last_verified_live ?? null,
1494
+ live_status: rawEntry.live_status ?? "unknown",
1495
+ };
1496
+ entries.push(withLatestLiveness(normalized));
1266
1497
  }
1267
1498
  }
1268
1499
  catch {
@@ -1325,6 +1556,54 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1325
1556
  entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1326
1557
  return entries.slice(0, limit);
1327
1558
  }
1559
+ // Feature 2 / Decision Provenance, P1: renders one ledger entry as a
1560
+ // stable markdown block -- "stable" meaning a pure function of the entry
1561
+ // alone (never Date.now(), never anything read live off disk), so the
1562
+ // same entry always produces byte-identical markdown. That determinism is
1563
+ // what makes verify-provenance-artifact.mjs's snapshot test meaningful:
1564
+ // a diff in the generated markdown for a fixed fixture means the format
1565
+ // changed, not that time passed. Markdown, not JSON, per the spec --
1566
+ // PRs/issues render it natively (P2, not built here, attaches this to
1567
+ // one).
1568
+ export function formatProvenanceArtifact(entry) {
1569
+ const lines = [];
1570
+ lines.push(`## Pattern decision: ${entry.component_need}`);
1571
+ lines.push("");
1572
+ lines.push(`- **Verdict:** ${entry.verdict} (confidence: ${entry.confidence})`);
1573
+ lines.push(`- **Reason:** ${entry.reason}`);
1574
+ lines.push(`- **Coverage:** ${entry.coverage ?? "n/a"}`);
1575
+ lines.push(`- **Domain:** ${entry.domain}`);
1576
+ lines.push(`- **Framework:** ${entry.framework}`);
1577
+ lines.push(`- **Snapshot:** ${entry.snapshot_ref ? "`" + entry.snapshot_ref + "`" : "not available (project root wasn't a git repository at judgment time)"}`);
1578
+ lines.push(`- **Judged at:** ${entry.timestamp}${entry.cache_hit ? " (served from ledger cache hit)" : ""}`);
1579
+ lines.push("");
1580
+ lines.push("### Requirements checked");
1581
+ if (entry.checklist.length === 0) {
1582
+ lines.push("_No checklist recorded for this entry._");
1583
+ }
1584
+ else {
1585
+ for (const item of entry.checklist)
1586
+ lines.push(`- ${item}`);
1587
+ }
1588
+ lines.push("");
1589
+ lines.push("### Candidates compared");
1590
+ if (entry.candidates_evaluated.length === 0) {
1591
+ lines.push(entry.verdict === "custom_build"
1592
+ ? "_No existing candidate met the bar -- Pattern recommended a custom build. Pattern doesn't persist the custom-build reference (Mobbin/Figma) to the ledger, so it isn't reproducible here._"
1593
+ : "_No candidates recorded for this entry._");
1594
+ }
1595
+ else {
1596
+ lines.push("| Source | Name | Coverage | Chosen |");
1597
+ lines.push("| --- | --- | --- | --- |");
1598
+ for (const c of entry.candidates_evaluated) {
1599
+ const chosen = c.name !== null && c.name === entry.chosen_candidate ? "✓" : "";
1600
+ lines.push(`| ${c.source ?? "n/a"} | ${c.name ?? "n/a"} | ${c.coverage_pct ?? "n/a"} | ${chosen} |`);
1601
+ }
1602
+ }
1603
+ lines.push("");
1604
+ lines.push(`_Generated by Pattern (\`export_ledger_provenance\`) from ledger entry \`${entry.id}\`._`);
1605
+ return lines.join("\n");
1606
+ }
1328
1607
  // report_build_cost (cost-attribution build plan, 1.3) -- self-reported
1329
1608
  // build cost, cheapest option first, since Pattern has no visibility into
1330
1609
  // what happens after judgeComponent returns a verdict (1.4's
@@ -1562,6 +1841,19 @@ function buildLedgerEntry(input, projectId, result, opts) {
1562
1841
  cost_usd: opts.costUsd,
1563
1842
  cache_hit: opts.cacheHit,
1564
1843
  project_conventions_snapshot: hashConventions(input.existing_stack),
1844
+ // Feature 2 P0: captured fresh for every entry (cache hits included),
1845
+ // not inherited from a matched ledger_cache_hit -- this reflects the
1846
+ // codebase state at the moment *this line* was written, not the
1847
+ // moment the original judgment ran (see PROJECT_ROOT above).
1848
+ snapshot_ref: computeSnapshotRef(PROJECT_ROOT),
1849
+ // Feature 1 P0: caller-supplied at write time (recommend_component's
1850
+ // optional file_path), null when not yet known -- typically the case,
1851
+ // since the decision is usually made before the file exists. Always
1852
+ // starts "unknown"/unchecked; check_ledger_liveness fills these in
1853
+ // later via the ledger_liveness.jsonl overlay (see withLatestLiveness).
1854
+ file_path: input.file_path ?? null,
1855
+ last_verified_live: null,
1856
+ live_status: "unknown",
1565
1857
  };
1566
1858
  }
1567
1859
  async function judgeComponent(input) {
@@ -2230,6 +2522,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2230
2522
  "This only appends a local record; it never calls the Anthropic API.",
2231
2523
  inputSchema: REPORT_OUTCOME_PROXY_INPUT_SCHEMA,
2232
2524
  },
2525
+ {
2526
+ name: CHECK_LEDGER_LIVENESS_TOOL_NAME,
2527
+ description: "Checks whether recommend_component ledger entries for a project_id " +
2528
+ "are still 'live' -- the file_path recorded on the entry (if any) " +
2529
+ "still exists and still mentions chosen_candidate. Requires real, " +
2530
+ "read-only filesystem access to PROJECT_ROOT (defaults to this " +
2531
+ "server's working directory; override with PATTERN_PROJECT_ROOT) -- " +
2532
+ "this is the one exception to Pattern otherwise having no " +
2533
+ "filesystem access to a caller's repo (see report_build_cost/" +
2534
+ "report_outcome_proxy above). Entries with no file_path are listed " +
2535
+ "but not checked -- their status is permanently 'unknown' since " +
2536
+ "there's nothing to check. Never writes to your repo, never runs " +
2537
+ "an arbitrary git/shell command beyond `git rev-parse HEAD` " +
2538
+ "elsewhere in this server. Results are also layered onto " +
2539
+ "read_ledger's live_status/last_verified_live fields for the same " +
2540
+ "entries afterward.",
2541
+ inputSchema: CHECK_LEDGER_LIVENESS_INPUT_SCHEMA,
2542
+ },
2543
+ {
2544
+ name: EXPORT_LEDGER_PROVENANCE_TOOL_NAME,
2545
+ description: "Formats one ledger entry (requirements checklist, candidates " +
2546
+ "compared, verdict, confidence, snapshot_ref) as a single markdown " +
2547
+ "block -- a stable, portable record of that decision you can paste " +
2548
+ "into a PR description or issue by hand. Pure and deterministic: " +
2549
+ "the same entry always produces the same markdown, nothing here " +
2550
+ "reads live system time or disk state. This only formats and " +
2551
+ "returns text; it does not post anything to GitHub or anywhere " +
2552
+ "else -- that's a separate, not-yet-built action.",
2553
+ inputSchema: EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA,
2554
+ },
2233
2555
  ],
2234
2556
  }));
2235
2557
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -2358,6 +2680,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2358
2680
  };
2359
2681
  }
2360
2682
  }
2683
+ if (request.params.name === CHECK_LEDGER_LIVENESS_TOOL_NAME) {
2684
+ const args = request.params.arguments;
2685
+ try {
2686
+ const result = checkLedgerLiveness(args);
2687
+ return {
2688
+ content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...result }) }],
2689
+ };
2690
+ }
2691
+ catch (err) {
2692
+ const message = err instanceof Error ? err.message : String(err);
2693
+ return {
2694
+ content: [{ type: "text", text: `Error: ${message}` }],
2695
+ isError: true,
2696
+ };
2697
+ }
2698
+ }
2699
+ if (request.params.name === EXPORT_LEDGER_PROVENANCE_TOOL_NAME) {
2700
+ const args = request.params.arguments;
2701
+ try {
2702
+ const entry = readLedgerEntries(args.project_id).find((e) => e.id === args.ledger_entry_id);
2703
+ if (!entry) {
2704
+ throw new Error(`No ledger entry with id "${args.ledger_entry_id}" found for project_id "${args.project_id}". Use read_ledger to list entries and their ids.`);
2705
+ }
2706
+ return {
2707
+ content: [
2708
+ { type: "text", text: JSON.stringify({ ledger_entry_id: entry.id, markdown: formatProvenanceArtifact(entry) }) },
2709
+ ],
2710
+ };
2711
+ }
2712
+ catch (err) {
2713
+ const message = err instanceof Error ? err.message : String(err);
2714
+ return {
2715
+ content: [{ type: "text", text: `Error: ${message}` }],
2716
+ isError: true,
2717
+ };
2718
+ }
2719
+ }
2361
2720
  throw new Error(`Unknown tool: ${request.params.name}`);
2362
2721
  });
2363
2722
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/ui, 21st.dev, or ReUI component or requires a custom build, using field/requirement coverage scored against real component code.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",