pattern-mcp 0.6.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 +278 -30
  2. package/dist/index.js +513 -17
  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 eight tools:
35
+ It exposes eleven tools:
36
36
 
37
37
  - `recommend_component` — evaluates a UI component need and returns a
38
38
  structured recommendation.
@@ -61,6 +61,19 @@ It exposes eight tools:
61
61
  markdown block (checklist, candidates, verdict, `snapshot_ref`) you can
62
62
  paste into a PR or issue by hand -- see [Tool:
63
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).
64
77
 
65
78
  ## How it works
66
79
 
@@ -739,14 +752,16 @@ came back with `served_from_ledger: true`.
739
752
  "file_path": null,
740
753
  "snapshot_ref": "a1b2c3d4e5f6...",
741
754
  "last_verified_live": null,
742
- "live_status": "unknown"
755
+ "live_status": "unknown",
756
+ "reconstructed_snapshot_ref": null
743
757
  }
744
758
  ]
745
759
  }
746
760
  ```
747
761
 
748
- `file_path`/`snapshot_ref`/`last_verified_live`/`live_status` are the
749
- 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
750
765
  decision provenance](#ledger-integrity-and-decision-provenance) and [Tool:
751
766
  `check_ledger_liveness`](#tool-check_ledger_liveness). Entries written
752
767
  before this feature shipped read back with `file_path`/`snapshot_ref`/
@@ -954,8 +969,11 @@ an arbitrary shell command.
954
969
  ```
955
970
 
956
971
  `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"`.
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)).
959
977
  Entries with no `file_path` are listed but never checked or written to
960
978
  `ledger_liveness.jsonl` -- their status is permanently `"unknown"` since
961
979
  there's nothing to check. Results here are also layered onto
@@ -1012,6 +1030,200 @@ custom-build reference (Mobbin/Figma) to the ledger (see
1012
1030
  A `null` `snapshot_ref` (project root wasn't a git repository at judgment
1013
1031
  time) renders as prose too, not the literal word `null`.
1014
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
+
1015
1227
  ## Feature cost attribution
1016
1228
 
1017
1229
  Every `recommend_component` call that writes to the ledger -- a fresh
@@ -1182,11 +1394,12 @@ original judgment's snapshot, look up the entry named in its
1182
1394
  [`export_ledger_provenance`](#tool-export_ledger_provenance) packages one
1183
1395
  entry's full record -- checklist, candidates, verdict, `snapshot_ref` --
1184
1396
  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`.
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.
1190
1403
 
1191
1404
  ### Referential integrity: `file_path` / `live_status`
1192
1405
 
@@ -1203,26 +1416,26 @@ check whether that file still exists and still mentions
1203
1416
  `PROJECT_ROOT`, or the file exists but `chosen_candidate` can't be
1204
1417
  confirmed in it. Deliberately the default outcome for anything
1205
1418
  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
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
1219
1435
  `outcome_proxies.jsonl`, see [Outcome proxies](#outcome-proxies)) and
1220
1436
  layered onto `ledger.jsonl`'s own entries at read time -- the ledger line
1221
1437
  itself is never rewritten.
1222
1438
 
1223
- Not yet built (P2-P3 of Feature 1): a scheduled/batch sweep across an
1224
- entire ledger, and dangling-cluster detection.
1225
-
1226
1439
  ## Per-project decision memory
1227
1440
 
1228
1441
  Pattern stores confirmed decisions locally in:
@@ -1451,6 +1664,38 @@ Pattern caches its system instructions using `cache_control: ephemeral`.
1451
1664
  The instructions are the same across calls, so repeated requests don't
1452
1665
  pay the full input cost for that block.
1453
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
+
1454
1699
  ### Search limits
1455
1700
 
1456
1701
  Pattern limits candidate discovery to 3 web searches -- one per source.
@@ -1488,9 +1733,12 @@ the category page), there's no safe fallback for an unverified met/not-met
1488
1733
  call, so nothing is silently corrected -- `scoring_fetch` just tells you
1489
1734
  whether the grounding actually ran.
1490
1735
 
1491
- A fetch can read up to 15,000 content tokens. `web_fetch` has no separate
1492
- per-call fee; the cost comes from the content added to the model's
1493
- 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.
1494
1742
 
1495
1743
  ### Choosing a cheaper model
1496
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
@@ -443,6 +491,9 @@ 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";
445
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";
446
497
  const INPUT_SCHEMA = {
447
498
  type: "object",
448
499
  properties: {
@@ -692,6 +743,54 @@ const EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA = {
692
743
  },
693
744
  required: ["project_id", "ledger_entry_id"],
694
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
+ };
695
794
  // Shared between buildSystemPrompt's own step 2 and
696
795
  // buildExtractionSystemPrompt (the extract_requirements tool's standalone
697
796
  // prompt) -- the extraction *instructions* are one piece of text reused
@@ -971,8 +1070,10 @@ existing_stack: ${input.existing_stack ?? "(not specified)"}${checklistBlock}${p
971
1070
  max_uses: 3,
972
1071
  // Category/browse pages can be large, and all we need from them
973
1072
  // is a permalink, not the full page -- caps token cost of a
974
- // fetch that turns out not to have a deep link after all.
975
- 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,
976
1077
  },
977
1078
  ],
978
1079
  });
@@ -1377,9 +1478,21 @@ function readLedgerLivenessRecords(ledgerEntryId) {
1377
1478
  }
1378
1479
  return records;
1379
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.
1380
1491
  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;
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));
1383
1496
  }
1384
1497
  function withLatestLiveness(entry) {
1385
1498
  const latest = latestLiveness(entry.id);
@@ -1387,6 +1500,59 @@ function withLatestLiveness(entry) {
1387
1500
  return entry;
1388
1501
  return { ...entry, live_status: latest.live_status, last_verified_live: latest.timestamp };
1389
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
+ }
1390
1556
  // Feature 1 / Referential Integrity, P1: the single-entry live-check.
1391
1557
  // Orphaned when file_path is set but the file no longer exists; live when
1392
1558
  // the file exists and (best-effort) still mentions chosen_candidate;
@@ -1394,9 +1560,10 @@ function withLatestLiveness(entry) {
1394
1560
  // resolveWithinRoot), or exists but the candidate name can't be confirmed
1395
1561
  // in its content -- conservative on purpose, per the spec's own risk
1396
1562
  // 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.
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.
1400
1567
  function checkFileLiveStatus(entry) {
1401
1568
  if (!entry.file_path)
1402
1569
  return "unknown";
@@ -1428,11 +1595,12 @@ function checkLedgerEntryLiveness(entry) {
1428
1595
  return record;
1429
1596
  }
1430
1597
  // 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.
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.
1436
1604
  function checkLedgerLiveness(input) {
1437
1605
  const entries = readLedgerEntries(input.project_id).filter((e) => !input.ledger_entry_id || e.id === input.ledger_entry_id);
1438
1606
  const results = entries.map((e) => {
@@ -1462,6 +1630,37 @@ function checkLedgerLiveness(input) {
1462
1630
  results,
1463
1631
  };
1464
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
+ }
1465
1664
  // Same "missing/malformed collapses to empty" philosophy as readMemory,
1466
1665
  // but line-oriented (JSONL) rather than whole-file JSON -- a single
1467
1666
  // corrupted line (e.g. a hand-edited file, or a write that got cut off)
@@ -1492,8 +1691,9 @@ function readLedgerEntries(projectId) {
1492
1691
  snapshot_ref: rawEntry.snapshot_ref ?? null,
1493
1692
  last_verified_live: rawEntry.last_verified_live ?? null,
1494
1693
  live_status: rawEntry.live_status ?? "unknown",
1694
+ reconstructed_snapshot_ref: rawEntry.reconstructed_snapshot_ref ?? null,
1495
1695
  };
1496
- entries.push(withLatestLiveness(normalized));
1696
+ entries.push(withReconstructedSnapshotRef(withLatestLiveness(normalized)));
1497
1697
  }
1498
1698
  }
1499
1699
  catch {
@@ -1502,6 +1702,109 @@ function readLedgerEntries(projectId) {
1502
1702
  }
1503
1703
  return entries;
1504
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
+ }
1505
1808
  // The only entry point that writes ledger.jsonl. Validates every
1506
1809
  // candidate against the DistilledCandidate boundary before it ever touches
1507
1810
  // disk -- a raw object reaching here throws rather than silently
@@ -1556,6 +1859,24 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1556
1859
  entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
1557
1860
  return entries.slice(0, limit);
1558
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
+ }
1559
1880
  // Feature 2 / Decision Provenance, P1: renders one ledger entry as a
1560
1881
  // stable markdown block -- "stable" meaning a pure function of the entry
1561
1882
  // alone (never Date.now(), never anything read live off disk), so the
@@ -1563,8 +1884,8 @@ function findLedgerMatches(projectId, componentNeed, limit = 20) {
1563
1884
  // what makes verify-provenance-artifact.mjs's snapshot test meaningful:
1564
1885
  // a diff in the generated markdown for a fixed fixture means the format
1565
1886
  // 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).
1887
+ // PRs/issues render it natively (see export_ledger_provenance and
1888
+ // post_ledger_provenance_to_github, which attach this to one).
1568
1889
  export function formatProvenanceArtifact(entry) {
1569
1890
  const lines = [];
1570
1891
  lines.push(`## Pattern decision: ${entry.component_need}`);
@@ -1574,7 +1895,7 @@ export function formatProvenanceArtifact(entry) {
1574
1895
  lines.push(`- **Coverage:** ${entry.coverage ?? "n/a"}`);
1575
1896
  lines.push(`- **Domain:** ${entry.domain}`);
1576
1897
  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)"}`);
1898
+ lines.push(`- **Snapshot:** ${formatSnapshotLine(entry)}`);
1578
1899
  lines.push(`- **Judged at:** ${entry.timestamp}${entry.cache_hit ? " (served from ledger cache hit)" : ""}`);
1579
1900
  lines.push("");
1580
1901
  lines.push("### Requirements checked");
@@ -1604,6 +1925,85 @@ export function formatProvenanceArtifact(entry) {
1604
1925
  lines.push(`_Generated by Pattern (\`export_ledger_provenance\`) from ledger entry \`${entry.id}\`._`);
1605
1926
  return lines.join("\n");
1606
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
+ }
1607
2007
  // report_build_cost (cost-attribution build plan, 1.3) -- self-reported
1608
2008
  // build cost, cheapest option first, since Pattern has no visibility into
1609
2009
  // what happens after judgeComponent returns a verdict (1.4's
@@ -1854,6 +2254,7 @@ function buildLedgerEntry(input, projectId, result, opts) {
1854
2254
  file_path: input.file_path ?? null,
1855
2255
  last_verified_live: null,
1856
2256
  live_status: "unknown",
2257
+ reconstructed_snapshot_ref: null,
1857
2258
  };
1858
2259
  }
1859
2260
  async function judgeComponent(input) {
@@ -2549,9 +2950,56 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2549
2950
  "the same entry always produces the same markdown, nothing here " +
2550
2951
  "reads live system time or disk state. This only formats and " +
2551
2952
  "returns text; it does not post anything to GitHub or anywhere " +
2552
- "else -- that's a separate, not-yet-built action.",
2953
+ "else -- see post_ledger_provenance_to_github for that.",
2553
2954
  inputSchema: EXPORT_LEDGER_PROVENANCE_INPUT_SCHEMA,
2554
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
+ },
2555
3003
  ],
2556
3004
  }));
2557
3005
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -2717,6 +3165,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2717
3165
  };
2718
3166
  }
2719
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
+ }
2720
3216
  throw new Error(`Unknown tool: ${request.params.name}`);
2721
3217
  });
2722
3218
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.6.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",