pattern-mcp 0.9.1 → 0.11.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 CHANGED
@@ -15,6 +15,11 @@ design reference.
15
15
 
16
16
  [Website](https://usepattern.sh) · [npm](https://www.npmjs.com/package/pattern-mcp) · [Report an issue](https://github.com/donaldrichard19-LVD/pattern-mcp/issues/new/choose)
17
17
 
18
+ **Current release: v0.10.0** — adds an opt-in enforcement boundary (a
19
+ `PreToolUse` hook plus a paired CI check) so a new component decision can
20
+ be required, not just logged. See [Enforcement boundary: hook + CI
21
+ gate](#enforcement-boundary-hook--ci-gate).
22
+
18
23
  <details>
19
24
  <summary><strong>Contents</strong> (click to expand)</summary>
20
25
 
@@ -22,6 +27,7 @@ design reference.
22
27
  - **Make the judgment call:** [`recommend_component`](#tool-recommend_component) · [`extract_requirements`](#tool-extract_requirements)
23
28
  - **Track cost and outcome:** [`record_component_decision`](#tool-record_component_decision) · [`read_ledger`](#tool-read_ledger) · [`report_build_cost`](#tool-report_build_cost) · [`report_outcome_proxy`](#tool-report_outcome_proxy) · [Feature cost attribution](#feature-cost-attribution) · [Outcome proxies](#outcome-proxies) · [Per-project judgment ledger](#per-project-judgment-ledger)
24
29
  - **Verify and export old decisions:** [`check_ledger_liveness`](#tool-check_ledger_liveness) · [`sweep_ledger_liveness`](#tool-sweep_ledger_liveness) · [`export_ledger_provenance`](#tool-export_ledger_provenance) · [`backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref) · [`post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github) · [Ledger integrity and decision provenance](#ledger-integrity-and-decision-provenance) (design overview — start here for how the five fit together)
30
+ - [Enforcement boundary: hook + CI gate](#enforcement-boundary-hook--ci-gate) (new in v0.10.0 — require the call, don't just log it)
25
31
  - [Per-project decision memory](#per-project-decision-memory) · [Security and privacy](#security-and-privacy) · [Telemetry](#telemetry)
26
32
  - **Cost:** [The `_meta` field](#the-_meta-field) · [Prompt caching](#prompt-caching) · [Measured cache and fetch behavior](#measured-cache-and-fetch-behavior) · [Search limits](#search-limits) · [Ensemble cost](#ensemble-cost-boundary-risk-cases-only) · [Session call cap](#session-call-cap)
27
33
  - [Local call log](#local-call-log) · [Known limitations](#known-limitations)
@@ -31,7 +37,7 @@ design reference.
31
37
  ## Install
32
38
 
33
39
  ```bash
34
- npm install pattern-mcp
40
+ npx pattern-mcp
35
41
  ```
36
42
 
37
43
  See [Quick Start](#quick-start) below to add your Anthropic API key and connect
@@ -224,11 +230,11 @@ threshold.
224
230
  ### 1. Install
225
231
 
226
232
  ```bash
227
- npm install pattern-mcp
233
+ npx pattern-mcp
228
234
  ```
229
235
 
230
- This installs the `pattern-mcp` command via `npx` (or your project's
231
- local `node_modules/.bin`), used in the client configs below.
236
+ `npx` runs the `pattern-mcp` command on demand without a separate install
237
+ step, used in the client configs below.
232
238
 
233
239
  <details>
234
240
  <summary>Build from source instead</summary>
@@ -1477,14 +1483,21 @@ Two gaps in the ledger, surfaced from user feedback: it tracks that a
1477
1483
  decision was made, but not whether the thing it decided about is still
1478
1484
  live in your codebase, and it stores the checklist/verdict but not a
1479
1485
  version pin or an exportable artifact you can attach to a PR or issue.
1480
- Both are now fully addressed, across five tools:
1481
- [`check_ledger_liveness`](#tool-check_ledger_liveness) and
1482
- [`sweep_ledger_liveness`](#tool-sweep_ledger_liveness) close the first gap;
1483
- [`export_ledger_provenance`](#tool-export_ledger_provenance),
1484
- [`backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref), and
1486
+ Both are now fully addressed, across five tools -- **old decisions can be
1487
+ checked, not just logged**: [`check_ledger_liveness`](#tool-check_ledger_liveness)
1488
+ verifies that the file where a decision was implemented still exists and
1489
+ still uses the recommended component, marking it an orphaned entry if it
1490
+ doesn't ([`sweep_ledger_liveness`](#tool-sweep_ledger_liveness) is the
1491
+ batch/scheduled version of the same check); **decisions can become
1492
+ shareable records**: [`export_ledger_provenance`](#tool-export_ledger_provenance)
1493
+ turns a decision into a self-contained Markdown record, and
1485
1494
  [`post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github)
1486
- close the second. See `pattern-ledger-integrity-and-provenance-spec.md`
1487
- for the original phased plan this was built against.
1495
+ can attach it directly to the relevant PR or issue; and **older decisions
1496
+ aren't left behind**: [`backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref)
1497
+ adds a `snapshot_ref` to decisions created before this feature existed, so
1498
+ the liveness check above works retroactively. See
1499
+ `pattern-ledger-integrity-and-provenance-spec.md` for the original phased
1500
+ plan this was built against.
1488
1501
 
1489
1502
  **This required the one deliberate exception** to Pattern otherwise having
1490
1503
  [no filesystem/git access to your repo](#per-project-judgment-ledger) at
@@ -1576,6 +1589,91 @@ the source line, most recent record wins at read time" convention as
1576
1589
  layered onto `ledger.jsonl`'s own entries at read time -- the ledger line
1577
1590
  itself is never rewritten.
1578
1591
 
1592
+ ## Enforcement boundary: hook + CI gate
1593
+
1594
+ **Decisions can be enforced, not just tracked.** An opt-in `PreToolUse`
1595
+ hook can block a new component from being written until a matching
1596
+ ledger entry exists; a paired GitHub Action can also fail the PR if that
1597
+ decision record isn't committed alongside the code.
1598
+
1599
+ **The gap this closes:** SKILL.md instructs the calling agent to call
1600
+ `recommend_component` before scaffolding a new, non-trivial UI component,
1601
+ but nothing before this feature *enforced* that -- an agent could simply
1602
+ skip the call, and nothing server-side would know. This is opt-in and
1603
+ Claude-Code-specific for the hook half; a consuming repo that never wires
1604
+ either piece up gets Pattern exactly as it worked before, and any other
1605
+ MCP host (Cursor, Codex, etc.) is entirely unaffected either way.
1606
+
1607
+ **Set it up with one command:**
1608
+
1609
+ ```bash
1610
+ npx pattern-check-gate init
1611
+ ```
1612
+
1613
+ Confirms each step independently rather than one blanket "proceed?", and
1614
+ never auto-commits -- review with `git status`/`git diff` and commit
1615
+ yourself when ready:
1616
+
1617
+ 1. Confirms a project id (pre-filled from `package.json`'s `name`, or
1618
+ your git remote/directory name -- accept it or type your own).
1619
+ 2. Writes or merges `.claude/settings.json` -- if one already exists, it
1620
+ parses it, leaves any unrelated hooks untouched, and only appends the
1621
+ `PreToolUse` entry if it isn't already there (safe to rerun).
1622
+ 3. Writes `.github/workflows/pattern-gate.yml`, if a GitHub remote is
1623
+ detected and the file doesn't already exist with different content
1624
+ (never silently overwritten).
1625
+ 4. Asks, as its own explicit yes/no: **mark the check required in branch
1626
+ protection?** Needs `gh` installed and authenticated with admin rights
1627
+ on the repo; skips with clear next steps otherwise. Deliberately only
1628
+ offered when no branch protection exists yet on the default branch --
1629
+ GitHub's branch-protection API replaces the *entire* configuration on
1630
+ write, not just the required-checks list, so this refuses to guess at
1631
+ merging into whatever you already have rather than risk silently
1632
+ dropping an unrelated setting (e.g. required PR reviews). If
1633
+ protection already exists, add `pattern-gate` to it by hand instead.
1634
+
1635
+ Run non-interactively with `--yes` (accepts every safe default; branch
1636
+ protection is never auto-confirmed even then -- it's the one step that
1637
+ reaches outside your local filesystem into real, shared GitHub config).
1638
+
1639
+ **Or set it up by hand**, two pieces, neither installed automatically:
1640
+
1641
+ - **`.claude/settings.json`** wired to run `npx --yes
1642
+ pattern-check-gate-hook` on `PreToolUse` (see
1643
+ `templates/claude-settings/settings.json` for the exact shape) -- a
1644
+ Claude Code hook that runs on `Write`/`Edit` calls. For a genuinely new
1645
+ `.tsx`/`.jsx` file that exports a non-trivial component, it looks up a
1646
+ ledger entry (via `~/.pattern/ledger.jsonl`, same as everywhere else in
1647
+ Pattern) whose `file_path` matches the file being written. A match
1648
+ writes a receipt and allows the write; no match blocks it with a reason
1649
+ fed back to the model as retryable guidance, not a hard failure.
1650
+ **This is the one new exception where Pattern writes into your repo**
1651
+ (`.pattern/receipts/<feature_id>.json`) -- everything else described in
1652
+ this README is read-only. `project_id` no longer needs to be set by
1653
+ hand either -- it's derived the same way `init` pre-fills it (see
1654
+ `src/project-id.ts`); set `PATTERN_PROJECT_ID` only to override that.
1655
+ - **`templates/github-workflows/pattern-gate.yml`** -- a required PR
1656
+ check that reads the same receipt files back out of the diff. It never
1657
+ touches `~/.pattern/` (not reachable from a CI runner) and needs no
1658
+ `GITHUB_TOKEN` -- it trusts the committed receipt as the artifact of
1659
+ record, the same way it would trust a committed test fixture.
1660
+
1661
+ The join between the two depends on `file_path` being passed to
1662
+ `recommend_component`/`record_component_decision` -- if it's omitted, the
1663
+ gate has nothing to match against and fails closed (blocks) rather than
1664
+ guessing. Pass `file_path` whenever you know it.
1665
+
1666
+ An escape hatch exists for both a whole-hook kill switch
1667
+ (`PATTERN_NO_ENFORCEMENT_HOOK`, local only -- does not affect the CI
1668
+ check) and a per-file override (a `// pattern-mcp:override reason="..."`
1669
+ comment) -- the override still writes a receipt recording
1670
+ `manual_override: true` and the reason, so it stays visible rather than
1671
+ silent. See `src/component-gate.ts`, `src/gate-receipt.ts`,
1672
+ `src/check-gate.ts` (the `pattern-check-gate` CLI, this project's first
1673
+ entry point separate from the stdio MCP server), `src/check-gate-hook.ts`,
1674
+ and `src/init-enforcement.ts` for the implementation, and BACKLOG.md's
1675
+ "Enforcement boundary" entries for the fuller design writeup.
1676
+
1579
1677
  ## Per-project decision memory
1580
1678
 
1581
1679
  Pattern stores confirmed decisions locally in:
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // pattern-check-gate-hook -- the Claude-Code-specific PreToolUse adapter.
3
+ // Wired up via .claude/settings.json (see templates/claude-settings/settings.json,
4
+ // or generated automatically by `pattern-check-gate init`). Not installed
5
+ // automatically by pattern-mcp itself -- opt-in, per BACKLOG.md's
6
+ // "Enforcement boundary: hook + CI gate" entry.
7
+ //
8
+ // A real bin entry (not a template file to hand-copy) so it resolves the
9
+ // same way whether pattern-mcp is a real devDependency in the consuming
10
+ // repo's node_modules or only ever fetched ad hoc via npx -- both cases
11
+ // invoke it as `npx --yes pattern-check-gate-hook`, npx's own caching
12
+ // handles the rest. This also makes it testable the same way as
13
+ // check-gate.ts itself (spawned as a real subprocess in
14
+ // scripts/verify-check-gate.mjs), rather than living outside the build.
15
+ //
16
+ // Reads the PreToolUse stdin JSON, shells out to `pattern-check-gate
17
+ // write`, and translates the result into the hook's blocking contract: a
18
+ // JSON object on stdout with permissionDecision "deny" blocks the tool
19
+ // call and feeds the reason back to Claude as retryable guidance (not a
20
+ // hard turn failure); exiting 0 with no output allows it.
21
+ //
22
+ // --project-id is intentionally omitted unless PATTERN_PROJECT_ID is set
23
+ // -- pattern-check-gate derives one itself (package.json name, then git
24
+ // remote, then directory name) when it's not passed. See project-id.ts.
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { spawnSync } from "node:child_process";
27
+ async function main() {
28
+ if (process.env.PATTERN_NO_ENFORCEMENT_HOOK) {
29
+ process.exit(0);
30
+ }
31
+ const input = JSON.parse(readFileSync(0, "utf8"));
32
+ const toolName = input.tool_name;
33
+ if (toolName !== "Write" && toolName !== "Edit") {
34
+ process.exit(0);
35
+ }
36
+ const filePath = input.tool_input?.file_path;
37
+ if (!filePath)
38
+ process.exit(0);
39
+ // isNewFile is determined here, at hook time, before the write happens
40
+ // -- the one piece of Claude-Code-specific state pattern-check-gate
41
+ // itself doesn't have access to. Edit calls always target an existing
42
+ // file, so isNewFile is always false for them -- the initial Write
43
+ // that creates the file is the highest-signal moment.
44
+ const isNewFile = !existsSync(filePath);
45
+ const content = toolName === "Write" ? (input.tool_input?.content ?? "") : "";
46
+ const root = input.cwd ?? process.cwd();
47
+ const args = ["write", "--file", filePath, "--project-root", root];
48
+ if (process.env.PATTERN_PROJECT_ID) {
49
+ args.push("--project-id", process.env.PATTERN_PROJECT_ID);
50
+ }
51
+ if (isNewFile)
52
+ args.push("--is-new");
53
+ const result = spawnSync("npx", ["--yes", "pattern-check-gate", ...args], {
54
+ input: content,
55
+ encoding: "utf8",
56
+ timeout: 30000,
57
+ });
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse((result.stdout || "").trim());
61
+ }
62
+ catch {
63
+ // If pattern-check-gate itself crashed or produced no parseable
64
+ // output, fail open (allow) rather than blocking on a plumbing bug --
65
+ // stderr still carries the detail for debugging.
66
+ if (result.stderr)
67
+ process.stderr.write(result.stderr);
68
+ process.exit(0);
69
+ }
70
+ if (parsed.ok === false) {
71
+ process.stdout.write(JSON.stringify({
72
+ hookSpecificOutput: {
73
+ hookEventName: "PreToolUse",
74
+ permissionDecision: "deny",
75
+ permissionDecisionReason: parsed.reason ?? "pattern-check-gate blocked this file.",
76
+ },
77
+ }));
78
+ }
79
+ process.exit(0);
80
+ }
81
+ main();
@@ -0,0 +1,219 @@
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
+ // Three modes. write/verify share one classifier (component-gate.ts) so
6
+ // the local hook and the CI check can never silently drift on what
7
+ // counts as "gated":
8
+ //
9
+ // write -- run locally (by check-gate-hook.ts) where
10
+ // ~/.pattern/ledger.jsonl is reachable. Looks up a ledger
11
+ // entry whose file_path matches the file being written; on a
12
+ // match (or a manual override), writes a receipt into the
13
+ // CONSUMING repo at .pattern/receipts/<feature_id>.json and
14
+ // exits 0. No match, no override -> exits 1 and blocks.
15
+ // --project-id is optional -- see project-id.ts (Option A).
16
+ //
17
+ // verify -- run in CI, where ~/.pattern/ is never reachable. Trusts the
18
+ // committed receipt as the artifact of record instead of
19
+ // re-deriving anything from the ledger -- fails if a gated
20
+ // file in the diff has no matching receipt.
21
+ //
22
+ // init -- Option C: a guided setup that writes/merges
23
+ // .claude/settings.json and the workflow file, and can
24
+ // optionally configure branch protection via `gh`. See
25
+ // init-enforcement.ts; this file only dispatches to it.
26
+ //
27
+ // No CLI-parsing or git-wrapper dependency, matching this project's
28
+ // existing minimal-dependency posture (index.ts shells out to fixed git
29
+ // subcommands rather than a library) -- argv is parsed by hand below.
30
+ //
31
+ // This is the project's first standalone CLI entry point separate from
32
+ // the stdio MCP server (see package.json's new "pattern-check-gate" bin
33
+ // entry) -- entirely opt-in. A consuming repo that never installs the
34
+ // hook template or the workflow template never invokes this file, and
35
+ // Pattern's core MCP tools are unaffected either way.
36
+ import { existsSync, readFileSync } from "node:fs";
37
+ import { isAbsolute, relative, resolve as resolvePath } from "node:path";
38
+ import { isGatedComponentFile, parseManualOverride } from "./component-gate.js";
39
+ import { deriveOverrideFeatureId, readAllGateReceipts, writeGateReceipt } from "./gate-receipt.js";
40
+ import { deriveProjectId } from "./project-id.js";
41
+ import { runInit } from "./init-enforcement.js";
42
+ function normalize(p) {
43
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
44
+ }
45
+ // Converts a possibly-absolute file argument into a path relative to
46
+ // root, without ever reading/writing outside root.
47
+ function toRepoRelative(root, fileArg) {
48
+ const abs = isAbsolute(fileArg) ? fileArg : resolvePath(root, fileArg);
49
+ const rel = relative(root, abs);
50
+ if (rel.startsWith("..") || isAbsolute(rel))
51
+ return null;
52
+ return normalize(rel);
53
+ }
54
+ function parseArgs(argv) {
55
+ const mode = argv[0];
56
+ const flags = {};
57
+ const files = [];
58
+ for (let i = 1; i < argv.length; i++) {
59
+ const arg = argv[i];
60
+ if (arg === "--files") {
61
+ // --files (verify mode) consumes every following non-flag token
62
+ i++;
63
+ while (i < argv.length && !argv[i].startsWith("--")) {
64
+ files.push(argv[i]);
65
+ i++;
66
+ }
67
+ i--;
68
+ }
69
+ else if (arg.startsWith("--")) {
70
+ const key = arg.slice(2);
71
+ const next = argv[i + 1];
72
+ if (next !== undefined && !next.startsWith("--")) {
73
+ flags[key] = next;
74
+ i++;
75
+ }
76
+ else {
77
+ flags[key] = true;
78
+ }
79
+ }
80
+ }
81
+ return { mode, flags, files };
82
+ }
83
+ function readStdin() {
84
+ return new Promise((resolveP, reject) => {
85
+ let data = "";
86
+ process.stdin.setEncoding("utf8");
87
+ process.stdin.on("data", (chunk) => (data += chunk));
88
+ process.stdin.on("end", () => resolveP(data));
89
+ process.stdin.on("error", reject);
90
+ });
91
+ }
92
+ function emit(result, ok) {
93
+ process.stdout.write(JSON.stringify(result) + "\n");
94
+ process.exit(ok ? 0 : 1);
95
+ }
96
+ async function runWrite(root, flags) {
97
+ const fileArg = flags.file;
98
+ if (typeof fileArg !== "string") {
99
+ emit({ ok: false, reason: "write mode requires --file <path>" }, false);
100
+ }
101
+ // Option A: --project-id is now optional -- derive it (package.json
102
+ // name, then git remote, then the directory name) rather than require
103
+ // every caller to know and pass it. An explicit --project-id always
104
+ // wins over the derivation.
105
+ const projectId = typeof flags["project-id"] === "string" ? flags["project-id"] : deriveProjectId(root);
106
+ const relPath = toRepoRelative(root, fileArg);
107
+ if (relPath === null) {
108
+ emit({ ok: false, reason: `--file resolves outside project root: ${fileArg}` }, false);
109
+ }
110
+ const content = await readStdin();
111
+ const isNew = flags["is-new"] === true;
112
+ if (!isGatedComponentFile(relPath, content, isNew)) {
113
+ emit({ ok: true, gated: false }, true);
114
+ }
115
+ const override = parseManualOverride(content);
116
+ const checkedAt = new Date().toISOString();
117
+ // Dynamic import, after nothing has set PATTERN_NO_AUTOSTART yet in
118
+ // this process -- set it now, before index.js's module body runs, same
119
+ // convention scripts/*.mjs already use to import from this file
120
+ // without starting the stdio MCP server as a side effect.
121
+ process.env.PATTERN_NO_AUTOSTART = "1";
122
+ const { readLedgerEntries, computeSnapshotRef } = await import("./index.js");
123
+ const snapshotRef = computeSnapshotRef(root);
124
+ if (override.overridden) {
125
+ const receipt = {
126
+ schema_version: 1,
127
+ feature_id: deriveOverrideFeatureId(projectId, relPath),
128
+ file_path: relPath,
129
+ ledger_entry_id: null,
130
+ verdict: null,
131
+ chosen_candidate: null,
132
+ snapshot_ref: snapshotRef,
133
+ checked_at: checkedAt,
134
+ manual_override: true,
135
+ override_reason: override.reason,
136
+ };
137
+ writeGateReceipt(root, receipt);
138
+ emit({ ok: true, gated: true, manual_override: true, feature_id: receipt.feature_id }, true);
139
+ }
140
+ const entries = readLedgerEntries(projectId);
141
+ const match = entries.find((e) => e.file_path && normalize(e.file_path) === relPath);
142
+ if (!match) {
143
+ emit({
144
+ ok: false,
145
+ gated: true,
146
+ reason: `No recommend_component/record_component_decision entry found with file_path="${relPath}" ` +
147
+ `for project_id="${projectId}". Call recommend_component with file_path set to this exact ` +
148
+ `path before creating it, or add \`// pattern-mcp:override reason="..."\` to the file.`,
149
+ }, false);
150
+ }
151
+ const receipt = {
152
+ schema_version: 1,
153
+ feature_id: match.feature_id,
154
+ file_path: relPath,
155
+ ledger_entry_id: match.id,
156
+ verdict: match.verdict,
157
+ chosen_candidate: match.chosen_candidate,
158
+ snapshot_ref: snapshotRef,
159
+ checked_at: checkedAt,
160
+ manual_override: false,
161
+ override_reason: null,
162
+ };
163
+ writeGateReceipt(root, receipt);
164
+ emit({ ok: true, gated: true, feature_id: receipt.feature_id }, true);
165
+ }
166
+ async function runVerify(root, files) {
167
+ const receipts = readAllGateReceipts(root);
168
+ const ungated = [];
169
+ let checked = 0;
170
+ for (const fileArg of files) {
171
+ const relPath = toRepoRelative(root, fileArg);
172
+ if (relPath === null)
173
+ continue;
174
+ const abs = resolvePath(root, relPath);
175
+ if (!existsSync(abs))
176
+ continue;
177
+ const content = readFileSync(abs, "utf8");
178
+ // verify mode's caller (the workflow) is expected to pass only files
179
+ // already filtered to "added in this diff" -- see
180
+ // templates/github-workflows/pattern-gate.yml.
181
+ if (!isGatedComponentFile(relPath, content, true))
182
+ continue;
183
+ checked++;
184
+ const hasReceipt = receipts.some((r) => normalize(r.file_path) === relPath);
185
+ if (!hasReceipt)
186
+ ungated.push(relPath);
187
+ }
188
+ if (ungated.length > 0) {
189
+ emit({
190
+ ok: false,
191
+ ungated_files: ungated,
192
+ reason: "One or more new UI components have no matching .pattern/receipts/*.json entry.",
193
+ }, false);
194
+ }
195
+ emit({ ok: true, checked }, true);
196
+ }
197
+ async function main() {
198
+ const { mode, flags, files } = parseArgs(process.argv.slice(2));
199
+ const root = typeof flags["project-root"] === "string" ? flags["project-root"] : process.cwd();
200
+ if (mode === "write") {
201
+ await runWrite(root, flags);
202
+ }
203
+ else if (mode === "verify") {
204
+ await runVerify(root, files);
205
+ }
206
+ else if (mode === "init") {
207
+ await runInit(root, { yes: flags.yes === true });
208
+ }
209
+ else {
210
+ process.stderr.write("Usage: pattern-check-gate write --file <path> [--is-new] [--project-id <id>] [--project-root <root>] (content on stdin)\n");
211
+ process.stderr.write(" pattern-check-gate verify --files <path...> [--project-root <root>]\n");
212
+ process.stderr.write(" pattern-check-gate init [--project-root <root>] [--yes]\n");
213
+ process.exit(2);
214
+ }
215
+ }
216
+ main().catch((err) => {
217
+ process.stderr.write(`pattern-check-gate crashed: ${err instanceof Error ? err.message : String(err)}\n`);
218
+ process.exit(2);
219
+ });
@@ -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
@@ -189,6 +189,12 @@ const LEDGER_TTL_DAYS = Number(process.env.PATTERN_LEDGER_TTL_DAYS ?? 30);
189
189
  // failure-prone surface than "does this one file exist right now" or
190
190
  // "what commit is HEAD."
191
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
+ //
192
198
  // Defaults to process.cwd() -- for a locally-run stdio MCP server, that's
193
199
  // normally the consuming repo's root, since MCP hosts typically launch
194
200
  // the server with the project directory as its working directory. When
@@ -202,7 +208,7 @@ const PROJECT_ROOT = process.env.PATTERN_PROJECT_ROOT ?? process.cwd();
202
208
  // to "unknown" rather than silently stat-ing something outside the
203
209
  // project. Returns null (never throws) on anything that doesn't resolve
204
210
  // cleanly inside root.
205
- function resolveWithinRoot(root, relPath) {
211
+ export function resolveWithinRoot(root, relPath) {
206
212
  if (!relPath || isAbsolute(relPath))
207
213
  return null;
208
214
  const resolved = resolve(root, relPath);
@@ -217,7 +223,7 @@ function resolveWithinRoot(root, relPath) {
217
223
  // rather than failing the judgment call that triggered this write (see
218
224
  // buildLedgerEntry). Read-only: `git rev-parse HEAD` never touches repo
219
225
  // state.
220
- function computeSnapshotRef(root) {
226
+ export function computeSnapshotRef(root) {
221
227
  try {
222
228
  const sha = execFileSync("git", ["rev-parse", "HEAD"], {
223
229
  cwd: root,
@@ -2206,7 +2212,7 @@ function backfillLedgerSnapshotRefs(input) {
2206
2212
  // but line-oriented (JSONL) rather than whole-file JSON -- a single
2207
2213
  // corrupted line (e.g. a hand-edited file, or a write that got cut off)
2208
2214
  // is skipped rather than failing the whole read.
2209
- function readLedgerEntries(projectId) {
2215
+ export function readLedgerEntries(projectId) {
2210
2216
  let raw;
2211
2217
  try {
2212
2218
  raw = readFileSync(LEDGER_PATH, "utf8");
@@ -0,0 +1,316 @@
1
+ // Option C from BACKLOG.md's "Enforcement boundary setup" entry: a
2
+ // guided `pattern-check-gate init` that does the mechanical parts of
3
+ // setting up the hook + CI gate (see check-gate-hook.ts,
4
+ // templates/github-workflows/pattern-gate.yml) instead of requiring a
5
+ // hand copy-edit-commit of each piece separately.
6
+ //
7
+ // Every step confirms independently and defaults to the safe choice --
8
+ // this never auto-commits, and the branch-protection step in particular
9
+ // never runs without an explicit, un-implied "yes" (see
10
+ // maybeSetupBranchProtection), matching the same "always confirm, never
11
+ // silently run" treatment Pattern already gives install_command and
12
+ // post_ledger_provenance_to_github.
13
+ import { execFileSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { createInterface } from "node:readline";
18
+ import { deriveProjectId } from "./project-id.js";
19
+ const HOOK_MARKER = "pattern-check-gate-hook";
20
+ // A queue-based prompt helper, not readline/promises' question() --
21
+ // question() only starts listening for a line *after* it's called, but
22
+ // with piped/non-TTY stdin (as in an automated test, or `init | cat`)
23
+ // every line arrives in one synchronous burst, ahead of any await
24
+ // cycle. Confirmed directly: two sequential `rl.question()` calls on a
25
+ // piped `printf 'a\nb\n'` answer only the first and hang forever on the
26
+ // second -- Node even logs "Detected unsettled top-level await" in that
27
+ // repro. The fix is a small always-listening queue: a persistent 'line'
28
+ // listener buffers answers that arrive before they're asked for, so
29
+ // `askLine` either drains an already-buffered answer immediately or
30
+ // waits for the next 'line' event, whichever comes first -- correct for
31
+ // both a real interactive TTY (waiter path) and piped/scripted input
32
+ // (queue path).
33
+ let rl = null;
34
+ const lineQueue = [];
35
+ const waiters = [];
36
+ function ensureRl() {
37
+ if (!rl) {
38
+ rl = createInterface({ input: process.stdin, output: process.stdout });
39
+ rl.on("line", (line) => {
40
+ const waiter = waiters.shift();
41
+ if (waiter)
42
+ waiter(line);
43
+ else
44
+ lineQueue.push(line);
45
+ });
46
+ }
47
+ return rl;
48
+ }
49
+ function askLine(promptStr) {
50
+ ensureRl();
51
+ process.stdout.write(promptStr);
52
+ const queued = lineQueue.shift();
53
+ if (queued !== undefined)
54
+ return Promise.resolve(queued);
55
+ return new Promise((resolve) => waiters.push(resolve));
56
+ }
57
+ function closeRl() {
58
+ rl?.close();
59
+ rl = null;
60
+ }
61
+ async function confirm(question, options, defaultYes) {
62
+ if (options.yes)
63
+ return defaultYes;
64
+ const suffix = defaultYes ? "[Y/n]" : "[y/N]";
65
+ const answer = (await askLine(`${question} ${suffix} `)).trim().toLowerCase();
66
+ if (!answer)
67
+ return defaultYes;
68
+ return answer === "y" || answer === "yes";
69
+ }
70
+ async function promptText(question, defaultValue, options) {
71
+ if (options.yes)
72
+ return defaultValue;
73
+ const answer = (await askLine(`${question} [${defaultValue}]: `)).trim();
74
+ return answer || defaultValue;
75
+ }
76
+ function shellQuote(value) {
77
+ return `'${value.replace(/'/g, `'\\''`)}'`;
78
+ }
79
+ async function setupClaudeSettings(root, projectIdOverride, options) {
80
+ const settingsPath = join(root, ".claude", "settings.json");
81
+ let settings = {};
82
+ let existed = false;
83
+ if (existsSync(settingsPath)) {
84
+ existed = true;
85
+ try {
86
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
87
+ }
88
+ catch {
89
+ console.log(" .claude/settings.json exists but isn't valid JSON -- skipping, fix it manually first.");
90
+ return;
91
+ }
92
+ }
93
+ const preToolUse = settings.hooks?.PreToolUse ?? [];
94
+ const alreadyInstalled = preToolUse.some((entry) => (entry.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)));
95
+ if (alreadyInstalled) {
96
+ console.log(" .claude/settings.json: hook already configured, skipping.");
97
+ return;
98
+ }
99
+ const command = projectIdOverride
100
+ ? `env PATTERN_PROJECT_ID=${shellQuote(projectIdOverride)} npx --yes pattern-check-gate-hook`
101
+ : "npx --yes pattern-check-gate-hook";
102
+ const newEntry = {
103
+ matcher: "Edit|Write",
104
+ hooks: [{ type: "command", command, timeout: 60 }],
105
+ };
106
+ console.log(`\n ${existed ? "Merging into" : "Creating"} .claude/settings.json:`);
107
+ console.log(JSON.stringify(newEntry, null, 2)
108
+ .split("\n")
109
+ .map((l) => ` ${l}`)
110
+ .join("\n"));
111
+ const proceed = await confirm(" Write this?", options, true);
112
+ if (!proceed) {
113
+ console.log(" Skipped.");
114
+ return;
115
+ }
116
+ const merged = {
117
+ ...settings,
118
+ hooks: {
119
+ ...settings.hooks,
120
+ PreToolUse: [...preToolUse, newEntry],
121
+ },
122
+ };
123
+ mkdirSync(dirname(settingsPath), { recursive: true });
124
+ writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
125
+ console.log(" Written.");
126
+ }
127
+ function isGitHubRepo(root) {
128
+ try {
129
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
130
+ cwd: root,
131
+ encoding: "utf8",
132
+ stdio: ["ignore", "pipe", "ignore"],
133
+ timeout: 2000,
134
+ }).trim();
135
+ const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/);
136
+ if (match)
137
+ return { owner: match[1], repo: match[2] };
138
+ }
139
+ catch {
140
+ // no remote, or not git
141
+ }
142
+ return null;
143
+ }
144
+ async function setupWorkflowFile(root, options) {
145
+ const gh = isGitHubRepo(root);
146
+ if (!gh) {
147
+ console.log("\n No GitHub remote detected -- skipping the GitHub Action workflow file.");
148
+ return false;
149
+ }
150
+ const workflowPath = join(root, ".github", "workflows", "pattern-gate.yml");
151
+ const templatePath = fileURLToPath(new URL("../templates/github-workflows/pattern-gate.yml", import.meta.url));
152
+ const templateContent = readFileSync(templatePath, "utf8");
153
+ if (existsSync(workflowPath)) {
154
+ const existing = readFileSync(workflowPath, "utf8");
155
+ if (existing === templateContent) {
156
+ console.log("\n .github/workflows/pattern-gate.yml: already up to date, skipping.");
157
+ return true;
158
+ }
159
+ console.log("\n .github/workflows/pattern-gate.yml already exists with different content.");
160
+ const overwrite = await confirm(" Overwrite it?", options, false);
161
+ if (!overwrite) {
162
+ console.log(" Skipped -- left your existing file untouched.");
163
+ return false;
164
+ }
165
+ }
166
+ else {
167
+ console.log("\n Creating .github/workflows/pattern-gate.yml.");
168
+ const proceed = await confirm(" Write this?", options, true);
169
+ if (!proceed) {
170
+ console.log(" Skipped.");
171
+ return false;
172
+ }
173
+ }
174
+ mkdirSync(dirname(workflowPath), { recursive: true });
175
+ writeFileSync(workflowPath, templateContent, "utf8");
176
+ console.log(" Written.");
177
+ return true;
178
+ }
179
+ function ghCliAvailable() {
180
+ try {
181
+ execFileSync("gh", ["--version"], { stdio: "ignore", timeout: 5000 });
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ function ghAuthenticated() {
189
+ try {
190
+ execFileSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000 });
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
197
+ function getDefaultBranch(gh) {
198
+ try {
199
+ const out = execFileSync("gh", ["api", `repos/${gh.owner}/${gh.repo}`, "--jq", ".default_branch"], {
200
+ encoding: "utf8",
201
+ timeout: 10000,
202
+ }).trim();
203
+ return out || null;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ function stderrOf(err) {
210
+ if (err && typeof err === "object" && "stderr" in err) {
211
+ const stderr = err.stderr;
212
+ if (stderr)
213
+ return String(stderr);
214
+ }
215
+ return "";
216
+ }
217
+ function getExistingProtection(gh, branch) {
218
+ try {
219
+ execFileSync("gh", ["api", `repos/${gh.owner}/${gh.repo}/branches/${branch}/protection`], {
220
+ encoding: "utf8",
221
+ timeout: 10000,
222
+ });
223
+ return "exists";
224
+ }
225
+ catch (err) {
226
+ return stderrOf(err).includes("404") ? "none" : "unknown";
227
+ }
228
+ }
229
+ // Only ever called when getExistingProtection returned "none". The
230
+ // Update Branch Protection endpoint REPLACES the entire protection
231
+ // object, and its GET/PUT schemas differ for several fields
232
+ // (enforce_admins is {enabled} on GET but a bare boolean on PUT, for
233
+ // example) -- verified against GitHub's own REST API docs before writing
234
+ // this. Deliberately does not attempt to merge into pre-existing
235
+ // protection; that case is handled by the caller bailing out to manual
236
+ // instructions instead of risking a wrong reconstruction that silently
237
+ // drops an unrelated setting (e.g. required PR reviews).
238
+ function createMinimalProtection(gh, branch) {
239
+ const body = JSON.stringify({
240
+ required_status_checks: { strict: false, checks: [{ context: "pattern-gate", app_id: -1 }] },
241
+ enforce_admins: false,
242
+ required_pull_request_reviews: null,
243
+ restrictions: null,
244
+ });
245
+ try {
246
+ execFileSync("gh", ["api", "-X", "PUT", `repos/${gh.owner}/${gh.repo}/branches/${branch}/protection`, "--input", "-"], {
247
+ input: body,
248
+ encoding: "utf8",
249
+ timeout: 10000,
250
+ });
251
+ return true;
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ }
257
+ async function maybeSetupBranchProtection(root, options) {
258
+ const gh = isGitHubRepo(root);
259
+ if (!gh)
260
+ return;
261
+ console.log("");
262
+ // Never implied by --yes, and never defaults to yes -- see BACKLOG's
263
+ // "strictest confirmation of the four steps" note. This is the one
264
+ // step that reaches outside the local filesystem into real, shared
265
+ // GitHub config.
266
+ const wantsIt = await confirm(" Mark the pattern-gate check as required in branch protection?", options, false);
267
+ if (!wantsIt) {
268
+ console.log(` Skipped. To do this later, add "pattern-gate" as a required status check in\n` +
269
+ ` https://github.com/${gh.owner}/${gh.repo}/settings/branches`);
270
+ return;
271
+ }
272
+ if (!ghCliAvailable()) {
273
+ console.log(" `gh` CLI not found -- install it (https://cli.github.com) and rerun, or configure manually.");
274
+ return;
275
+ }
276
+ if (!ghAuthenticated()) {
277
+ console.log(" `gh` CLI isn't authenticated -- run `gh auth login` and rerun, or configure manually.");
278
+ return;
279
+ }
280
+ const branch = getDefaultBranch(gh);
281
+ if (!branch) {
282
+ console.log(" Couldn't determine the default branch -- configure manually.");
283
+ return;
284
+ }
285
+ const existing = getExistingProtection(gh, branch);
286
+ if (existing !== "none") {
287
+ console.log(existing === "exists"
288
+ ? ` Branch protection already exists on "${branch}". To avoid overwriting your other protection\n` +
289
+ ` settings (this endpoint replaces the whole configuration, not just required checks), add\n` +
290
+ ` "pattern-gate" to your required status checks manually instead of through this command.`
291
+ : ` Couldn't determine "${branch}"'s current protection state -- configure manually rather than\n` +
292
+ ` risk overwriting settings this command can't see.`);
293
+ return;
294
+ }
295
+ const ok = createMinimalProtection(gh, branch);
296
+ console.log(ok
297
+ ? ` Required "pattern-gate" check added to "${branch}" branch protection.`
298
+ : " Failed to update branch protection -- configure manually.");
299
+ }
300
+ export async function runInit(root, options) {
301
+ console.log("Setting up Pattern's enforcement boundary (hook + CI gate)...\n");
302
+ const derivedId = deriveProjectId(root);
303
+ const projectId = await promptText("Project id", derivedId, options);
304
+ const projectIdOverride = projectId !== derivedId ? projectId : null;
305
+ try {
306
+ await setupClaudeSettings(root, projectIdOverride, options);
307
+ await setupWorkflowFile(root, options);
308
+ await maybeSetupBranchProtection(root, options);
309
+ }
310
+ finally {
311
+ // Always close, even on error -- an open readline interface keeps
312
+ // the process alive waiting on stdin otherwise.
313
+ closeRl();
314
+ }
315
+ console.log("\nDone. Review the changes with `git status` / `git diff`, then commit when ready.");
316
+ }
@@ -0,0 +1,36 @@
1
+ // Option A from BACKLOG.md's "Enforcement boundary setup" entry:
2
+ // auto-derives a project id instead of requiring PATTERN_PROJECT_ID to be
3
+ // hand-set. Shared by check-gate.ts's write mode (runtime fallback when
4
+ // --project-id is omitted) and init-enforcement.ts (to pre-fill its
5
+ // prompt) -- one derivation, not two, so the hook's actual behavior and
6
+ // init's preview can never disagree.
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { basename, join } from "node:path";
10
+ export function deriveProjectId(root) {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
13
+ if (typeof pkg.name === "string" && pkg.name.trim())
14
+ return pkg.name.trim();
15
+ }
16
+ catch {
17
+ // no package.json, or unparseable -- fall through to the git remote
18
+ }
19
+ try {
20
+ const url = execFileSync("git", ["remote", "get-url", "origin"], {
21
+ cwd: root,
22
+ encoding: "utf8",
23
+ stdio: ["ignore", "pipe", "ignore"],
24
+ timeout: 2000,
25
+ }).trim();
26
+ // Matches the repo name out of either an https or ssh remote URL,
27
+ // with or without a trailing .git.
28
+ const match = url.match(/([^/:]+?)(\.git)?$/);
29
+ if (match && match[1])
30
+ return match[1];
31
+ }
32
+ catch {
33
+ // no git remote, or git not available -- fall through to the dir name
34
+ }
35
+ return basename(root);
36
+ }
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "pattern-mcp",
3
- "version": "0.9.1",
3
+ "version": "0.11.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",
10
+ "pattern-check-gate-hook": "dist/check-gate-hook.js"
9
11
  },
10
12
  "author": "Don Richard",
11
13
  "license": "MIT",
@@ -28,7 +30,8 @@
28
30
  ],
29
31
  "files": [
30
32
  "dist",
31
- "README.md"
33
+ "README.md",
34
+ "templates"
32
35
  ],
33
36
  "scripts": {
34
37
  "build": "tsc",
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Edit|Write",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "npx --yes pattern-check-gate-hook",
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"