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.
- package/README.md +334 -27
- package/dist/index.js +605 -13
- 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
|
|
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
|
|
745
|
-
ledger integrity + decision
|
|
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
|
-
(
|
|
954
|
-
|
|
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
|
-
|
|
1130
|
-
|
|
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`** --
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
`
|
|
1159
|
-
|
|
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
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
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)
|
|
1367
|
-
|
|
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" (
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
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 --
|
|
1417
|
-
//
|
|
1418
|
-
// reported but never checked/recorded --
|
|
1419
|
-
// "unknown" by construction, so re-checking
|
|
1420
|
-
// grow ledger_liveness.jsonl without ever
|
|
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.
|
|
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",
|