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.
- package/README.md +278 -30
- package/dist/index.js +513 -17
- 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.
|
|
@@ -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
|
|
749
|
-
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
|
|
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
|
-
(
|
|
958
|
-
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
entries that predate
|
|
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`** --
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
`
|
|
1218
|
-
|
|
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
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
-
|
|
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)
|
|
1382
|
-
|
|
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" (
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
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 --
|
|
1432
|
-
//
|
|
1433
|
-
// reported but never checked/recorded --
|
|
1434
|
-
// "unknown" by construction, so re-checking
|
|
1435
|
-
// 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.
|
|
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 (
|
|
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
|
|
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 --
|
|
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.
|
|
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",
|