pattern-mcp 0.5.0 → 0.7.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 +334 -27
  2. package/dist/index.js +605 -13
  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 seven tools:
35
+ It exposes eleven tools:
36
36
 
37
37
  - `recommend_component` — evaluates a UI component need and returns a
38
38
  structured recommendation.
@@ -57,6 +57,23 @@ It exposes seven tools:
57
57
  - `check_ledger_liveness` — checks whether a ledger entry's recorded
58
58
  `file_path` still exists and still references its `chosen_candidate` --
59
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).
64
+ - `post_ledger_provenance_to_github` — posts that same artifact as a real
65
+ comment on a GitHub PR/issue, idempotently -- see [Tool:
66
+ `post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github).
67
+ The one tool here with a real, visible side effect outside your own
68
+ machine; confirm with the user before calling it.
69
+ - `sweep_ledger_liveness` — batch version of `check_ledger_liveness`
70
+ across a whole project (or every project in the ledger), plus
71
+ dangling-cluster detection -- see [Tool:
72
+ `sweep_ledger_liveness`](#tool-sweep_ledger_liveness). Meant to be
73
+ invoked by your own cron/CI, not something Pattern schedules itself.
74
+ - `backfill_ledger_snapshot_ref` — best-effort `snapshot_ref`
75
+ reconstruction for entries written before that field existed -- see
76
+ [Tool: `backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref).
60
77
 
61
78
  ## How it works
62
79
 
@@ -735,14 +752,16 @@ came back with `served_from_ledger: true`.
735
752
  "file_path": null,
736
753
  "snapshot_ref": "a1b2c3d4e5f6...",
737
754
  "last_verified_live": null,
738
- "live_status": "unknown"
755
+ "live_status": "unknown",
756
+ "reconstructed_snapshot_ref": null
739
757
  }
740
758
  ]
741
759
  }
742
760
  ```
743
761
 
744
- `file_path`/`snapshot_ref`/`last_verified_live`/`live_status` are the
745
- ledger integrity + decision provenance fields -- see [Ledger integrity and
762
+ `file_path`/`snapshot_ref`/`last_verified_live`/`live_status`/
763
+ `reconstructed_snapshot_ref` are the ledger integrity + decision
764
+ provenance fields -- see [Ledger integrity and
746
765
  decision provenance](#ledger-integrity-and-decision-provenance) and [Tool:
747
766
  `check_ledger_liveness`](#tool-check_ledger_liveness). Entries written
748
767
  before this feature shipped read back with `file_path`/`snapshot_ref`/
@@ -950,8 +969,11 @@ an arbitrary shell command.
950
969
  ```
951
970
 
952
971
  `live_status` is one of `"live"`, `"orphaned"`, `"unknown"`, or
953
- (reserved, not yet produced -- see [Ledger integrity and decision
954
- provenance](#ledger-integrity-and-decision-provenance)) `"dangling"`.
972
+ `"dangling"` (only ever produced by
973
+ [`sweep_ledger_liveness`](#tool-sweep_ledger_liveness)'s cluster
974
+ detection, never by a single-entry `check_ledger_liveness` call -- see
975
+ [Ledger integrity and decision
976
+ provenance](#ledger-integrity-and-decision-provenance)).
955
977
  Entries with no `file_path` are listed but never checked or written to
956
978
  `ledger_liveness.jsonl` -- their status is permanently `"unknown"` since
957
979
  there's nothing to check. Results here are also layered onto
@@ -959,6 +981,249 @@ there's nothing to check. Results here are also layered onto
959
981
  entries afterward -- `check_ledger_liveness` is the only thing that
960
982
  advances those fields past their write-time defaults.
961
983
 
984
+ ## Tool: `export_ledger_provenance`
985
+
986
+ Formats one ledger entry -- requirements checklist, candidates compared,
987
+ verdict, confidence, `snapshot_ref` -- as a single markdown block: a
988
+ stable, portable record of that decision you can paste into a PR
989
+ description or issue by hand. See [Ledger integrity and decision
990
+ provenance](#ledger-integrity-and-decision-provenance) for the full
991
+ design and its deliberate limits.
992
+
993
+ Pure and deterministic: the same entry always produces byte-identical
994
+ markdown, since the function reads nothing but its input (no live system
995
+ time, no disk state). This only formats and returns text -- it does not
996
+ post anything to GitHub or anywhere else; that's a separate action, not
997
+ yet built.
998
+
999
+ ### Input
1000
+
1001
+ ```json
1002
+ {
1003
+ "project_id": "my-booking-app",
1004
+ "ledger_entry_id": "a1b2c3d4-..."
1005
+ }
1006
+ ```
1007
+
1008
+ Both fields are required -- unlike `check_ledger_liveness`, there's no
1009
+ "every entry for this project" mode, since a provenance artifact is
1010
+ inherently about one specific decision.
1011
+
1012
+ ### Output
1013
+
1014
+ ```json
1015
+ {
1016
+ "ledger_entry_id": "a1b2c3d4-...",
1017
+ "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-...`._"
1018
+ }
1019
+ ```
1020
+
1021
+ Errors (as `isError: true`, not a thrown exception) when `ledger_entry_id`
1022
+ doesn't match any entry for that `project_id` -- including when the id is
1023
+ real but belongs to a different project, since entries are always scoped
1024
+ per `project_id`.
1025
+
1026
+ For a `custom_build` verdict, the candidates section explains that gap in
1027
+ prose instead of an empty table -- Pattern doesn't persist the
1028
+ custom-build reference (Mobbin/Figma) to the ledger (see
1029
+ [`distillCandidate`](#data-minimization)), so it can't reproduce it here.
1030
+ A `null` `snapshot_ref` (project root wasn't a git repository at judgment
1031
+ time) renders as prose too, not the literal word `null`.
1032
+
1033
+ ## Tool: `post_ledger_provenance_to_github`
1034
+
1035
+ Posts one ledger entry's provenance artifact (the same content
1036
+ `export_ledger_provenance` produces) as a real comment on a GitHub PR or
1037
+ issue. **This is the one tool in this server with a real, visible side
1038
+ effect on a third-party service** -- every other tool here only ever
1039
+ touches local files. Confirm with the user before calling it, the same
1040
+ way you're expected to confirm before running a suggested
1041
+ `install_command` (see [Installation commands are not
1042
+ trusted](#installation-commands-are-not-trusted) and SECURITY.md).
1043
+
1044
+ GitHub treats a PR and an issue identically for comments (both use the
1045
+ same `/issues/{number}/comments` endpoint), so there's one input shape
1046
+ for both -- no separate "is this a PR" flag.
1047
+
1048
+ ### Auth: `GITHUB_TOKEN`, not a GitHub App
1049
+
1050
+ This resolves the open question left in [Ledger integrity and decision
1051
+ provenance](#ledger-integrity-and-decision-provenance)'s earlier writeup
1052
+ in favor of a **personal access token**, read from the `GITHUB_TOKEN`
1053
+ environment variable -- the same convention every GitHub Action and the
1054
+ `gh` CLI itself already use. Needs `repo` scope. A GitHub App was the
1055
+ alternative on the table, but it needs a hosted installation flow and a
1056
+ webhook receiver, which contradicts this project's entire distribution
1057
+ model (a local npm package, no hosted infrastructure -- see [Ledger
1058
+ integrity and decision provenance](#ledger-integrity-and-decision-provenance)
1059
+ and the Pattern Primer's build-order principle). Pattern manages no
1060
+ GitHub credential of its own, the same way it manages no git credential
1061
+ for `snapshot_ref` -- it just reads what's already in your environment.
1062
+
1063
+ ### Input
1064
+
1065
+ ```json
1066
+ {
1067
+ "project_id": "my-booking-app",
1068
+ "ledger_entry_id": "a1b2c3d4-...",
1069
+ "repo": "my-org/my-booking-app",
1070
+ "issue_number": 42
1071
+ }
1072
+ ```
1073
+
1074
+ All four fields are required.
1075
+
1076
+ ### Output
1077
+
1078
+ ```json
1079
+ {
1080
+ "posted": true,
1081
+ "comment_url": "https://github.com/my-org/my-booking-app/pull/42#issuecomment-...",
1082
+ "comment_id": 123456789
1083
+ }
1084
+ ```
1085
+
1086
+ ### Idempotent by construction
1087
+
1088
+ Every posted comment is prefixed with a hidden HTML marker keyed to the
1089
+ ledger entry's id (`<!-- pattern-ledger-provenance:<id> -->`). A call
1090
+ first checks the thread's existing comments (most recent 100 -- full
1091
+ pagination isn't handled yet) for that marker; if found, it returns
1092
+ `{ "posted": false, "reason": "already_posted", "comment_url": "..." }`
1093
+ pointing at the existing comment instead of creating a duplicate. A
1094
+ repeat call is always safe to make.
1095
+
1096
+ Errors (`isError: true`) clearly on: no `GITHUB_TOKEN` set, a malformed
1097
+ `repo` (not `owner/repo`), an unknown `ledger_entry_id`, or a GitHub API
1098
+ error (bad credentials, repo/issue not found, rate limit) -- the error
1099
+ message includes the real HTTP status and GitHub's own error text.
1100
+
1101
+ ## Tool: `sweep_ledger_liveness`
1102
+
1103
+ Batch version of [`check_ledger_liveness`](#tool-check_ledger_liveness):
1104
+ updates `live_status` for every `file_path`-bearing entry across an
1105
+ entire project, or -- when `project_id` is omitted -- every `project_id`
1106
+ present in the ledger. This is the "on a schedule (project open or cron)"
1107
+ half of the referential-integrity design that `check_ledger_liveness`'s
1108
+ on-demand, single-project call doesn't cover.
1109
+
1110
+ **Pattern has no daemon or scheduler of its own.** Each server invocation
1111
+ is transient, tied to its MCP host's lifecycle -- there is nowhere inside
1112
+ this server for a cron job to live. This tool is meant to be invoked by
1113
+ whatever external scheduler you already have (a cron job, a CI step
1114
+ running nightly), not something Pattern triggers automatically or ever
1115
+ will on its own.
1116
+
1117
+ ### Input
1118
+
1119
+ ```json
1120
+ {
1121
+ "project_id": "my-booking-app"
1122
+ }
1123
+ ```
1124
+
1125
+ `project_id` is optional -- omit it to sweep every `project_id` present
1126
+ in the ledger in one call.
1127
+
1128
+ ### Output
1129
+
1130
+ ```json
1131
+ {
1132
+ "projects_swept": 2,
1133
+ "total_entries_checked": 14,
1134
+ "dangling_clusters": [
1135
+ { "project_id": "my-booking-app", "feature_id": "3f9a21c0", "entry_ids": ["...", "..."] }
1136
+ ],
1137
+ "per_project": [
1138
+ { "project_id": "my-booking-app", "checked": 9, "total_entries": 12, "dangling_clusters": 1 },
1139
+ { "project_id": "other-project", "checked": 5, "total_entries": 5, "dangling_clusters": 0 }
1140
+ ]
1141
+ }
1142
+ ```
1143
+
1144
+ ### Dangling clusters, and how "cluster" maps onto what the ledger actually stores
1145
+
1146
+ The ledger has no explicit entry-to-entry reference field -- each line is
1147
+ an independent judgment record. `feature_id` (see [Feature cost
1148
+ attribution](#feature-cost-attribution)) is the one real grouping
1149
+ construct that already exists, so a "cluster" here means every entry
1150
+ sharing one `feature_id`, and "no live anchor" means none of them
1151
+ resolved to `live_status: "live"`. A single-entry group is just an
1152
+ ordinary orphaned/unknown entry, not a cluster phenomenon, so groups of
1153
+ one are never flagged.
1154
+
1155
+ Every entry in a qualifying cluster gets `live_status: "dangling"` --
1156
+ overriding whatever `"orphaned"`/`"unknown"` value it had -- visible on
1157
+ its next `read_ledger`/`check_ledger_liveness` read via the same
1158
+ `ledger_liveness.jsonl` overlay `check_ledger_liveness` already writes to
1159
+ (see [Referential integrity](#referential-integrity-file_path--live_status)).
1160
+ Tested against the exact repro shape reported by a user: 13 entries, 12
1161
+ sharing a `feature_id` with no live anchor among them, 1 separate and
1162
+ live -- all 12 flag `dangling`, the 13th doesn't. Also tested at 200 and
1163
+ 1,000 synthetic entries without reintroducing search+score-class latency
1164
+ (both complete in well under a second -- this is `fs.existsSync` calls
1165
+ and in-memory grouping, not API calls).
1166
+
1167
+ ## Tool: `backfill_ledger_snapshot_ref`
1168
+
1169
+ Best-effort reconstruction of `snapshot_ref` for ledger entries written
1170
+ before that field existed (or written outside a git repository): finds
1171
+ the commit that was `HEAD` at or just before each entry's own timestamp
1172
+ (`git log --before=<timestamp> -1 --format=%H`). Entries that already
1173
+ have a real `snapshot_ref` are reported but never touched -- backfill
1174
+ only ever fills a gap, never second-guesses a captured value.
1175
+
1176
+ ### Input
1177
+
1178
+ ```json
1179
+ {
1180
+ "project_id": "my-booking-app",
1181
+ "ledger_entry_id": "a1b2c3d4-..."
1182
+ }
1183
+ ```
1184
+
1185
+ `ledger_entry_id` is optional -- omit it to backfill every entry in the
1186
+ project missing `snapshot_ref`.
1187
+
1188
+ ### Output
1189
+
1190
+ ```json
1191
+ {
1192
+ "project_id": "my-booking-app",
1193
+ "attempted": 3,
1194
+ "reconstructed": 2,
1195
+ "results": [
1196
+ { "ledger_entry_id": "a1b2c3d4-...", "already_had_snapshot_ref": false, "reconstructed_snapshot_ref": "9f3a1c7e2b0d4f5a6b7c8d9e0f1a2b3c4d5e6f70" },
1197
+ { "ledger_entry_id": "e5f6a7b8-...", "already_had_snapshot_ref": false, "reconstructed_snapshot_ref": null }
1198
+ ]
1199
+ }
1200
+ ```
1201
+
1202
+ ### A reconstructed value is always labeled, never presented as real
1203
+
1204
+ Necessarily an approximation, not a guarantee: a rebase, force-push, or
1205
+ history rewrite since that timestamp can make "the commit `HEAD` pointed
1206
+ to then" no longer resolve to what the codebase actually looked like at
1207
+ judgment time. Every attempt is persisted (including failures -- a
1208
+ project whose git history doesn't reach back that far, or that isn't a
1209
+ git repository at all) to `~/.pattern/snapshot_backfill.jsonl` (override
1210
+ with `PATTERN_SNAPSHOT_BACKFILL_PATH`), and surfaces on later reads as
1211
+ `reconstructed_snapshot_ref` -- a field kept fully separate from
1212
+ `snapshot_ref` itself, never overwriting or being confused with it.
1213
+ [`export_ledger_provenance`](#tool-export_ledger_provenance) and
1214
+ [`post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github)
1215
+ both render a reconstructed value with an explicit "(reconstructed via
1216
+ backfill -- best-effort approximation, not the original captured
1217
+ snapshot)" label, never silently as if it were equivalent to a value
1218
+ captured live.
1219
+
1220
+ Tested against a real throwaway git repo with known commit history (an
1221
+ entry timestamped between two real commits reconstructs to exactly the
1222
+ first one), a 200-entry synthetic ledger outside any git repo (every
1223
+ attempt fails fast and reports `null` rather than throwing), and a
1224
+ read-only run against this project's own real `coop-commerce` ledger
1225
+ entries, per the spec's own test plan.
1226
+
962
1227
  ## Feature cost attribution
963
1228
 
964
1229
  Every `recommend_component` call that writes to the ledger -- a fresh
@@ -1126,8 +1391,15 @@ cache-hit line was written*, not the original judgment's -- to see the
1126
1391
  original judgment's snapshot, look up the entry named in its
1127
1392
  `ledger_entry_id`/`original_verdict_timestamp` fields instead.
1128
1393
 
1129
- Not yet built (P1-P3 of Feature 2): a markdown export of one entry's full
1130
- record for attaching to a PR/issue, and the GitHub posting action itself.
1394
+ [`export_ledger_provenance`](#tool-export_ledger_provenance) packages one
1395
+ entry's full record -- checklist, candidates, verdict, `snapshot_ref` --
1396
+ into a markdown block you can paste into a PR or issue by hand.
1397
+ [`post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github)
1398
+ posts that same artifact automatically, idempotently, using a personal
1399
+ `GITHUB_TOKEN` rather than a GitHub App (see that tool's docs for why).
1400
+ [`backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref)
1401
+ reconstructs a best-effort `snapshot_ref` for entries that predate the
1402
+ field, always clearly labeled as reconstructed wherever it's rendered.
1131
1403
 
1132
1404
  ### Referential integrity: `file_path` / `live_status`
1133
1405
 
@@ -1144,26 +1416,26 @@ check whether that file still exists and still mentions
1144
1416
  `PROJECT_ROOT`, or the file exists but `chosen_candidate` can't be
1145
1417
  confirmed in it. Deliberately the default outcome for anything
1146
1418
  ambiguous: a false `"orphaned"` is worse than a lingering `"unknown"`.
1147
- - **`dangling`** -- reserved, not yet produced. Feature 1's second
1148
- staleness type (a cluster of entries that only reference each other,
1149
- with no live anchor anywhere) is graph-level analysis across the whole
1150
- ledger, not a single-entry check -- P3, not built here.
1151
-
1152
- Checks are on-demand only right now (call `check_ledger_liveness`
1153
- yourself, or on whatever schedule you want) -- there's no automatic
1154
- sweep. `live_status`/`last_verified_live` start `"unknown"`/`null` on
1155
- every entry at write time and only ever advance via a
1156
- `check_ledger_liveness` call; results are stored append-only in
1157
- `~/.pattern/ledger_liveness.jsonl` (override with
1158
- `PATTERN_LEDGER_LIVENESS_PATH`, same "append, never mutate the source
1159
- line, most recent record wins at read time" convention as
1419
+ - **`dangling`** -- only ever produced by
1420
+ [`sweep_ledger_liveness`](#tool-sweep_ledger_liveness), never by
1421
+ `check_ledger_liveness` on its own: a cluster of 2+ entries sharing a
1422
+ `feature_id` where none of them resolved to `"live"`. Graph-level
1423
+ analysis across a project's whole entry set, not a single-entry check
1424
+ -- see that tool's docs for why `feature_id` is the grouping used.
1425
+
1426
+ `check_ledger_liveness` remains on-demand and single-project;
1427
+ [`sweep_ledger_liveness`](#tool-sweep_ledger_liveness) is the
1428
+ scheduled/batch counterpart -- meant to be invoked by your own cron/CI,
1429
+ since Pattern has no scheduler of its own. `live_status`/`last_verified_live`
1430
+ start `"unknown"`/`null` on every entry at write time and only ever
1431
+ advance via a `check_ledger_liveness`/`sweep_ledger_liveness` call;
1432
+ results are stored append-only in `~/.pattern/ledger_liveness.jsonl`
1433
+ (override with `PATTERN_LEDGER_LIVENESS_PATH`, same "append, never mutate
1434
+ the source line, most recent record wins at read time" convention as
1160
1435
  `outcome_proxies.jsonl`, see [Outcome proxies](#outcome-proxies)) and
1161
1436
  layered onto `ledger.jsonl`'s own entries at read time -- the ledger line
1162
1437
  itself is never rewritten.
1163
1438
 
1164
- Not yet built (P2-P3 of Feature 1): a scheduled/batch sweep across an
1165
- entire ledger, and dangling-cluster detection.
1166
-
1167
1439
  ## Per-project decision memory
1168
1440
 
1169
1441
  Pattern stores confirmed decisions locally in:
@@ -1392,6 +1664,38 @@ Pattern caches its system instructions using `cache_control: ephemeral`.
1392
1664
  The instructions are the same across calls, so repeated requests don't
1393
1665
  pay the full input cost for that block.
1394
1666
 
1667
+ ### Measured cache and fetch behavior
1668
+
1669
+ `_meta.tokens_used.input_breakdown` splits input tokens into `fresh`,
1670
+ `cache_write`, and `cache_read` (see [The `_meta`
1671
+ field](#the-_meta-field)) -- added specifically to check assumptions
1672
+ about caching against real numbers rather than guessing. Two real
1673
+ findings so far:
1674
+
1675
+ - **A single, non-repeat call is not "all fresh."** The working
1676
+ assumption had been that only exact-repeat calls (the [ledger cache
1677
+ hit](#the-cache-hit-exception)) benefit from caching at all. A live
1678
+ test disproved that: a fresh, non-repeat toast-component call came back
1679
+ with roughly half its input tokens served from `cache_read`. A
1680
+ follow-up 4-case sample (2026-09-02, spanning a clean `use_existing`
1681
+ call, a `custom_build` call, and two historically boundary/inconsistent
1682
+ cases) confirmed this wasn't a fluke -- `cache_read` share stayed in a
1683
+ 46-63% band across all four, regardless of call shape.
1684
+ - **`fresh` (fully-priced, never-cached) tokens are driven by whether the
1685
+ call reaches step 6's Mobbin/Figma reference search, not by general
1686
+ complexity or the boundary-risk ensemble firing.** In that same
1687
+ 4-case sample, the two `use_existing` calls had negligible `fresh`
1688
+ tokens (0.2%); both `custom_build` calls (which searched Mobbin/Figma)
1689
+ had 24-27.5% `fresh` -- even though, in both of those cases, the actual
1690
+ Mobbin *fetch* failed (`url_not_accessible`, 0 bytes returned). That
1691
+ rules out fetched-page content size as the driver for this cost --
1692
+ it's the extra Mobbin/Figma-restricted *search* calls themselves. This
1693
+ is why [`PATTERN_FETCH_MAX_CONTENT_TOKENS`](#fetch-grounded-scoring-and-reference-verification)
1694
+ was trimmed (a fetch-content cap can't fix a search-call cost) rather
1695
+ than split per-step as originally considered, and why reducing
1696
+ Mobbin/Figma search overhead is tracked as its own, differently-scoped
1697
+ future item rather than folded into that change.
1698
+
1395
1699
  ### Search limits
1396
1700
 
1397
1701
  Pattern limits candidate discovery to 3 web searches -- one per source.
@@ -1429,9 +1733,12 @@ the category page), there's no safe fallback for an unverified met/not-met
1429
1733
  call, so nothing is silently corrected -- `scoring_fetch` just tells you
1430
1734
  whether the grounding actually ran.
1431
1735
 
1432
- A fetch can read up to 15,000 content tokens. `web_fetch` has no separate
1433
- per-call fee; the cost comes from the content added to the model's
1434
- context.
1736
+ A fetch can read up to `PATTERN_FETCH_MAX_CONTENT_TOKENS` content tokens
1737
+ (default 12,000 -- trimmed from 15,000 after a real instrumentation
1738
+ sample showed the largest actual fetched page was ~10.7k tokens, see
1739
+ [Measured cache and fetch behavior](#measured-cache-and-fetch-behavior)
1740
+ above). `web_fetch` has no separate per-call fee; the cost comes from the
1741
+ content added to the model's context.
1435
1742
 
1436
1743
  ### Choosing a cheaper model
1437
1744
 
package/dist/index.js CHANGED
@@ -59,6 +59,29 @@ const SEARCH_BUDGET = SEARCH_BUDGET_RAW.trim().toLowerCase() === "unlimited"
59
59
  }
60
60
  return parsed;
61
61
  })();
62
+ // Cost/latency reduction plan, step 2 (BACKLOG.md): trimmed from 15,000
63
+ // after a real 4-case instrumentation sample (2026-09-02) showed the
64
+ // largest actual fetched page was ~10.7k tokens (a shadcn doc page),
65
+ // comfortably under this new cap with headroom. Deliberately NOT split
66
+ // into separate step-4 (candidate-doc) vs. step-6 (Mobbin/Figma) caps as
67
+ // originally scoped: both steps share one web_fetch tool instance, so a
68
+ // real split would mean defining two separately-named web_fetch tools and
69
+ // trusting the model to pick the right one per step -- a real behavior
70
+ // risk for a saving the same sample disproved anyway. Both custom_build
71
+ // cases in that sample had elevated "fresh" (fully-priced, uncached)
72
+ // token counts even though their Mobbin fetch *failed*
73
+ // (url_not_accessible, 0 bytes returned) -- the cost driver there is the
74
+ // extra Mobbin/Figma-restricted search calls, not fetched content size,
75
+ // so this cap can't address it. That's tracked as a separate, differently
76
+ // -scoped backlog item, not folded into this one.
77
+ const FETCH_MAX_CONTENT_TOKENS_RAW = process.env.PATTERN_FETCH_MAX_CONTENT_TOKENS ?? "12000";
78
+ const FETCH_MAX_CONTENT_TOKENS = (() => {
79
+ const parsed = Number.parseInt(FETCH_MAX_CONTENT_TOKENS_RAW, 10);
80
+ if (!Number.isFinite(parsed) || parsed <= 0) {
81
+ throw new Error(`PATTERN_FETCH_MAX_CONTENT_TOKENS must be a positive integer, got: ${FETCH_MAX_CONTENT_TOKENS_RAW}`);
82
+ }
83
+ return parsed;
84
+ })();
62
85
  // Static skip-list: single-purpose primitives with no meaningful internal
63
86
  // structure to score coverage against. Decided in the product brief as a
64
87
  // starting point -- revisit once real usage data exists (see README).
@@ -188,6 +211,31 @@ function computeSnapshotRef(root) {
188
211
  return null;
189
212
  }
190
213
  }
214
+ // Feature 2 / Decision Provenance, P3: best-effort reconstruction of
215
+ // snapshot_ref for an entry written before that field existed (or written
216
+ // outside a git repo -- though a project that's never used git has
217
+ // nothing to reconstruct from either way). Finds the commit that was HEAD
218
+ // at or just before the entry's own timestamp. Necessarily an
219
+ // approximation, not a guarantee: a rebase, force-push, or history
220
+ // rewrite since that time can make "the commit HEAD pointed to then" no
221
+ // longer resolve to what the codebase actually looked like at judgment
222
+ // time -- exactly the risk the spec's own mitigation table already names.
223
+ // Read-only, same timeout/error-swallowing discipline as
224
+ // computeSnapshotRef above.
225
+ function reconstructSnapshotRef(root, atISOTimestamp) {
226
+ try {
227
+ const sha = execFileSync("git", ["log", `--before=${atISOTimestamp}`, "-1", "--format=%H"], {
228
+ cwd: root,
229
+ encoding: "utf8",
230
+ stdio: ["ignore", "pipe", "ignore"],
231
+ timeout: 2000,
232
+ }).trim();
233
+ return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
191
239
  // Kill switch for the cache-hit short-circuit specifically -- does NOT
192
240
  // disable the ledger itself. Entries still get written and read_ledger
193
241
  // still works either way; this only controls whether judgeComponent is
@@ -442,6 +490,10 @@ const READ_LEDGER_TOOL_NAME = "read_ledger";
442
490
  const REPORT_BUILD_COST_TOOL_NAME = "report_build_cost";
443
491
  const REPORT_OUTCOME_PROXY_TOOL_NAME = "report_outcome_proxy";
444
492
  const CHECK_LEDGER_LIVENESS_TOOL_NAME = "check_ledger_liveness";
493
+ const EXPORT_LEDGER_PROVENANCE_TOOL_NAME = "export_ledger_provenance";
494
+ const POST_LEDGER_PROVENANCE_TOOL_NAME = "post_ledger_provenance_to_github";
495
+ const SWEEP_LEDGER_LIVENESS_TOOL_NAME = "sweep_ledger_liveness";
496
+ const BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME = "backfill_ledger_snapshot_ref";
445
497
  const INPUT_SCHEMA = {
446
498
  type: "object",
447
499
  properties: {
@@ -677,6 +729,68 @@ const CHECK_LEDGER_LIVENESS_INPUT_SCHEMA = {
677
729
  },
678
730
  required: ["project_id"],
679
731
  };
732
+ const EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA = {
733
+ type: "object",
734
+ properties: {
735
+ project_id: {
736
+ type: "string",
737
+ description: "The project_id used in the recommend_component call that produced this ledger entry.",
738
+ },
739
+ ledger_entry_id: {
740
+ type: "string",
741
+ description: "The specific entry to export, from read_ledger or check_ledger_liveness.",
742
+ },
743
+ },
744
+ required: ["project_id", "ledger_entry_id"],
745
+ };
746
+ const POST_LEDGER_PROVENANCE_INPUT_SCHEMA = {
747
+ type: "object",
748
+ properties: {
749
+ project_id: {
750
+ type: "string",
751
+ description: "The project_id used in the recommend_component call that produced this ledger entry.",
752
+ },
753
+ ledger_entry_id: {
754
+ type: "string",
755
+ description: "The specific entry to post, from read_ledger or check_ledger_liveness.",
756
+ },
757
+ repo: {
758
+ type: "string",
759
+ description: 'GitHub repo in "owner/repo" form, e.g. "my-org/my-booking-app".',
760
+ },
761
+ issue_number: {
762
+ type: "number",
763
+ description: "The PR or issue number to comment on -- GitHub treats both identically for comments, so no separate type flag is needed.",
764
+ },
765
+ },
766
+ required: ["project_id", "ledger_entry_id", "repo", "issue_number"],
767
+ };
768
+ const SWEEP_LEDGER_LIVENESS_INPUT_SCHEMA = {
769
+ type: "object",
770
+ properties: {
771
+ project_id: {
772
+ type: "string",
773
+ description: "Optional. Scope the sweep to one project_id. Omit to sweep every " +
774
+ "project_id present in the ledger -- the whole-ledger, scheduler-driven " +
775
+ "mode this tool exists for.",
776
+ },
777
+ },
778
+ required: [],
779
+ };
780
+ const BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA = {
781
+ type: "object",
782
+ properties: {
783
+ project_id: {
784
+ type: "string",
785
+ description: "The project_id whose ledger entries to backfill.",
786
+ },
787
+ ledger_entry_id: {
788
+ type: "string",
789
+ description: "Optional. Backfill just this one entry instead of every entry for project_id missing snapshot_ref.",
790
+ },
791
+ },
792
+ required: ["project_id"],
793
+ };
680
794
  // Shared between buildSystemPrompt's own step 2 and
681
795
  // buildExtractionSystemPrompt (the extract_requirements tool's standalone
682
796
  // prompt) -- the extraction *instructions* are one piece of text reused
@@ -956,8 +1070,10 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
956
1070
  max_uses: 3,
957
1071
  // Category/browse pages can be large, and all we need from them
958
1072
  // is a permalink, not the full page -- caps token cost of a
959
- // fetch that turns out not to have a deep link after all.
960
- max_content_tokens: 15000,
1073
+ // fetch that turns out not to have a deep link after all. See
1074
+ // FETCH_MAX_CONTENT_TOKENS above for why this is 12,000, not the
1075
+ // original 15,000, and why it isn't split per-step.
1076
+ max_content_tokens: FETCH_MAX_CONTENT_TOKENS,
961
1077
  },
962
1078
  ],
963
1079
  });
@@ -1362,9 +1478,21 @@ function readLedgerLivenessRecords(ledgerEntryId) {
1362
1478
  }
1363
1479
  return records;
1364
1480
  }
1481
+ // Deliberately not a sort-then-take-first: readLedgerLivenessRecords
1482
+ // returns records in file/append order (oldest first), and a descending
1483
+ // sort by timestamp is NOT tie-safe -- JS's stable sort preserves the
1484
+ // original relative order among equal timestamps, so on a tie (two
1485
+ // records appended within the same millisecond, which sweepLedgerLiveness
1486
+ // does routinely -- a per-entry check followed immediately by a
1487
+ // dangling-cluster append for the same entry) it would silently return
1488
+ // the OLDER of the two. reduce with >= walks forward through true append
1489
+ // order and lets each later-appended tied record win, which is what
1490
+ // "latest" actually means here.
1365
1491
  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;
1492
+ const records = readLedgerLivenessRecords(ledgerEntryId);
1493
+ if (records.length === 0)
1494
+ return null;
1495
+ return records.reduce((latest, r) => (new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime() ? r : latest));
1368
1496
  }
1369
1497
  function withLatestLiveness(entry) {
1370
1498
  const latest = latestLiveness(entry.id);
@@ -1372,6 +1500,59 @@ function withLatestLiveness(entry) {
1372
1500
  return entry;
1373
1501
  return { ...entry, live_status: latest.live_status, last_verified_live: latest.timestamp };
1374
1502
  }
1503
+ // Feature 2 P3's overlay -- same append-only/latest-wins convention as
1504
+ // ledger_liveness.jsonl above, kept as a fully separate file/function pair
1505
+ // rather than folded into the liveness overlay: these two overlays answer
1506
+ // unrelated questions (is the file still there vs. what commit was this
1507
+ // judged against) and happen to share only their storage shape, not their
1508
+ // meaning.
1509
+ const SNAPSHOT_BACKFILL_PATH = process.env.PATTERN_SNAPSHOT_BACKFILL_PATH ?? join(homedir(), ".pattern", "snapshot_backfill.jsonl");
1510
+ function appendSnapshotBackfillRecord(record) {
1511
+ mkdirSync(dirname(SNAPSHOT_BACKFILL_PATH), { recursive: true });
1512
+ appendFileSync(SNAPSHOT_BACKFILL_PATH, JSON.stringify(record) + "\n", "utf8");
1513
+ }
1514
+ function readSnapshotBackfillRecords(ledgerEntryId) {
1515
+ let raw;
1516
+ try {
1517
+ raw = readFileSync(SNAPSHOT_BACKFILL_PATH, "utf8");
1518
+ }
1519
+ catch {
1520
+ return [];
1521
+ }
1522
+ const records = [];
1523
+ for (const line of raw.split("\n")) {
1524
+ if (!line.trim())
1525
+ continue;
1526
+ try {
1527
+ const parsed = JSON.parse(line);
1528
+ if (parsed && typeof parsed === "object" && parsed.ledger_entry_id === ledgerEntryId) {
1529
+ records.push(parsed);
1530
+ }
1531
+ }
1532
+ catch {
1533
+ // skip malformed line
1534
+ }
1535
+ }
1536
+ return records;
1537
+ }
1538
+ // Same tie-safety reasoning as latestLiveness above.
1539
+ function latestSnapshotBackfill(ledgerEntryId) {
1540
+ const records = readSnapshotBackfillRecords(ledgerEntryId);
1541
+ if (records.length === 0)
1542
+ return null;
1543
+ return records.reduce((latest, r) => (new Date(r.timestamp).getTime() >= new Date(latest.timestamp).getTime() ? r : latest));
1544
+ }
1545
+ // Only overlays onto entries that actually need it -- an entry with a
1546
+ // real snapshot_ref never consults the backfill overlay at all, so a
1547
+ // stray/stale backfill record can never shadow a genuine captured value.
1548
+ function withReconstructedSnapshotRef(entry) {
1549
+ if (entry.snapshot_ref)
1550
+ return entry;
1551
+ const latest = latestSnapshotBackfill(entry.id);
1552
+ if (!latest)
1553
+ return entry;
1554
+ return { ...entry, reconstructed_snapshot_ref: latest.reconstructed_snapshot_ref };
1555
+ }
1375
1556
  // Feature 1 / Referential Integrity, P1: the single-entry live-check.
1376
1557
  // Orphaned when file_path is set but the file no longer exists; live when
1377
1558
  // the file exists and (best-effort) still mentions chosen_candidate;
@@ -1379,9 +1560,10 @@ function withLatestLiveness(entry) {
1379
1560
  // resolveWithinRoot), or exists but the candidate name can't be confirmed
1380
1561
  // in its content -- conservative on purpose, per the spec's own risk
1381
1562
  // 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.
1563
+ // "dangling" (a cluster of entries with no live anchor anywhere among
1564
+ // them) is graph-level analysis across a whole project's entries, not a
1565
+ // single-entry check -- see detectDanglingClusters, part of
1566
+ // sweep_ledger_liveness (Feature 1 P2/P3), not this function.
1385
1567
  function checkFileLiveStatus(entry) {
1386
1568
  if (!entry.file_path)
1387
1569
  return "unknown";
@@ -1413,11 +1595,12 @@ function checkLedgerEntryLiveness(entry) {
1413
1595
  return record;
1414
1596
  }
1415
1597
  // 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.
1598
+ // (the design's "on demand via an MCP call" case -- see
1599
+ // sweepLedgerLiveness below for the scheduled/batch case, Feature 1 P2).
1600
+ // Entries with no file_path are reported but never checked/recorded --
1601
+ // their status is permanently "unknown" by construction, so re-checking
1602
+ // them on every call would only grow ledger_liveness.jsonl without ever
1603
+ // learning anything new.
1421
1604
  function checkLedgerLiveness(input) {
1422
1605
  const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
1423
1606
  const results = entries.map((e) => {
@@ -1447,6 +1630,37 @@ function checkLedgerLiveness(input) {
1447
1630
  results,
1448
1631
  };
1449
1632
  }
1633
+ // backfill_ledger_snapshot_ref tool (Feature 2 P3): attempts
1634
+ // reconstructSnapshotRef for every entry in a project that's missing a
1635
+ // real snapshot_ref, and persists each attempt to snapshot_backfill.jsonl
1636
+ // regardless of outcome -- a documented "we tried, here's what we found"
1637
+ // audit trail, not just a cache, since a failed reconstruction is itself
1638
+ // meaningful information (this project's git history doesn't reach back
1639
+ // that far, or PROJECT_ROOT isn't a git repo at all). Entries that
1640
+ // already have a real snapshot_ref are reported but never touched --
1641
+ // backfill only ever fills a gap, never second-guesses a captured value.
1642
+ function backfillLedgerSnapshotRefs(input) {
1643
+ const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
1644
+ const results = entries.map((e) => {
1645
+ if (e.snapshot_ref) {
1646
+ return { ledger_entry_id: e.id, already_had_snapshot_ref: true, reconstructed_snapshot_ref: null };
1647
+ }
1648
+ const reconstructed = reconstructSnapshotRef(PROJECT_ROOT, e.timestamp);
1649
+ appendSnapshotBackfillRecord({
1650
+ id: randomUUID(),
1651
+ timestamp: new Date().toISOString(),
1652
+ ledger_entry_id: e.id,
1653
+ project_id: e.project_id,
1654
+ reconstructed_snapshot_ref: reconstructed,
1655
+ });
1656
+ return { ledger_entry_id: e.id, already_had_snapshot_ref: false, reconstructed_snapshot_ref: reconstructed };
1657
+ });
1658
+ return {
1659
+ attempted: results.filter((r) => !r.already_had_snapshot_ref).length,
1660
+ reconstructed: results.filter((r) => r.reconstructed_snapshot_ref !== null).length,
1661
+ results,
1662
+ };
1663
+ }
1450
1664
  // Same "missing/malformed collapses to empty" philosophy as readMemory,
1451
1665
  // but line-oriented (JSONL) rather than whole-file JSON -- a single
1452
1666
  // corrupted line (e.g. a hand-edited file, or a write that got cut off)
@@ -1477,8 +1691,9 @@ function readLedgerEntries(projectId) {
1477
1691
  snapshot_ref: rawEntry.snapshot_ref ?? null,
1478
1692
  last_verified_live: rawEntry.last_verified_live ?? null,
1479
1693
  live_status: rawEntry.live_status ?? "unknown",
1694
+ reconstructed_snapshot_ref: rawEntry.reconstructed_snapshot_ref ?? null,
1480
1695
  };
1481
- entries.push(withLatestLiveness(normalized));
1696
+ entries.push(withReconstructedSnapshotRef(withLatestLiveness(normalized)));
1482
1697
  }
1483
1698
  }
1484
1699
  catch {
@@ -1487,6 +1702,109 @@ function readLedgerEntries(projectId) {
1487
1702
  }
1488
1703
  return entries;
1489
1704
  }
1705
+ // sweep_ledger_liveness (Feature 1 P2) needs every project_id present in
1706
+ // the ledger when none is specified -- readLedgerEntries always filters
1707
+ // to one project_id, so this is the one place that reads every line
1708
+ // unfiltered. Same "missing/malformed collapses to empty" tolerance as
1709
+ // readLedgerEntries itself.
1710
+ function listAllProjectIds() {
1711
+ let raw;
1712
+ try {
1713
+ raw = readFileSync(LEDGER_PATH, "utf8");
1714
+ }
1715
+ catch {
1716
+ return [];
1717
+ }
1718
+ const ids = new Set();
1719
+ for (const line of raw.split("\n")) {
1720
+ if (!line.trim())
1721
+ continue;
1722
+ try {
1723
+ const parsed = JSON.parse(line);
1724
+ if (parsed && typeof parsed === "object" && typeof parsed.project_id === "string") {
1725
+ ids.add(parsed.project_id);
1726
+ }
1727
+ }
1728
+ catch {
1729
+ // skip malformed line
1730
+ }
1731
+ }
1732
+ return [...ids];
1733
+ }
1734
+ // Feature 1 P3: the graph-level half of referential integrity that
1735
+ // checkFileLiveStatus's single-entry check can't do. Pattern's ledger has
1736
+ // no explicit entry-to-entry reference field (each line is an independent
1737
+ // judgment record) -- feature_id is the one real grouping construct that
1738
+ // already exists (deriveFeatureId), so a "cluster" here means every entry
1739
+ // sharing one feature_id, and "cross-linked with no live anchor" means
1740
+ // none of them resolved to live_status "live". A cluster of exactly one
1741
+ // entry is just an ordinary orphaned/unknown entry, not a cluster
1742
+ // phenomenon, so single-entry groups are never flagged.
1743
+ //
1744
+ // Must run after checkLedgerLiveness has updated live_status for the
1745
+ // same project -- otherwise this would be judging stale per-entry
1746
+ // statuses. sweepLedgerLiveness below enforces that ordering; this
1747
+ // function does not re-check individual entries itself.
1748
+ function detectDanglingClusters(projectId) {
1749
+ const entries = readLedgerEntries(projectId);
1750
+ const byFeature = new Map();
1751
+ for (const e of entries) {
1752
+ const group = byFeature.get(e.feature_id) ?? [];
1753
+ group.push(e);
1754
+ byFeature.set(e.feature_id, group);
1755
+ }
1756
+ const clusters = [];
1757
+ for (const [featureId, group] of byFeature) {
1758
+ if (group.length < 2)
1759
+ continue;
1760
+ if (group.some((e) => e.live_status === "live"))
1761
+ continue;
1762
+ clusters.push({ feature_id: featureId, entry_ids: group.map((e) => e.id) });
1763
+ for (const e of group) {
1764
+ appendLedgerLivenessRecord({
1765
+ id: randomUUID(),
1766
+ timestamp: new Date().toISOString(),
1767
+ ledger_entry_id: e.id,
1768
+ project_id: projectId,
1769
+ live_status: "dangling",
1770
+ checked_file_path: e.file_path,
1771
+ });
1772
+ }
1773
+ }
1774
+ return clusters;
1775
+ }
1776
+ // The MCP tool: batch-updates live_status across an entire ledger,
1777
+ // optionally scoped to one project_id, but sweeping every project_id
1778
+ // present when omitted -- the "on a schedule (project open or cron)" half
1779
+ // of the design that check_ledger_liveness's on-demand, single-project
1780
+ // call (P1) doesn't cover. Pattern has no daemon or background process of
1781
+ // its own to schedule this from (each server invocation is transient,
1782
+ // tied to its MCP host's lifecycle) -- this tool is meant to be invoked
1783
+ // by whatever external scheduler you already have (a cron job, a CI
1784
+ // step), not something Pattern triggers on its own.
1785
+ function sweepLedgerLiveness(input) {
1786
+ const projectIds = input.project_id ? [input.project_id] : listAllProjectIds();
1787
+ const perProject = [];
1788
+ const allDangling = [];
1789
+ for (const projectId of projectIds) {
1790
+ const liveness = checkLedgerLiveness({ project_id: projectId });
1791
+ const clusters = detectDanglingClusters(projectId);
1792
+ for (const c of clusters)
1793
+ allDangling.push({ project_id: projectId, ...c });
1794
+ perProject.push({
1795
+ project_id: projectId,
1796
+ checked: liveness.checked,
1797
+ total_entries: liveness.total_entries,
1798
+ dangling_clusters: clusters.length,
1799
+ });
1800
+ }
1801
+ return {
1802
+ projects_swept: projectIds.length,
1803
+ total_entries_checked: perProject.reduce((sum, p) => sum + p.checked, 0),
1804
+ dangling_clusters: allDangling,
1805
+ per_project: perProject,
1806
+ };
1807
+ }
1490
1808
  // The only entry point that writes ledger.jsonl. Validates every
1491
1809
  // candidate against the DistilledCandidate boundary before it ever touches
1492
1810
  // disk -- a raw object reaching here throws rather than silently
@@ -1541,6 +1859,151 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1541
1859
  entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1542
1860
  return entries.slice(0, limit);
1543
1861
  }
1862
+ // entry.reconstructed_snapshot_ref only ever gets consulted when
1863
+ // snapshot_ref itself is null (see withReconstructedSnapshotRef) -- this
1864
+ // still checks both explicitly, rather than assuming that invariant holds,
1865
+ // so the two can never be silently conflated even if that changes later.
1866
+ // A reconstructed value is always labeled as such: it's an approximation
1867
+ // (the commit HEAD probably pointed to at that timestamp), not the
1868
+ // original captured snapshot, and presenting it unlabeled would overstate
1869
+ // its reliability.
1870
+ function formatSnapshotLine(entry) {
1871
+ if (entry.snapshot_ref)
1872
+ return "`" + entry.snapshot_ref + "`";
1873
+ if (entry.reconstructed_snapshot_ref) {
1874
+ return ("`" +
1875
+ entry.reconstructed_snapshot_ref +
1876
+ "` (reconstructed via backfill -- best-effort approximation, not the original captured snapshot)");
1877
+ }
1878
+ return "not available (project root wasn't a git repository at judgment time)";
1879
+ }
1880
+ // Feature 2 / Decision Provenance, P1: renders one ledger entry as a
1881
+ // stable markdown block -- "stable" meaning a pure function of the entry
1882
+ // alone (never Date.now(), never anything read live off disk), so the
1883
+ // same entry always produces byte-identical markdown. That determinism is
1884
+ // what makes verify-provenance-artifact.mjs's snapshot test meaningful:
1885
+ // a diff in the generated markdown for a fixed fixture means the format
1886
+ // changed, not that time passed. Markdown, not JSON, per the spec --
1887
+ // PRs/issues render it natively (see export_ledger_provenance and
1888
+ // post_ledger_provenance_to_github, which attach this to one).
1889
+ export function formatProvenanceArtifact(entry) {
1890
+ const lines = [];
1891
+ lines.push(`## Pattern decision: ${entry.component_need}`);
1892
+ lines.push("");
1893
+ lines.push(`- **Verdict:** ${entry.verdict} (confidence: ${entry.confidence})`);
1894
+ lines.push(`- **Reason:** ${entry.reason}`);
1895
+ lines.push(`- **Coverage:** ${entry.coverage ?? "n/a"}`);
1896
+ lines.push(`- **Domain:** ${entry.domain}`);
1897
+ lines.push(`- **Framework:** ${entry.framework}`);
1898
+ lines.push(`- **Snapshot:** ${formatSnapshotLine(entry)}`);
1899
+ lines.push(`- **Judged at:** ${entry.timestamp}${entry.cache_hit ? " (served from ledger cache hit)" : ""}`);
1900
+ lines.push("");
1901
+ lines.push("### Requirements checked");
1902
+ if (entry.checklist.length === 0) {
1903
+ lines.push("_No checklist recorded for this entry._");
1904
+ }
1905
+ else {
1906
+ for (const item of entry.checklist)
1907
+ lines.push(`- ${item}`);
1908
+ }
1909
+ lines.push("");
1910
+ lines.push("### Candidates compared");
1911
+ if (entry.candidates_evaluated.length === 0) {
1912
+ lines.push(entry.verdict === "custom_build"
1913
+ ? "_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._"
1914
+ : "_No candidates recorded for this entry._");
1915
+ }
1916
+ else {
1917
+ lines.push("| Source | Name | Coverage | Chosen |");
1918
+ lines.push("| --- | --- | --- | --- |");
1919
+ for (const c of entry.candidates_evaluated) {
1920
+ const chosen = c.name !== null && c.name === entry.chosen_candidate ? "✓" : "";
1921
+ lines.push(`| ${c.source ?? "n/a"} | ${c.name ?? "n/a"} | ${c.coverage_pct ?? "n/a"} | ${chosen} |`);
1922
+ }
1923
+ }
1924
+ lines.push("");
1925
+ lines.push(`_Generated by Pattern (\`export_ledger_provenance\`) from ledger entry \`${entry.id}\`._`);
1926
+ return lines.join("\n");
1927
+ }
1928
+ // Feature 2 / Decision Provenance, P2: posts an export_ledger_provenance
1929
+ // artifact as a real comment on a GitHub PR or issue. GitHub's REST API
1930
+ // treats a PR and an issue identically for comments (both are backed by
1931
+ // the same /issues/{number}/comments endpoint), so one input shape covers
1932
+ // both -- no separate "is this a PR" flag needed.
1933
+ //
1934
+ // This is the one tool in this server with a real, visible side effect on
1935
+ // a third-party service outside the caller's own machine -- every other
1936
+ // tool here only ever touches local files. The calling agent should
1937
+ // confirm with the user before invoking it, the same way it's expected to
1938
+ // confirm before running a suggested install_command (see SECURITY.md).
1939
+ //
1940
+ // Auth resolves the spec's own open question (personal token vs. GitHub
1941
+ // App) in favor of a personal token: reads GITHUB_TOKEN from the
1942
+ // environment, the same convention every GitHub Action and the `gh` CLI
1943
+ // itself use. A GitHub App needs a hosted installation flow and a webhook
1944
+ // receiver, which contradicts this project's "local npm package, no
1945
+ // hosted infrastructure" distribution model (see the README's Ledger
1946
+ // integrity section and the Pattern Primer's build-order principle) --
1947
+ // Pattern manages no GitHub credential of its own, the same way it
1948
+ // manages no git credential for computeSnapshotRef above.
1949
+ //
1950
+ // Idempotent by construction, not just by convention: every posted
1951
+ // comment is prefixed with a hidden HTML marker keyed to the ledger
1952
+ // entry's id, and a post first checks existing comments for that marker
1953
+ // -- a repeat call for the same entry returns posted: false instead of
1954
+ // creating a duplicate. Only checks the most recent 100 comments (one
1955
+ // page) -- a thread with more prior comments than that is an edge case
1956
+ // this pass doesn't handle; full pagination is a later concern, not built
1957
+ // here.
1958
+ const GITHUB_API_BASE = process.env.PATTERN_GITHUB_API_BASE ?? "https://api.github.com";
1959
+ function provenanceMarker(ledgerEntryId) {
1960
+ return `<!-- pattern-ledger-provenance:${ledgerEntryId} -->`;
1961
+ }
1962
+ async function postProvenanceToGitHub(input) {
1963
+ const token = process.env.GITHUB_TOKEN;
1964
+ if (!token) {
1965
+ throw new Error("GITHUB_TOKEN is not set. This tool posts a real comment to GitHub and needs a personal access token " +
1966
+ "with repo scope (the same one `gh auth login` or a GitHub Action would use) -- set the GITHUB_TOKEN " +
1967
+ "environment variable and retry.");
1968
+ }
1969
+ if (!/^[^/\s]+\/[^/\s]+$/.test(input.repo)) {
1970
+ throw new Error(`repo must be in "owner/repo" form, got: "${input.repo}"`);
1971
+ }
1972
+ const entry = readLedgerEntries(input.project_id).find((e) => e.id === input.ledger_entry_id);
1973
+ if (!entry) {
1974
+ throw new Error(`No ledger entry with id "${input.ledger_entry_id}" found for project_id "${input.project_id}". Use read_ledger to list entries and their ids.`);
1975
+ }
1976
+ const marker = provenanceMarker(entry.id);
1977
+ const headers = {
1978
+ Authorization: `Bearer ${token}`,
1979
+ Accept: "application/vnd.github+json",
1980
+ "Content-Type": "application/json",
1981
+ "User-Agent": "pattern-mcp",
1982
+ };
1983
+ const commentsUrl = `${GITHUB_API_BASE}/repos/${input.repo}/issues/${input.issue_number}/comments`;
1984
+ const listResponse = await fetch(`${commentsUrl}?per_page=100`, { headers });
1985
+ if (!listResponse.ok) {
1986
+ const errText = await listResponse.text();
1987
+ throw new Error(`GitHub API error ${listResponse.status} listing comments on ${input.repo}#${input.issue_number}: ${errText}`);
1988
+ }
1989
+ const existingComments = (await listResponse.json());
1990
+ const existing = existingComments.find((c) => c.body.includes(marker));
1991
+ if (existing) {
1992
+ return { posted: false, reason: "already_posted", comment_url: existing.html_url, comment_id: existing.id };
1993
+ }
1994
+ const body = `${marker}\n\n${formatProvenanceArtifact(entry)}`;
1995
+ const postResponse = await fetch(commentsUrl, {
1996
+ method: "POST",
1997
+ headers,
1998
+ body: JSON.stringify({ body }),
1999
+ });
2000
+ if (!postResponse.ok) {
2001
+ const errText = await postResponse.text();
2002
+ throw new Error(`GitHub API error ${postResponse.status} posting comment to ${input.repo}#${input.issue_number}: ${errText}`);
2003
+ }
2004
+ const created = (await postResponse.json());
2005
+ return { posted: true, comment_url: created.html_url, comment_id: created.id };
2006
+ }
1544
2007
  // report_build_cost (cost-attribution build plan, 1.3) -- self-reported
1545
2008
  // build cost, cheapest option first, since Pattern has no visibility into
1546
2009
  // what happens after judgeComponent returns a verdict (1.4's
@@ -1791,6 +2254,7 @@ function buildLedgerEntry(input, projectId, result, opts) {
1791
2254
  file_path: input.file_path ?? null,
1792
2255
  last_verified_live: null,
1793
2256
  live_status: "unknown",
2257
+ reconstructed_snapshot_ref: null,
1794
2258
  };
1795
2259
  }
1796
2260
  async function judgeComponent(input) {
@@ -2477,6 +2941,65 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2477
2941
  "entries afterward.",
2478
2942
  inputSchema: CHECK_LEDGER_LIVENESS_INPUT_SCHEMA,
2479
2943
  },
2944
+ {
2945
+ name: EXPORT_LEDGER_PROVENANCE_TOOL_NAME,
2946
+ description: "Formats one ledger entry (requirements checklist, candidates " +
2947
+ "compared, verdict, confidence, snapshot_ref) as a single markdown " +
2948
+ "block -- a stable, portable record of that decision you can paste " +
2949
+ "into a PR description or issue by hand. Pure and deterministic: " +
2950
+ "the same entry always produces the same markdown, nothing here " +
2951
+ "reads live system time or disk state. This only formats and " +
2952
+ "returns text; it does not post anything to GitHub or anywhere " +
2953
+ "else -- see post_ledger_provenance_to_github for that.",
2954
+ inputSchema: EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA,
2955
+ },
2956
+ {
2957
+ name: POST_LEDGER_PROVENANCE_TOOL_NAME,
2958
+ description: "Posts one ledger entry's provenance artifact (same content " +
2959
+ "export_ledger_provenance produces) as a real comment on a GitHub " +
2960
+ "PR or issue. This is the one tool in this server with a real, " +
2961
+ "visible side effect on a third-party service, not just your own " +
2962
+ "machine -- confirm with the user before calling this, the same " +
2963
+ "way you'd confirm before running a suggested install_command " +
2964
+ "(see SECURITY.md). Requires GITHUB_TOKEN (a personal access " +
2965
+ "token with repo scope) in the environment -- Pattern manages no " +
2966
+ "GitHub credential of its own. Idempotent: a repeat call for the " +
2967
+ "same ledger_entry_id/repo/issue_number detects the previously " +
2968
+ "posted comment (via a hidden marker) and returns posted: false " +
2969
+ "instead of creating a duplicate.",
2970
+ inputSchema: POST_LEDGER_PROVENANCE_INPUT_SCHEMA,
2971
+ },
2972
+ {
2973
+ name: SWEEP_LEDGER_LIVENESS_TOOL_NAME,
2974
+ description: "Batch version of check_ledger_liveness: updates live_status for " +
2975
+ "every file_path-bearing entry across an entire project (or, when " +
2976
+ "project_id is omitted, every project_id present in the ledger), " +
2977
+ "then flags dangling clusters -- groups of 2+ entries sharing a " +
2978
+ "feature_id where none of them resolved to live_status 'live'. " +
2979
+ "Pattern has no daemon or scheduler of its own (each server " +
2980
+ "invocation is transient, tied to its MCP host's lifecycle) -- " +
2981
+ "this tool is meant to be invoked by whatever external scheduler " +
2982
+ "you already have (a cron job, a CI step), not something Pattern " +
2983
+ "triggers automatically. Tested at 200 and 1,000 synthetic " +
2984
+ "entries without reintroducing search+score latency -- this is " +
2985
+ "fs stat calls, not API calls.",
2986
+ inputSchema: SWEEP_LEDGER_LIVENESS_INPUT_SCHEMA,
2987
+ },
2988
+ {
2989
+ name: BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME,
2990
+ description: "Best-effort reconstruction of snapshot_ref for ledger entries " +
2991
+ "written before that field existed (or written outside a git " +
2992
+ "repo): finds the commit that was HEAD at or just before each " +
2993
+ "entry's own timestamp. Always clearly distinguished from a real " +
2994
+ "captured snapshot_ref wherever it's rendered (export_ledger_provenance, " +
2995
+ "post_ledger_provenance_to_github) -- a rebase/force-push/history " +
2996
+ "rewrite since that time can make this approximation wrong, so " +
2997
+ "it's never presented as equivalent to a value actually captured " +
2998
+ "live. Entries that already have a real snapshot_ref are reported " +
2999
+ "but never touched. Persists every attempt (including failures) " +
3000
+ "for later lookup; never modifies ledger.jsonl itself.",
3001
+ inputSchema: BACKFILL_LEDGER_SNAPSHOT_REF_INPUT_SCHEMA,
3002
+ },
2480
3003
  ],
2481
3004
  }));
2482
3005
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -2621,6 +3144,75 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2621
3144
  };
2622
3145
  }
2623
3146
  }
3147
+ if (request.params.name === EXPORT_LEDGER_PROVENANCE_TOOL_NAME) {
3148
+ const args = request.params.arguments;
3149
+ try {
3150
+ const entry = readLedgerEntries(args.project_id).find((e) => e.id === args.ledger_entry_id);
3151
+ if (!entry) {
3152
+ 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.`);
3153
+ }
3154
+ return {
3155
+ content: [
3156
+ { type: "text", text: JSON.stringify({ ledger_entry_id: entry.id, markdown: formatProvenanceArtifact(entry) }) },
3157
+ ],
3158
+ };
3159
+ }
3160
+ catch (err) {
3161
+ const message = err instanceof Error ? err.message : String(err);
3162
+ return {
3163
+ content: [{ type: "text", text: `Error: ${message}` }],
3164
+ isError: true,
3165
+ };
3166
+ }
3167
+ }
3168
+ if (request.params.name === POST_LEDGER_PROVENANCE_TOOL_NAME) {
3169
+ const args = request.params.arguments;
3170
+ try {
3171
+ const result = await postProvenanceToGitHub(args);
3172
+ return {
3173
+ content: [{ type: "text", text: JSON.stringify(result) }],
3174
+ };
3175
+ }
3176
+ catch (err) {
3177
+ const message = err instanceof Error ? err.message : String(err);
3178
+ return {
3179
+ content: [{ type: "text", text: `Error: ${message}` }],
3180
+ isError: true,
3181
+ };
3182
+ }
3183
+ }
3184
+ if (request.params.name === SWEEP_LEDGER_LIVENESS_TOOL_NAME) {
3185
+ const args = request.params.arguments;
3186
+ try {
3187
+ const result = sweepLedgerLiveness(args);
3188
+ return {
3189
+ content: [{ type: "text", text: JSON.stringify(result) }],
3190
+ };
3191
+ }
3192
+ catch (err) {
3193
+ const message = err instanceof Error ? err.message : String(err);
3194
+ return {
3195
+ content: [{ type: "text", text: `Error: ${message}` }],
3196
+ isError: true,
3197
+ };
3198
+ }
3199
+ }
3200
+ if (request.params.name === BACKFILL_LEDGER_SNAPSHOT_REF_TOOL_NAME) {
3201
+ const args = request.params.arguments;
3202
+ try {
3203
+ const result = backfillLedgerSnapshotRefs(args);
3204
+ return {
3205
+ content: [{ type: "text", text: JSON.stringify({ project_id: args.project_id, ...result }) }],
3206
+ };
3207
+ }
3208
+ catch (err) {
3209
+ const message = err instanceof Error ? err.message : String(err);
3210
+ return {
3211
+ content: [{ type: "text", text: `Error: ${message}` }],
3212
+ isError: true,
3213
+ };
3214
+ }
3215
+ }
2624
3216
  throw new Error(`Unknown tool: ${request.params.name}`);
2625
3217
  });
2626
3218
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",