pattern-mcp 0.9.0 → 0.10.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 +49 -4
- package/dist/check-gate.js +202 -0
- package/dist/component-gate.js +56 -0
- package/dist/gate-receipt.js +94 -0
- package/dist/index.js +20 -4
- package/dist/staged/anthropic.js +2 -1
- package/package.json +5 -3
- package/templates/claude-settings/settings.json +16 -0
- package/templates/github-workflows/pattern-gate.yml +42 -0
- package/templates/hooks/check-gate-hook.mjs +89 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ design reference.
|
|
|
31
31
|
## Install
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
34
|
+
npx pattern-mcp
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
See [Quick Start](#quick-start) below to add your Anthropic API key and connect
|
|
@@ -224,11 +224,11 @@ threshold.
|
|
|
224
224
|
### 1. Install
|
|
225
225
|
|
|
226
226
|
```bash
|
|
227
|
-
|
|
227
|
+
npx pattern-mcp
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
|
|
231
|
-
|
|
230
|
+
`npx` runs the `pattern-mcp` command on demand without a separate install
|
|
231
|
+
step, used in the client configs below.
|
|
232
232
|
|
|
233
233
|
<details>
|
|
234
234
|
<summary>Build from source instead</summary>
|
|
@@ -1576,6 +1576,51 @@ the source line, most recent record wins at read time" convention as
|
|
|
1576
1576
|
layered onto `ledger.jsonl`'s own entries at read time -- the ledger line
|
|
1577
1577
|
itself is never rewritten.
|
|
1578
1578
|
|
|
1579
|
+
## Enforcement boundary: hook + CI gate
|
|
1580
|
+
|
|
1581
|
+
**The gap this closes:** SKILL.md instructs the calling agent to call
|
|
1582
|
+
`recommend_component` before scaffolding a new, non-trivial UI component,
|
|
1583
|
+
but nothing before this feature *enforced* that -- an agent could simply
|
|
1584
|
+
skip the call, and nothing server-side would know. This is opt-in and
|
|
1585
|
+
Claude-Code-specific for the hook half; a consuming repo that never wires
|
|
1586
|
+
either piece up gets Pattern exactly as it worked before, and any other
|
|
1587
|
+
MCP host (Cursor, Codex, etc.) is entirely unaffected either way.
|
|
1588
|
+
|
|
1589
|
+
Two pieces, both templates under `templates/` -- pattern-mcp never
|
|
1590
|
+
installs either into your repo on its own:
|
|
1591
|
+
|
|
1592
|
+
- **`templates/hooks/check-gate-hook.mjs`** + **`templates/claude-settings/settings.json`**
|
|
1593
|
+
-- a Claude Code `PreToolUse` hook that runs on `Write`/`Edit` calls. For
|
|
1594
|
+
a genuinely new `.tsx`/`.jsx` file that exports a non-trivial component,
|
|
1595
|
+
it looks up a ledger entry (via `~/.pattern/ledger.jsonl`, same as
|
|
1596
|
+
everywhere else in Pattern) whose `file_path` matches the file being
|
|
1597
|
+
written. A match writes a receipt and allows the write; no match blocks
|
|
1598
|
+
it with a reason fed back to the model as retryable guidance, not a hard
|
|
1599
|
+
failure. **This is the one new exception where Pattern writes into your
|
|
1600
|
+
repo** (`.pattern/receipts/<feature_id>.json`) -- everything else
|
|
1601
|
+
described in this README is read-only.
|
|
1602
|
+
- **`templates/github-workflows/pattern-gate.yml`** -- a required PR
|
|
1603
|
+
check that reads the same receipt files back out of the diff. It never
|
|
1604
|
+
touches `~/.pattern/` (not reachable from a CI runner) and needs no
|
|
1605
|
+
`GITHUB_TOKEN` -- it trusts the committed receipt as the artifact of
|
|
1606
|
+
record, the same way it would trust a committed test fixture.
|
|
1607
|
+
|
|
1608
|
+
The join between the two depends on `file_path` being passed to
|
|
1609
|
+
`recommend_component`/`record_component_decision` -- if it's omitted, the
|
|
1610
|
+
gate has nothing to match against and fails closed (blocks) rather than
|
|
1611
|
+
guessing. Pass `file_path` whenever you know it.
|
|
1612
|
+
|
|
1613
|
+
An escape hatch exists for both a whole-hook kill switch
|
|
1614
|
+
(`PATTERN_NO_ENFORCEMENT_HOOK`, local only -- does not affect the CI
|
|
1615
|
+
check) and a per-file override (a `// pattern-mcp:override reason="..."`
|
|
1616
|
+
comment) -- the override still writes a receipt recording
|
|
1617
|
+
`manual_override: true` and the reason, so it stays visible rather than
|
|
1618
|
+
silent. See `src/component-gate.ts`, `src/gate-receipt.ts`, and
|
|
1619
|
+
`src/check-gate.ts` (the new `pattern-check-gate` CLI, this project's
|
|
1620
|
+
first entry point separate from the stdio MCP server) for the
|
|
1621
|
+
implementation, and BACKLOG.md's "Enforcement boundary: hook + CI gate"
|
|
1622
|
+
entry for the fuller design writeup.
|
|
1623
|
+
|
|
1579
1624
|
## Per-project decision memory
|
|
1580
1625
|
|
|
1581
1626
|
Pattern stores confirmed decisions locally in:
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pattern-check-gate -- the enforcement-boundary CLI (see
|
|
3
|
+
// BACKLOG.md's "Enforcement boundary: hook + CI gate" entry).
|
|
4
|
+
//
|
|
5
|
+
// Two modes, one shared classifier (component-gate.ts) so the local hook
|
|
6
|
+
// and the CI check can never silently drift on what counts as "gated":
|
|
7
|
+
//
|
|
8
|
+
// write -- run locally (by the PreToolUse hook template) where
|
|
9
|
+
// ~/.pattern/ledger.jsonl is reachable. Looks up a ledger
|
|
10
|
+
// entry whose file_path matches the file being written; on a
|
|
11
|
+
// match (or a manual override), writes a receipt into the
|
|
12
|
+
// CONSUMING repo at .pattern/receipts/<feature_id>.json and
|
|
13
|
+
// exits 0. No match, no override -> exits 1 and blocks.
|
|
14
|
+
//
|
|
15
|
+
// verify -- run in CI, where ~/.pattern/ is never reachable. Trusts the
|
|
16
|
+
// committed receipt as the artifact of record instead of
|
|
17
|
+
// re-deriving anything from the ledger -- fails if a gated
|
|
18
|
+
// file in the diff has no matching receipt.
|
|
19
|
+
//
|
|
20
|
+
// No CLI-parsing or git-wrapper dependency, matching this project's
|
|
21
|
+
// existing minimal-dependency posture (index.ts shells out to fixed git
|
|
22
|
+
// subcommands rather than a library) -- argv is parsed by hand below.
|
|
23
|
+
//
|
|
24
|
+
// This is the project's first standalone CLI entry point separate from
|
|
25
|
+
// the stdio MCP server (see package.json's new "pattern-check-gate" bin
|
|
26
|
+
// entry) -- entirely opt-in. A consuming repo that never installs the
|
|
27
|
+
// hook template or the workflow template never invokes this file, and
|
|
28
|
+
// Pattern's core MCP tools are unaffected either way.
|
|
29
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
30
|
+
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
31
|
+
import { isGatedComponentFile, parseManualOverride } from "./component-gate.js";
|
|
32
|
+
import { deriveOverrideFeatureId, readAllGateReceipts, writeGateReceipt } from "./gate-receipt.js";
|
|
33
|
+
function normalize(p) {
|
|
34
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
35
|
+
}
|
|
36
|
+
// Converts a possibly-absolute file argument into a path relative to
|
|
37
|
+
// root, without ever reading/writing outside root.
|
|
38
|
+
function toRepoRelative(root, fileArg) {
|
|
39
|
+
const abs = isAbsolute(fileArg) ? fileArg : resolvePath(root, fileArg);
|
|
40
|
+
const rel = relative(root, abs);
|
|
41
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
42
|
+
return null;
|
|
43
|
+
return normalize(rel);
|
|
44
|
+
}
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const mode = argv[0];
|
|
47
|
+
const flags = {};
|
|
48
|
+
const files = [];
|
|
49
|
+
for (let i = 1; i < argv.length; i++) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === "--files") {
|
|
52
|
+
// --files (verify mode) consumes every following non-flag token
|
|
53
|
+
i++;
|
|
54
|
+
while (i < argv.length && !argv[i].startsWith("--")) {
|
|
55
|
+
files.push(argv[i]);
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
i--;
|
|
59
|
+
}
|
|
60
|
+
else if (arg.startsWith("--")) {
|
|
61
|
+
const key = arg.slice(2);
|
|
62
|
+
const next = argv[i + 1];
|
|
63
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
64
|
+
flags[key] = next;
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
flags[key] = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { mode, flags, files };
|
|
73
|
+
}
|
|
74
|
+
function readStdin() {
|
|
75
|
+
return new Promise((resolveP, reject) => {
|
|
76
|
+
let data = "";
|
|
77
|
+
process.stdin.setEncoding("utf8");
|
|
78
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
79
|
+
process.stdin.on("end", () => resolveP(data));
|
|
80
|
+
process.stdin.on("error", reject);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function emit(result, ok) {
|
|
84
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
85
|
+
process.exit(ok ? 0 : 1);
|
|
86
|
+
}
|
|
87
|
+
async function runWrite(root, flags) {
|
|
88
|
+
const fileArg = flags.file;
|
|
89
|
+
const projectId = flags["project-id"];
|
|
90
|
+
if (typeof fileArg !== "string" || typeof projectId !== "string") {
|
|
91
|
+
emit({ ok: false, reason: "write mode requires --file <path> and --project-id <id>" }, false);
|
|
92
|
+
}
|
|
93
|
+
const relPath = toRepoRelative(root, fileArg);
|
|
94
|
+
if (relPath === null) {
|
|
95
|
+
emit({ ok: false, reason: `--file resolves outside project root: ${fileArg}` }, false);
|
|
96
|
+
}
|
|
97
|
+
const content = await readStdin();
|
|
98
|
+
const isNew = flags["is-new"] === true;
|
|
99
|
+
if (!isGatedComponentFile(relPath, content, isNew)) {
|
|
100
|
+
emit({ ok: true, gated: false }, true);
|
|
101
|
+
}
|
|
102
|
+
const override = parseManualOverride(content);
|
|
103
|
+
const checkedAt = new Date().toISOString();
|
|
104
|
+
// Dynamic import, after nothing has set PATTERN_NO_AUTOSTART yet in
|
|
105
|
+
// this process -- set it now, before index.js's module body runs, same
|
|
106
|
+
// convention scripts/*.mjs already use to import from this file
|
|
107
|
+
// without starting the stdio MCP server as a side effect.
|
|
108
|
+
process.env.PATTERN_NO_AUTOSTART = "1";
|
|
109
|
+
const { readLedgerEntries, computeSnapshotRef } = await import("./index.js");
|
|
110
|
+
const snapshotRef = computeSnapshotRef(root);
|
|
111
|
+
if (override.overridden) {
|
|
112
|
+
const receipt = {
|
|
113
|
+
schema_version: 1,
|
|
114
|
+
feature_id: deriveOverrideFeatureId(projectId, relPath),
|
|
115
|
+
file_path: relPath,
|
|
116
|
+
ledger_entry_id: null,
|
|
117
|
+
verdict: null,
|
|
118
|
+
chosen_candidate: null,
|
|
119
|
+
snapshot_ref: snapshotRef,
|
|
120
|
+
checked_at: checkedAt,
|
|
121
|
+
manual_override: true,
|
|
122
|
+
override_reason: override.reason,
|
|
123
|
+
};
|
|
124
|
+
writeGateReceipt(root, receipt);
|
|
125
|
+
emit({ ok: true, gated: true, manual_override: true, feature_id: receipt.feature_id }, true);
|
|
126
|
+
}
|
|
127
|
+
const entries = readLedgerEntries(projectId);
|
|
128
|
+
const match = entries.find((e) => e.file_path && normalize(e.file_path) === relPath);
|
|
129
|
+
if (!match) {
|
|
130
|
+
emit({
|
|
131
|
+
ok: false,
|
|
132
|
+
gated: true,
|
|
133
|
+
reason: `No recommend_component/record_component_decision entry found with file_path="${relPath}" ` +
|
|
134
|
+
`for project_id="${projectId}". Call recommend_component with file_path set to this exact ` +
|
|
135
|
+
`path before creating it, or add \`// pattern-mcp:override reason="..."\` to the file.`,
|
|
136
|
+
}, false);
|
|
137
|
+
}
|
|
138
|
+
const receipt = {
|
|
139
|
+
schema_version: 1,
|
|
140
|
+
feature_id: match.feature_id,
|
|
141
|
+
file_path: relPath,
|
|
142
|
+
ledger_entry_id: match.id,
|
|
143
|
+
verdict: match.verdict,
|
|
144
|
+
chosen_candidate: match.chosen_candidate,
|
|
145
|
+
snapshot_ref: snapshotRef,
|
|
146
|
+
checked_at: checkedAt,
|
|
147
|
+
manual_override: false,
|
|
148
|
+
override_reason: null,
|
|
149
|
+
};
|
|
150
|
+
writeGateReceipt(root, receipt);
|
|
151
|
+
emit({ ok: true, gated: true, feature_id: receipt.feature_id }, true);
|
|
152
|
+
}
|
|
153
|
+
async function runVerify(root, files) {
|
|
154
|
+
const receipts = readAllGateReceipts(root);
|
|
155
|
+
const ungated = [];
|
|
156
|
+
let checked = 0;
|
|
157
|
+
for (const fileArg of files) {
|
|
158
|
+
const relPath = toRepoRelative(root, fileArg);
|
|
159
|
+
if (relPath === null)
|
|
160
|
+
continue;
|
|
161
|
+
const abs = resolvePath(root, relPath);
|
|
162
|
+
if (!existsSync(abs))
|
|
163
|
+
continue;
|
|
164
|
+
const content = readFileSync(abs, "utf8");
|
|
165
|
+
// verify mode's caller (the workflow) is expected to pass only files
|
|
166
|
+
// already filtered to "added in this diff" -- see
|
|
167
|
+
// templates/github-workflows/pattern-gate.yml.
|
|
168
|
+
if (!isGatedComponentFile(relPath, content, true))
|
|
169
|
+
continue;
|
|
170
|
+
checked++;
|
|
171
|
+
const hasReceipt = receipts.some((r) => normalize(r.file_path) === relPath);
|
|
172
|
+
if (!hasReceipt)
|
|
173
|
+
ungated.push(relPath);
|
|
174
|
+
}
|
|
175
|
+
if (ungated.length > 0) {
|
|
176
|
+
emit({
|
|
177
|
+
ok: false,
|
|
178
|
+
ungated_files: ungated,
|
|
179
|
+
reason: "One or more new UI components have no matching .pattern/receipts/*.json entry.",
|
|
180
|
+
}, false);
|
|
181
|
+
}
|
|
182
|
+
emit({ ok: true, checked }, true);
|
|
183
|
+
}
|
|
184
|
+
async function main() {
|
|
185
|
+
const { mode, flags, files } = parseArgs(process.argv.slice(2));
|
|
186
|
+
const root = typeof flags["project-root"] === "string" ? flags["project-root"] : process.cwd();
|
|
187
|
+
if (mode === "write") {
|
|
188
|
+
await runWrite(root, flags);
|
|
189
|
+
}
|
|
190
|
+
else if (mode === "verify") {
|
|
191
|
+
await runVerify(root, files);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
process.stderr.write("Usage: pattern-check-gate write --file <path> [--is-new] --project-id <id> [--project-root <root>] (content on stdin)\n");
|
|
195
|
+
process.stderr.write(" pattern-check-gate verify --files <path...> [--project-root <root>]\n");
|
|
196
|
+
process.exit(2);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
main().catch((err) => {
|
|
200
|
+
process.stderr.write(`pattern-check-gate crashed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
201
|
+
process.exit(2);
|
|
202
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Shared classifier for the enforcement-boundary feature (hook + CI gate).
|
|
2
|
+
// Used identically by check-gate.ts's write mode (the local PreToolUse
|
|
3
|
+
// hook) and verify mode (the CI check) -- lives in its own module,
|
|
4
|
+
// deliberately with zero dependency on index.ts, so the two call sites can
|
|
5
|
+
// never silently drift on what counts as "a non-trivial new UI component."
|
|
6
|
+
// This is host-agnostic and opt-in: nothing here runs unless a consuming
|
|
7
|
+
// repo explicitly wires up the hook template or the workflow template --
|
|
8
|
+
// Pattern's core MCP tools (recommend_component, record_component_decision,
|
|
9
|
+
// etc.) are unaffected either way, so Codex/Cursor/any other MCP host keeps
|
|
10
|
+
// working exactly as before.
|
|
11
|
+
const GATED_EXTENSIONS = new Set([".tsx", ".jsx"]);
|
|
12
|
+
// Deliberately NOT a "non-trivial" threshold -- an earlier version used
|
|
13
|
+
// 15 here specifically to auto-exempt "trivial" files, and a real,
|
|
14
|
+
// 14-non-blank-line component (a labeled progress-bar widget) slipped
|
|
15
|
+
// through ungated in end-to-end testing on 2026-09-11 as a direct result.
|
|
16
|
+
// Any fixed line-count threshold used as an exemption has this problem by
|
|
17
|
+
// construction: there's always a real component sitting just under
|
|
18
|
+
// whatever number you pick, and tuning the number only moves the
|
|
19
|
+
// boundary to a different real component, it doesn't close the class of
|
|
20
|
+
// bug. This floor exists ONLY to exclude degenerate non-components (a
|
|
21
|
+
// bare re-export line, an empty file) -- genuine trivial-but-real
|
|
22
|
+
// components are meant to go through the manual override instead
|
|
23
|
+
// (parseManualOverride below), which requires a reason and still leaves
|
|
24
|
+
// a visible, logged receipt, rather than being silently auto-exempted.
|
|
25
|
+
const MIN_NON_BLANK_LINES = 3;
|
|
26
|
+
const COMPONENT_EXPORT_PATTERN = /^export\s+(default\s+)?(function|class)\s+[A-Z]|^export\s+(default\s+)?const\s+[A-Z]\w*\s*[:=]/m;
|
|
27
|
+
// isNewFile is passed in, not derived here -- the local hook knows it via
|
|
28
|
+
// existsSync before the write happens; CI verify mode knows it via the
|
|
29
|
+
// workflow's own `git diff --diff-filter=A` filtering. Keeping that
|
|
30
|
+
// detection out of this module keeps it a pure, easily-tested function.
|
|
31
|
+
export function isGatedComponentFile(filePath, fileContent, isNewFile) {
|
|
32
|
+
if (!isNewFile)
|
|
33
|
+
return false;
|
|
34
|
+
const dot = filePath.lastIndexOf(".");
|
|
35
|
+
if (dot === -1)
|
|
36
|
+
return false;
|
|
37
|
+
if (!GATED_EXTENSIONS.has(filePath.slice(dot)))
|
|
38
|
+
return false;
|
|
39
|
+
if (!COMPONENT_EXPORT_PATTERN.test(fileContent))
|
|
40
|
+
return false;
|
|
41
|
+
const nonBlankLines = fileContent.split("\n").filter((l) => l.trim().length > 0).length;
|
|
42
|
+
return nonBlankLines >= MIN_NON_BLANK_LINES;
|
|
43
|
+
}
|
|
44
|
+
// Per-file escape hatch (Q3 from the enforcement-boundary design): a
|
|
45
|
+
// magic comment with a required reason. The hook and CI both honor this
|
|
46
|
+
// identically because both call this same function -- but it never
|
|
47
|
+
// silently bypasses: check-gate.ts still writes a receipt recording
|
|
48
|
+
// manual_override: true and the reason, so the exception stays visible in
|
|
49
|
+
// the same committed artifact as a normal pass.
|
|
50
|
+
const OVERRIDE_PATTERN = /\/\/\s*pattern-mcp:override\s+reason="([^"]+)"/;
|
|
51
|
+
export function parseManualOverride(fileContent) {
|
|
52
|
+
const match = fileContent.match(OVERRIDE_PATTERN);
|
|
53
|
+
if (!match || !match[1].trim())
|
|
54
|
+
return { overridden: false, reason: null };
|
|
55
|
+
return { overridden: true, reason: match[1].trim() };
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Receipt schema + read/write for the enforcement-boundary feature.
|
|
2
|
+
// Deliberately committed into the CONSUMING repo (e.g. `.pattern/receipts/`
|
|
3
|
+
// at that repo's root) rather than `~/.pattern/` -- this is the one
|
|
4
|
+
// artifact a CI runner can see without any access to the local, homedir-
|
|
5
|
+
// scoped ledger (see SECURITY.md's "not sent anywhere by Pattern itself").
|
|
6
|
+
// One JSON file per feature, git-diffable, not an append-only jsonl --
|
|
7
|
+
// receipts are meant to be read directly out of a small PR diff, not
|
|
8
|
+
// grown forever like the homedir ledger overlays.
|
|
9
|
+
//
|
|
10
|
+
// No dependency on index.ts by design: importing index.ts triggers its
|
|
11
|
+
// module-level MCP-server autostart unless PATTERN_NO_AUTOSTART is set
|
|
12
|
+
// before the import resolves, which a static import from this module
|
|
13
|
+
// could not guarantee. check-gate.ts handles that ordering itself via a
|
|
14
|
+
// dynamic import for the one piece of real reuse it needs
|
|
15
|
+
// (readLedgerEntries/computeSnapshotRef) -- this module stays
|
|
16
|
+
// self-contained.
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
const RECEIPTS_DIR = ".pattern/receipts";
|
|
21
|
+
const ALLOWED_GATE_RECEIPT_KEYS = new Set([
|
|
22
|
+
"schema_version",
|
|
23
|
+
"feature_id",
|
|
24
|
+
"file_path",
|
|
25
|
+
"ledger_entry_id",
|
|
26
|
+
"verdict",
|
|
27
|
+
"chosen_candidate",
|
|
28
|
+
"snapshot_ref",
|
|
29
|
+
"checked_at",
|
|
30
|
+
"manual_override",
|
|
31
|
+
"override_reason",
|
|
32
|
+
]);
|
|
33
|
+
// Same throw-on-unknown-key discipline as index.ts's
|
|
34
|
+
// assertDistilledCandidateShape -- a receipt reaching this function with
|
|
35
|
+
// an extra key is a bug, not something to silently strip, since this
|
|
36
|
+
// shape is the one thing CI trusts without re-deriving it.
|
|
37
|
+
export function assertGateReceiptShape(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
39
|
+
throw new Error("GateReceipt must be a plain object");
|
|
40
|
+
}
|
|
41
|
+
const record = value;
|
|
42
|
+
const extra = Object.keys(record).filter((k) => !ALLOWED_GATE_RECEIPT_KEYS.has(k));
|
|
43
|
+
if (extra.length > 0) {
|
|
44
|
+
throw new Error(`GateReceipt has disallowed key(s): ${extra.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
if (record.schema_version !== 1) {
|
|
47
|
+
throw new Error("GateReceipt.schema_version must be 1");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// feature_id can be caller-supplied (recommend_component's optional
|
|
51
|
+
// feature_id arg) -- never trust it as a bare filename. Sanitizing to a
|
|
52
|
+
// safe character set makes path traversal structurally impossible here
|
|
53
|
+
// without needing index.ts's resolveWithinRoot (see file header).
|
|
54
|
+
function sanitizeFeatureIdForFilename(featureId) {
|
|
55
|
+
return featureId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
56
|
+
}
|
|
57
|
+
// Only used on the manual-override path, which has no ledger entry to
|
|
58
|
+
// derive a feature_id from. Deliberately a separate, local derivation
|
|
59
|
+
// rather than index.ts's deriveFeatureId (not exported, and this only
|
|
60
|
+
// ever needs to be stable for one project_id+file_path pair -- it's never
|
|
61
|
+
// joined against real ledger data).
|
|
62
|
+
export function deriveOverrideFeatureId(projectId, filePath) {
|
|
63
|
+
return createHash("sha256").update(`override::${projectId}::${filePath}`).digest("hex").slice(0, 8);
|
|
64
|
+
}
|
|
65
|
+
export function writeGateReceipt(root, receipt) {
|
|
66
|
+
assertGateReceiptShape(receipt);
|
|
67
|
+
const abs = join(root, RECEIPTS_DIR, `${sanitizeFeatureIdForFilename(receipt.feature_id)}.json`);
|
|
68
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
69
|
+
writeFileSync(abs, JSON.stringify(receipt, null, 2) + "\n", "utf8");
|
|
70
|
+
}
|
|
71
|
+
// verify mode's only read path: it doesn't know a file's feature_id ahead
|
|
72
|
+
// of time (that lives in the local ledger, invisible to CI), so it scans
|
|
73
|
+
// every committed receipt and matches on file_path instead. A malformed
|
|
74
|
+
// receipt is treated as absent, not fatal -- the caller reports the gated
|
|
75
|
+
// file as unreceipted, same as if no file existed at all.
|
|
76
|
+
export function readAllGateReceipts(root) {
|
|
77
|
+
const dirAbs = join(root, RECEIPTS_DIR);
|
|
78
|
+
if (!existsSync(dirAbs))
|
|
79
|
+
return [];
|
|
80
|
+
const receipts = [];
|
|
81
|
+
for (const name of readdirSync(dirAbs)) {
|
|
82
|
+
if (!name.endsWith(".json"))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(join(dirAbs, name), "utf8"));
|
|
86
|
+
assertGateReceiptShape(parsed);
|
|
87
|
+
receipts.push(parsed);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// skip malformed/unreadable receipt
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return receipts;
|
|
94
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -36,8 +36,12 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
36
36
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
37
37
|
import { homedir } from "node:os";
|
|
38
38
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
39
40
|
import { captureApiError, captureRecommendation, getClient as getPostHogClient, installId, printTelemetryNoticeOnce, shutdownTelemetry, TELEMETRY_ENABLED, } from "./telemetry.js";
|
|
40
41
|
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
42
|
+
// Only required for org-scoped keys (not tied to one workspace); unset for
|
|
43
|
+
// legacy workspace-scoped keys, which don't need it.
|
|
44
|
+
export const ANTHROPIC_WORKSPACE_ID = process.env.ANTHROPIC_WORKSPACE_ID;
|
|
41
45
|
// Configurable so Sonnet vs. Haiku can be A/B tested without a code change.
|
|
42
46
|
// Defaults to Sonnet 5. Try MODEL=claude-haiku-4-5-20251001 to test the
|
|
43
47
|
// cheaper tier -- re-run the 5 validated test cases from the product brief
|
|
@@ -185,6 +189,12 @@ const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
|
|
|
185
189
|
// failure-prone surface than "does this one file exist right now" or
|
|
186
190
|
// "what commit is HEAD."
|
|
187
191
|
//
|
|
192
|
+
// resolveWithinRoot/computeSnapshotRef/readLedgerEntries are exported so
|
|
193
|
+
// check-gate.ts (the enforcement-boundary CLI, see that file) can reuse
|
|
194
|
+
// this exact scoping rather than growing a second, parallel fs/git-access
|
|
195
|
+
// surface -- it imports these dynamically, after setting
|
|
196
|
+
// PATTERN_NO_AUTOSTART, the same convention scripts/*.mjs already use.
|
|
197
|
+
//
|
|
188
198
|
// Defaults to process.cwd() -- for a locally-run stdio MCP server, that's
|
|
189
199
|
// normally the consuming repo's root, since MCP hosts typically launch
|
|
190
200
|
// the server with the project directory as its working directory. When
|
|
@@ -198,7 +208,7 @@ const PROJECT_ROOT = process.env.PATTERN_PROJECT_ROOT ?? process.cwd();
|
|
|
198
208
|
// to "unknown" rather than silently stat-ing something outside the
|
|
199
209
|
// project. Returns null (never throws) on anything that doesn't resolve
|
|
200
210
|
// cleanly inside root.
|
|
201
|
-
function resolveWithinRoot(root, relPath) {
|
|
211
|
+
export function resolveWithinRoot(root, relPath) {
|
|
202
212
|
if (!relPath || isAbsolute(relPath))
|
|
203
213
|
return null;
|
|
204
214
|
const resolved = resolve(root, relPath);
|
|
@@ -213,7 +223,7 @@ function resolveWithinRoot(root, relPath) {
|
|
|
213
223
|
// rather than failing the judgment call that triggered this write (see
|
|
214
224
|
// buildLedgerEntry). Read-only: `git rev-parse HEAD` never touches repo
|
|
215
225
|
// state.
|
|
216
|
-
function computeSnapshotRef(root) {
|
|
226
|
+
export function computeSnapshotRef(root) {
|
|
217
227
|
try {
|
|
218
228
|
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
219
229
|
cwd: root,
|
|
@@ -382,6 +392,7 @@ async function streamAnthropicMessage(body) {
|
|
|
382
392
|
"content-type": "application/json",
|
|
383
393
|
"x-api-key": ANTHROPIC_API_KEY,
|
|
384
394
|
"anthropic-version": "2023-06-01",
|
|
395
|
+
...(ANTHROPIC_WORKSPACE_ID ? { "anthropic-workspace-id": ANTHROPIC_WORKSPACE_ID } : {}),
|
|
385
396
|
},
|
|
386
397
|
body: JSON.stringify({ ...body, stream: true }),
|
|
387
398
|
});
|
|
@@ -2201,7 +2212,7 @@ function backfillLedgerSnapshotRefs(input) {
|
|
|
2201
2212
|
// but line-oriented (JSONL) rather than whole-file JSON -- a single
|
|
2202
2213
|
// corrupted line (e.g. a hand-edited file, or a write that got cut off)
|
|
2203
2214
|
// is skipped rather than failing the whole read.
|
|
2204
|
-
function readLedgerEntries(projectId) {
|
|
2215
|
+
export function readLedgerEntries(projectId) {
|
|
2205
2216
|
let raw;
|
|
2206
2217
|
try {
|
|
2207
2218
|
raw = readFileSync(LEDGER_PATH, "utf8");
|
|
@@ -3338,7 +3349,12 @@ export function extractJson(text) {
|
|
|
3338
3349
|
return text;
|
|
3339
3350
|
return text.slice(start, end + 1);
|
|
3340
3351
|
}
|
|
3341
|
-
|
|
3352
|
+
// Read from package.json rather than hardcoding, so $mcp_server_version in
|
|
3353
|
+
// PostHog's MCP tool-call analytics (and any client that reads the MCP
|
|
3354
|
+
// initialize response) reflects the version actually installed instead of
|
|
3355
|
+
// staying frozen at whatever it was when this line was first written.
|
|
3356
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
3357
|
+
const server = new Server({ name: "pattern-mcp", version: PACKAGE_VERSION }, { capabilities: { tools: {} } });
|
|
3342
3358
|
// Standard MCP tool-call analytics (tool name, duration, success/failure,
|
|
3343
3359
|
// unique installs/sessions) via PostHog's own MCP SDK -- separate from
|
|
3344
3360
|
// this file's captureRecommendation()/captureApiError() calls, which carry
|
package/dist/staged/anthropic.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// four independent calls. Deliberately separate from src/index.ts's own
|
|
3
3
|
// fetch logic (which is tuned for the single bundled call) rather than
|
|
4
4
|
// reused, since each stage here has a different tool/budget shape.
|
|
5
|
-
import { ANTHROPIC_API_KEY, MODEL, extractJson, extractUrlsForDomain } from "../index.js";
|
|
5
|
+
import { ANTHROPIC_API_KEY, ANTHROPIC_WORKSPACE_ID, MODEL, extractJson, extractUrlsForDomain } from "../index.js";
|
|
6
6
|
export async function callAnthropic({ systemPrompt, userMessage, tools, maxTokens = 4096 }) {
|
|
7
7
|
if (!ANTHROPIC_API_KEY) {
|
|
8
8
|
throw new Error("ANTHROPIC_API_KEY is not set. Export it in the environment running this pipeline.");
|
|
@@ -13,6 +13,7 @@ export async function callAnthropic({ systemPrompt, userMessage, tools, maxToken
|
|
|
13
13
|
"content-type": "application/json",
|
|
14
14
|
"x-api-key": ANTHROPIC_API_KEY,
|
|
15
15
|
"anthropic-version": "2023-06-01",
|
|
16
|
+
...(ANTHROPIC_WORKSPACE_ID ? { "anthropic-workspace-id": ANTHROPIC_WORKSPACE_ID } : {}),
|
|
16
17
|
},
|
|
17
18
|
body: JSON.stringify({
|
|
18
19
|
model: MODEL,
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pattern-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "MCP server that turns your design guidance into a checkable process -- evaluates UI components from external libraries (shadcn/ui, 21st.dev, ReUI) or your own registered design system against a requirements checklist, then tells the agent whether to reuse an existing component or build one from a concrete design reference.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"pattern-mcp": "dist/index.js"
|
|
8
|
+
"pattern-mcp": "dist/index.js",
|
|
9
|
+
"pattern-check-gate": "dist/check-gate.js"
|
|
9
10
|
},
|
|
10
11
|
"author": "Don Richard",
|
|
11
12
|
"license": "MIT",
|
|
@@ -28,7 +29,8 @@
|
|
|
28
29
|
],
|
|
29
30
|
"files": [
|
|
30
31
|
"dist",
|
|
31
|
-
"README.md"
|
|
32
|
+
"README.md",
|
|
33
|
+
"templates"
|
|
32
34
|
],
|
|
33
35
|
"scripts": {
|
|
34
36
|
"build": "tsc",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Edit|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "env PATTERN_PROJECT_ID=your-project-id node ./node_modules/pattern-mcp/templates/hooks/check-gate-hook.mjs",
|
|
10
|
+
"timeout": 60
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Pattern gate
|
|
2
|
+
|
|
3
|
+
# Required-check template for the enforcement boundary (see BACKLOG.md's
|
|
4
|
+
# "Enforcement boundary: hook + CI gate" entry). Copy this into your own
|
|
5
|
+
# repo's .github/workflows/ and mark the "pattern-gate" job required in
|
|
6
|
+
# branch protection. Opt-in -- pattern-mcp never installs this for you.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately does NOT need a GITHUB_TOKEN or any secret: it only reads
|
|
9
|
+
# the receipt files already committed in this PR's diff
|
|
10
|
+
# (.pattern/receipts/*.json), which were written locally by the
|
|
11
|
+
# PreToolUse hook (see ../hooks/check-gate-hook.mjs) when the gate
|
|
12
|
+
# passed. It never touches ~/.pattern/ledger.jsonl, which isn't reachable
|
|
13
|
+
# from a CI runner at all -- see gate-receipt.ts for why the receipt file
|
|
14
|
+
# is the artifact of record here, not a posted PR comment.
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
pull_request:
|
|
18
|
+
branches: [main]
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
pattern-gate:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v6
|
|
28
|
+
with:
|
|
29
|
+
fetch-depth: 0
|
|
30
|
+
- uses: actions/setup-node@v6
|
|
31
|
+
with:
|
|
32
|
+
node-version: "20"
|
|
33
|
+
- name: Compute added files
|
|
34
|
+
id: diff
|
|
35
|
+
run: |
|
|
36
|
+
ADDED=$(git diff --name-only --diff-filter=A "origin/${{ github.base_ref }}...HEAD")
|
|
37
|
+
echo "files<<EOF" >> "$GITHUB_OUTPUT"
|
|
38
|
+
echo "$ADDED" >> "$GITHUB_OUTPUT"
|
|
39
|
+
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
40
|
+
- name: Run pattern-check-gate verify
|
|
41
|
+
if: steps.diff.outputs.files != ''
|
|
42
|
+
run: npx --yes pattern-check-gate verify --files ${{ steps.diff.outputs.files }} --project-root "$PWD"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude-Code-specific PreToolUse adapter for pattern-check-gate. Copy this
|
|
3
|
+
// file into your repo (or reference it from node_modules/pattern-mcp) and
|
|
4
|
+
// wire it up via .claude/settings.json -- see
|
|
5
|
+
// ../claude-settings/settings.json for the exact hook config. Not
|
|
6
|
+
// installed automatically by pattern-mcp; this is opt-in (see
|
|
7
|
+
// BACKLOG.md's "Enforcement boundary: hook + CI gate" entry for why).
|
|
8
|
+
//
|
|
9
|
+
// Reads the PreToolUse stdin JSON, shells out to `pattern-check-gate
|
|
10
|
+
// write`, and translates the result into the hook's blocking contract:
|
|
11
|
+
// a JSON object on stdout with permissionDecision "deny" blocks the tool
|
|
12
|
+
// call and feeds the reason back to Claude as retryable guidance (not a
|
|
13
|
+
// hard turn failure); exiting 0 with no output allows it.
|
|
14
|
+
//
|
|
15
|
+
// Requires a PATTERN_PROJECT_ID env var (or --project-id below) -- set it
|
|
16
|
+
// in the hook's own "command" (e.g. via `env PATTERN_PROJECT_ID=my-app
|
|
17
|
+
// node ...`) or export it in your shell profile for local dev.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
if (process.env.PATTERN_NO_ENFORCEMENT_HOOK) {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
28
|
+
const toolName = input.tool_name;
|
|
29
|
+
if (toolName !== "Write" && toolName !== "Edit") {
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const filePath = input.tool_input?.file_path;
|
|
34
|
+
if (!filePath) process.exit(0);
|
|
35
|
+
|
|
36
|
+
// isNewFile is determined here, at hook time, before the write happens
|
|
37
|
+
// -- this is the one piece of Claude-Code-specific state check-gate.ts
|
|
38
|
+
// itself doesn't have access to. Edit calls always target an existing
|
|
39
|
+
// file, so isNewFile is always false for them, which is why they're
|
|
40
|
+
// effectively never gated by this hook (see component-gate.ts's
|
|
41
|
+
// comment on isNewFile) -- the initial Write that creates the file is
|
|
42
|
+
// the highest-signal moment.
|
|
43
|
+
const isNewFile = !existsSync(filePath);
|
|
44
|
+
const content = toolName === "Write" ? input.tool_input?.content ?? "" : "";
|
|
45
|
+
|
|
46
|
+
const projectId = process.env.PATTERN_PROJECT_ID;
|
|
47
|
+
if (!projectId) {
|
|
48
|
+
// Fail open with a clear stderr note rather than blocking every write
|
|
49
|
+
// in a repo that hasn't configured this yet -- misconfiguration
|
|
50
|
+
// shouldn't look identical to "no ledger entry found."
|
|
51
|
+
process.stderr.write("check-gate-hook: PATTERN_PROJECT_ID not set, skipping gate\n");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const args = ["write", "--file", filePath, "--project-id", projectId, "--project-root", input.cwd ?? process.cwd()];
|
|
56
|
+
if (isNewFile) args.push("--is-new");
|
|
57
|
+
|
|
58
|
+
const result = spawnSync("npx", ["pattern-check-gate", ...args], {
|
|
59
|
+
input: content,
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
timeout: 30000,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse((result.stdout || "").trim());
|
|
67
|
+
} catch {
|
|
68
|
+
// If pattern-check-gate itself crashed or produced no parseable
|
|
69
|
+
// output, fail open (allow) rather than blocking on a plumbing bug --
|
|
70
|
+
// stderr still carries the detail for debugging.
|
|
71
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (parsed.ok === false) {
|
|
76
|
+
process.stdout.write(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
hookSpecificOutput: {
|
|
79
|
+
hookEventName: "PreToolUse",
|
|
80
|
+
permissionDecision: "deny",
|
|
81
|
+
permissionDecisionReason: parsed.reason ?? "pattern-check-gate blocked this file.",
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|