pattern-mcp 0.10.0 → 0.12.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 +79 -25
- package/dist/check-gate-hook.js +81 -0
- package/dist/check-gate.js +24 -7
- package/dist/index.js +7 -0
- package/dist/init-enforcement.js +385 -0
- package/dist/project-id.js +36 -0
- package/package.json +3 -2
- package/templates/claude-settings/settings.json +1 -1
- package/templates/hooks/check-gate-hook.mjs +0 -89
package/README.md
CHANGED
|
@@ -15,6 +15,12 @@ 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.11.0** — adds `pattern-check-gate init`, a guided
|
|
19
|
+
setup for the opt-in enforcement boundary (a `PreToolUse` hook plus a
|
|
20
|
+
paired CI check) so a new component decision can be required, not just
|
|
21
|
+
logged. See [Enforcement boundary: hook + CI
|
|
22
|
+
gate](#enforcement-boundary-hook--ci-gate).
|
|
23
|
+
|
|
18
24
|
<details>
|
|
19
25
|
<summary><strong>Contents</strong> (click to expand)</summary>
|
|
20
26
|
|
|
@@ -22,6 +28,7 @@ design reference.
|
|
|
22
28
|
- **Make the judgment call:** [`recommend_component`](#tool-recommend_component) · [`extract_requirements`](#tool-extract_requirements)
|
|
23
29
|
- **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
30
|
- **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)
|
|
31
|
+
- [Enforcement boundary: hook + CI gate](#enforcement-boundary-hook--ci-gate) (new in v0.10.0 — require the call, don't just log it)
|
|
25
32
|
- [Per-project decision memory](#per-project-decision-memory) · [Security and privacy](#security-and-privacy) · [Telemetry](#telemetry)
|
|
26
33
|
- **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
34
|
- [Local call log](#local-call-log) · [Known limitations](#known-limitations)
|
|
@@ -1477,14 +1484,21 @@ Two gaps in the ledger, surfaced from user feedback: it tracks that a
|
|
|
1477
1484
|
decision was made, but not whether the thing it decided about is still
|
|
1478
1485
|
live in your codebase, and it stores the checklist/verdict but not a
|
|
1479
1486
|
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)
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
[`
|
|
1487
|
+
Both are now fully addressed, across five tools -- **old decisions can be
|
|
1488
|
+
checked, not just logged**: [`check_ledger_liveness`](#tool-check_ledger_liveness)
|
|
1489
|
+
verifies that the file where a decision was implemented still exists and
|
|
1490
|
+
still uses the recommended component, marking it an orphaned entry if it
|
|
1491
|
+
doesn't ([`sweep_ledger_liveness`](#tool-sweep_ledger_liveness) is the
|
|
1492
|
+
batch/scheduled version of the same check); **decisions can become
|
|
1493
|
+
shareable records**: [`export_ledger_provenance`](#tool-export_ledger_provenance)
|
|
1494
|
+
turns a decision into a self-contained Markdown record, and
|
|
1485
1495
|
[`post_ledger_provenance_to_github`](#tool-post_ledger_provenance_to_github)
|
|
1486
|
-
|
|
1487
|
-
|
|
1496
|
+
can attach it directly to the relevant PR or issue; and **older decisions
|
|
1497
|
+
aren't left behind**: [`backfill_ledger_snapshot_ref`](#tool-backfill_ledger_snapshot_ref)
|
|
1498
|
+
adds a `snapshot_ref` to decisions created before this feature existed, so
|
|
1499
|
+
the liveness check above works retroactively. See
|
|
1500
|
+
`pattern-ledger-integrity-and-provenance-spec.md` for the original phased
|
|
1501
|
+
plan this was built against.
|
|
1488
1502
|
|
|
1489
1503
|
**This required the one deliberate exception** to Pattern otherwise having
|
|
1490
1504
|
[no filesystem/git access to your repo](#per-project-judgment-ledger) at
|
|
@@ -1578,6 +1592,11 @@ itself is never rewritten.
|
|
|
1578
1592
|
|
|
1579
1593
|
## Enforcement boundary: hook + CI gate
|
|
1580
1594
|
|
|
1595
|
+
**Decisions can be enforced, not just tracked.** An opt-in `PreToolUse`
|
|
1596
|
+
hook can block a new component from being written until a matching
|
|
1597
|
+
ledger entry exists; a paired GitHub Action can also fail the PR if that
|
|
1598
|
+
decision record isn't committed alongside the code.
|
|
1599
|
+
|
|
1581
1600
|
**The gap this closes:** SKILL.md instructs the calling agent to call
|
|
1582
1601
|
`recommend_component` before scaffolding a new, non-trivial UI component,
|
|
1583
1602
|
but nothing before this feature *enforced* that -- an agent could simply
|
|
@@ -1586,19 +1605,54 @@ Claude-Code-specific for the hook half; a consuming repo that never wires
|
|
|
1586
1605
|
either piece up gets Pattern exactly as it worked before, and any other
|
|
1587
1606
|
MCP host (Cursor, Codex, etc.) is entirely unaffected either way.
|
|
1588
1607
|
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1608
|
+
**Set it up with one command:**
|
|
1609
|
+
|
|
1610
|
+
```bash
|
|
1611
|
+
npx pattern-check-gate init
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
Confirms each step independently rather than one blanket "proceed?", and
|
|
1615
|
+
never auto-commits -- review with `git status`/`git diff` and commit
|
|
1616
|
+
yourself when ready:
|
|
1617
|
+
|
|
1618
|
+
1. Confirms a project id (pre-filled from `package.json`'s `name`, or
|
|
1619
|
+
your git remote/directory name -- accept it or type your own).
|
|
1620
|
+
2. Writes or merges `.claude/settings.json` -- if one already exists, it
|
|
1621
|
+
parses it, leaves any unrelated hooks untouched, and only appends the
|
|
1622
|
+
`PreToolUse` entry if it isn't already there (safe to rerun).
|
|
1623
|
+
3. Writes `.github/workflows/pattern-gate.yml`, if a GitHub remote is
|
|
1624
|
+
detected and the file doesn't already exist with different content
|
|
1625
|
+
(never silently overwritten).
|
|
1626
|
+
4. Asks, as its own explicit yes/no: **mark the check required in branch
|
|
1627
|
+
protection?** Needs `gh` installed and authenticated with admin rights
|
|
1628
|
+
on the repo; skips with clear next steps otherwise. Deliberately only
|
|
1629
|
+
offered when no branch protection exists yet on the default branch --
|
|
1630
|
+
GitHub's branch-protection API replaces the *entire* configuration on
|
|
1631
|
+
write, not just the required-checks list, so this refuses to guess at
|
|
1632
|
+
merging into whatever you already have rather than risk silently
|
|
1633
|
+
dropping an unrelated setting (e.g. required PR reviews). If
|
|
1634
|
+
protection already exists, add `pattern-gate` to it by hand instead.
|
|
1635
|
+
|
|
1636
|
+
Run non-interactively with `--yes` (accepts every safe default; branch
|
|
1637
|
+
protection is never auto-confirmed even then -- it's the one step that
|
|
1638
|
+
reaches outside your local filesystem into real, shared GitHub config).
|
|
1639
|
+
|
|
1640
|
+
**Or set it up by hand**, two pieces, neither installed automatically:
|
|
1641
|
+
|
|
1642
|
+
- **`.claude/settings.json`** wired to run `npx --yes
|
|
1643
|
+
pattern-check-gate-hook` on `PreToolUse` (see
|
|
1644
|
+
`templates/claude-settings/settings.json` for the exact shape) -- a
|
|
1645
|
+
Claude Code hook that runs on `Write`/`Edit` calls. For a genuinely new
|
|
1646
|
+
`.tsx`/`.jsx` file that exports a non-trivial component, it looks up a
|
|
1647
|
+
ledger entry (via `~/.pattern/ledger.jsonl`, same as everywhere else in
|
|
1648
|
+
Pattern) whose `file_path` matches the file being written. A match
|
|
1649
|
+
writes a receipt and allows the write; no match blocks it with a reason
|
|
1650
|
+
fed back to the model as retryable guidance, not a hard failure.
|
|
1651
|
+
**This is the one new exception where Pattern writes into your repo**
|
|
1652
|
+
(`.pattern/receipts/<feature_id>.json`) -- everything else described in
|
|
1653
|
+
this README is read-only. `project_id` no longer needs to be set by
|
|
1654
|
+
hand either -- it's derived the same way `init` pre-fills it (see
|
|
1655
|
+
`src/project-id.ts`); set `PATTERN_PROJECT_ID` only to override that.
|
|
1602
1656
|
- **`templates/github-workflows/pattern-gate.yml`** -- a required PR
|
|
1603
1657
|
check that reads the same receipt files back out of the diff. It never
|
|
1604
1658
|
touches `~/.pattern/` (not reachable from a CI runner) and needs no
|
|
@@ -1615,11 +1669,11 @@ An escape hatch exists for both a whole-hook kill switch
|
|
|
1615
1669
|
check) and a per-file override (a `// pattern-mcp:override reason="..."`
|
|
1616
1670
|
comment) -- the override still writes a receipt recording
|
|
1617
1671
|
`manual_override: true` and the reason, so it stays visible rather than
|
|
1618
|
-
silent. See `src/component-gate.ts`, `src/gate-receipt.ts`,
|
|
1619
|
-
`src/check-gate.ts` (the
|
|
1620
|
-
|
|
1621
|
-
implementation, and BACKLOG.md's
|
|
1622
|
-
|
|
1672
|
+
silent. See `src/component-gate.ts`, `src/gate-receipt.ts`,
|
|
1673
|
+
`src/check-gate.ts` (the `pattern-check-gate` CLI, this project's first
|
|
1674
|
+
entry point separate from the stdio MCP server), `src/check-gate-hook.ts`,
|
|
1675
|
+
and `src/init-enforcement.ts` for the implementation, and BACKLOG.md's
|
|
1676
|
+
"Enforcement boundary" entries for the fuller design writeup.
|
|
1623
1677
|
|
|
1624
1678
|
## Per-project decision memory
|
|
1625
1679
|
|
|
@@ -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();
|
package/dist/check-gate.js
CHANGED
|
@@ -2,21 +2,28 @@
|
|
|
2
2
|
// pattern-check-gate -- the enforcement-boundary CLI (see
|
|
3
3
|
// BACKLOG.md's "Enforcement boundary: hook + CI gate" entry).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// and the CI check can never silently drift on what
|
|
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":
|
|
7
8
|
//
|
|
8
|
-
// write -- run locally (by
|
|
9
|
+
// write -- run locally (by check-gate-hook.ts) where
|
|
9
10
|
// ~/.pattern/ledger.jsonl is reachable. Looks up a ledger
|
|
10
11
|
// entry whose file_path matches the file being written; on a
|
|
11
12
|
// match (or a manual override), writes a receipt into the
|
|
12
13
|
// CONSUMING repo at .pattern/receipts/<feature_id>.json and
|
|
13
14
|
// exits 0. No match, no override -> exits 1 and blocks.
|
|
15
|
+
// --project-id is optional -- see project-id.ts (Option A).
|
|
14
16
|
//
|
|
15
17
|
// verify -- run in CI, where ~/.pattern/ is never reachable. Trusts the
|
|
16
18
|
// committed receipt as the artifact of record instead of
|
|
17
19
|
// re-deriving anything from the ledger -- fails if a gated
|
|
18
20
|
// file in the diff has no matching receipt.
|
|
19
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
|
+
//
|
|
20
27
|
// No CLI-parsing or git-wrapper dependency, matching this project's
|
|
21
28
|
// existing minimal-dependency posture (index.ts shells out to fixed git
|
|
22
29
|
// subcommands rather than a library) -- argv is parsed by hand below.
|
|
@@ -30,6 +37,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
30
37
|
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
31
38
|
import { isGatedComponentFile, parseManualOverride } from "./component-gate.js";
|
|
32
39
|
import { deriveOverrideFeatureId, readAllGateReceipts, writeGateReceipt } from "./gate-receipt.js";
|
|
40
|
+
import { deriveProjectId } from "./project-id.js";
|
|
41
|
+
import { runInit } from "./init-enforcement.js";
|
|
33
42
|
function normalize(p) {
|
|
34
43
|
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
35
44
|
}
|
|
@@ -86,10 +95,14 @@ function emit(result, ok) {
|
|
|
86
95
|
}
|
|
87
96
|
async function runWrite(root, flags) {
|
|
88
97
|
const fileArg = flags.file;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
emit({ ok: false, reason: "write mode requires --file <path> and --project-id <id>" }, false);
|
|
98
|
+
if (typeof fileArg !== "string") {
|
|
99
|
+
emit({ ok: false, reason: "write mode requires --file <path>" }, false);
|
|
92
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);
|
|
93
106
|
const relPath = toRepoRelative(root, fileArg);
|
|
94
107
|
if (relPath === null) {
|
|
95
108
|
emit({ ok: false, reason: `--file resolves outside project root: ${fileArg}` }, false);
|
|
@@ -190,9 +203,13 @@ async function main() {
|
|
|
190
203
|
else if (mode === "verify") {
|
|
191
204
|
await runVerify(root, files);
|
|
192
205
|
}
|
|
206
|
+
else if (mode === "init") {
|
|
207
|
+
await runInit(root, { yes: flags.yes === true });
|
|
208
|
+
}
|
|
193
209
|
else {
|
|
194
|
-
process.stderr.write("Usage: pattern-check-gate write --file <path> [--is-new] --project-id <id> [--project-root <root>] (content on stdin)\n");
|
|
210
|
+
process.stderr.write("Usage: pattern-check-gate write --file <path> [--is-new] [--project-id <id>] [--project-root <root>] (content on stdin)\n");
|
|
195
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");
|
|
196
213
|
process.exit(2);
|
|
197
214
|
}
|
|
198
215
|
}
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { homedir } from "node:os";
|
|
|
38
38
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
39
39
|
import { fileURLToPath } from "node:url";
|
|
40
40
|
import { captureApiError, captureRecommendation, getClient as getPostHogClient, installId, printTelemetryNoticeOnce, shutdownTelemetry, TELEMETRY_ENABLED, } from "./telemetry.js";
|
|
41
|
+
import { offerEnforcementSetupOnce } from "./init-enforcement.js";
|
|
41
42
|
export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
42
43
|
// Only required for org-scoped keys (not tied to one workspace); unset for
|
|
43
44
|
// legacy workspace-scoped keys, which don't need it.
|
|
@@ -3864,6 +3865,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3864
3865
|
});
|
|
3865
3866
|
async function main() {
|
|
3866
3867
|
printTelemetryNoticeOnce();
|
|
3868
|
+
// Piggybacks on this same first-run moment (Option B, see
|
|
3869
|
+
// init-enforcement.ts) -- always prints a one-time, non-blocking mention;
|
|
3870
|
+
// only prompts interactively when stdin is a real TTY, never when a real
|
|
3871
|
+
// MCP client has piped stdio into this process for JSON-RPC. Always
|
|
3872
|
+
// returns before the transport below claims stdin.
|
|
3873
|
+
await offerEnforcementSetupOnce(PROJECT_ROOT);
|
|
3867
3874
|
const transport = new StdioServerTransport();
|
|
3868
3875
|
await server.connect(transport);
|
|
3869
3876
|
// Best-effort telemetry drain on clean shutdown -- no-op when telemetry
|
|
@@ -0,0 +1,385 @@
|
|
|
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 { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
import { deriveProjectId } from "./project-id.js";
|
|
20
|
+
const HOOK_MARKER = "pattern-check-gate-hook";
|
|
21
|
+
// A queue-based prompt helper, not readline/promises' question() --
|
|
22
|
+
// question() only starts listening for a line *after* it's called, but
|
|
23
|
+
// with piped/non-TTY stdin (as in an automated test, or `init | cat`)
|
|
24
|
+
// every line arrives in one synchronous burst, ahead of any await
|
|
25
|
+
// cycle. Confirmed directly: two sequential `rl.question()` calls on a
|
|
26
|
+
// piped `printf 'a\nb\n'` answer only the first and hang forever on the
|
|
27
|
+
// second -- Node even logs "Detected unsettled top-level await" in that
|
|
28
|
+
// repro. The fix is a small always-listening queue: a persistent 'line'
|
|
29
|
+
// listener buffers answers that arrive before they're asked for, so
|
|
30
|
+
// `askLine` either drains an already-buffered answer immediately or
|
|
31
|
+
// waits for the next 'line' event, whichever comes first -- correct for
|
|
32
|
+
// both a real interactive TTY (waiter path) and piped/scripted input
|
|
33
|
+
// (queue path).
|
|
34
|
+
let rl = null;
|
|
35
|
+
const lineQueue = [];
|
|
36
|
+
const waiters = [];
|
|
37
|
+
function ensureRl() {
|
|
38
|
+
if (!rl) {
|
|
39
|
+
rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
rl.on("line", (line) => {
|
|
41
|
+
const waiter = waiters.shift();
|
|
42
|
+
if (waiter)
|
|
43
|
+
waiter(line);
|
|
44
|
+
else
|
|
45
|
+
lineQueue.push(line);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return rl;
|
|
49
|
+
}
|
|
50
|
+
function askLine(promptStr) {
|
|
51
|
+
ensureRl();
|
|
52
|
+
process.stdout.write(promptStr);
|
|
53
|
+
const queued = lineQueue.shift();
|
|
54
|
+
if (queued !== undefined)
|
|
55
|
+
return Promise.resolve(queued);
|
|
56
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
57
|
+
}
|
|
58
|
+
function closeRl() {
|
|
59
|
+
rl?.close();
|
|
60
|
+
rl = null;
|
|
61
|
+
}
|
|
62
|
+
async function confirm(question, options, defaultYes) {
|
|
63
|
+
if (options.yes)
|
|
64
|
+
return defaultYes;
|
|
65
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
66
|
+
const answer = (await askLine(`${question} ${suffix} `)).trim().toLowerCase();
|
|
67
|
+
if (!answer)
|
|
68
|
+
return defaultYes;
|
|
69
|
+
return answer === "y" || answer === "yes";
|
|
70
|
+
}
|
|
71
|
+
async function promptText(question, defaultValue, options) {
|
|
72
|
+
if (options.yes)
|
|
73
|
+
return defaultValue;
|
|
74
|
+
const answer = (await askLine(`${question} [${defaultValue}]: `)).trim();
|
|
75
|
+
return answer || defaultValue;
|
|
76
|
+
}
|
|
77
|
+
function shellQuote(value) {
|
|
78
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
79
|
+
}
|
|
80
|
+
async function setupClaudeSettings(root, projectIdOverride, options) {
|
|
81
|
+
const settingsPath = join(root, ".claude", "settings.json");
|
|
82
|
+
let settings = {};
|
|
83
|
+
let existed = false;
|
|
84
|
+
if (existsSync(settingsPath)) {
|
|
85
|
+
existed = true;
|
|
86
|
+
try {
|
|
87
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
console.log(" .claude/settings.json exists but isn't valid JSON -- skipping, fix it manually first.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const preToolUse = settings.hooks?.PreToolUse ?? [];
|
|
95
|
+
const alreadyInstalled = preToolUse.some((entry) => (entry.hooks ?? []).some((h) => typeof h.command === "string" && h.command.includes(HOOK_MARKER)));
|
|
96
|
+
if (alreadyInstalled) {
|
|
97
|
+
console.log(" .claude/settings.json: hook already configured, skipping.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const command = projectIdOverride
|
|
101
|
+
? `env PATTERN_PROJECT_ID=${shellQuote(projectIdOverride)} npx --yes pattern-check-gate-hook`
|
|
102
|
+
: "npx --yes pattern-check-gate-hook";
|
|
103
|
+
const newEntry = {
|
|
104
|
+
matcher: "Edit|Write",
|
|
105
|
+
hooks: [{ type: "command", command, timeout: 60 }],
|
|
106
|
+
};
|
|
107
|
+
console.log(`\n ${existed ? "Merging into" : "Creating"} .claude/settings.json:`);
|
|
108
|
+
console.log(JSON.stringify(newEntry, null, 2)
|
|
109
|
+
.split("\n")
|
|
110
|
+
.map((l) => ` ${l}`)
|
|
111
|
+
.join("\n"));
|
|
112
|
+
const proceed = await confirm(" Write this?", options, true);
|
|
113
|
+
if (!proceed) {
|
|
114
|
+
console.log(" Skipped.");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const merged = {
|
|
118
|
+
...settings,
|
|
119
|
+
hooks: {
|
|
120
|
+
...settings.hooks,
|
|
121
|
+
PreToolUse: [...preToolUse, newEntry],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
125
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
126
|
+
console.log(" Written.");
|
|
127
|
+
}
|
|
128
|
+
function isGitHubRepo(root) {
|
|
129
|
+
try {
|
|
130
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
131
|
+
cwd: root,
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
134
|
+
timeout: 2000,
|
|
135
|
+
}).trim();
|
|
136
|
+
const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(\.git)?$/);
|
|
137
|
+
if (match)
|
|
138
|
+
return { owner: match[1], repo: match[2] };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// no remote, or not git
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
async function setupWorkflowFile(root, options) {
|
|
146
|
+
const gh = isGitHubRepo(root);
|
|
147
|
+
if (!gh) {
|
|
148
|
+
console.log("\n No GitHub remote detected -- skipping the GitHub Action workflow file.");
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const workflowPath = join(root, ".github", "workflows", "pattern-gate.yml");
|
|
152
|
+
const templatePath = fileURLToPath(new URL("../templates/github-workflows/pattern-gate.yml", import.meta.url));
|
|
153
|
+
const templateContent = readFileSync(templatePath, "utf8");
|
|
154
|
+
if (existsSync(workflowPath)) {
|
|
155
|
+
const existing = readFileSync(workflowPath, "utf8");
|
|
156
|
+
if (existing === templateContent) {
|
|
157
|
+
console.log("\n .github/workflows/pattern-gate.yml: already up to date, skipping.");
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
console.log("\n .github/workflows/pattern-gate.yml already exists with different content.");
|
|
161
|
+
const overwrite = await confirm(" Overwrite it?", options, false);
|
|
162
|
+
if (!overwrite) {
|
|
163
|
+
console.log(" Skipped -- left your existing file untouched.");
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
console.log("\n Creating .github/workflows/pattern-gate.yml.");
|
|
169
|
+
const proceed = await confirm(" Write this?", options, true);
|
|
170
|
+
if (!proceed) {
|
|
171
|
+
console.log(" Skipped.");
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
mkdirSync(dirname(workflowPath), { recursive: true });
|
|
176
|
+
writeFileSync(workflowPath, templateContent, "utf8");
|
|
177
|
+
console.log(" Written.");
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function ghCliAvailable() {
|
|
181
|
+
try {
|
|
182
|
+
execFileSync("gh", ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function ghAuthenticated() {
|
|
190
|
+
try {
|
|
191
|
+
execFileSync("gh", ["auth", "status"], { stdio: "ignore", timeout: 5000 });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function getDefaultBranch(gh) {
|
|
199
|
+
try {
|
|
200
|
+
const out = execFileSync("gh", ["api", `repos/${gh.owner}/${gh.repo}`, "--jq", ".default_branch"], {
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
timeout: 10000,
|
|
203
|
+
}).trim();
|
|
204
|
+
return out || null;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function stderrOf(err) {
|
|
211
|
+
if (err && typeof err === "object" && "stderr" in err) {
|
|
212
|
+
const stderr = err.stderr;
|
|
213
|
+
if (stderr)
|
|
214
|
+
return String(stderr);
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
function getExistingProtection(gh, branch) {
|
|
219
|
+
try {
|
|
220
|
+
execFileSync("gh", ["api", `repos/${gh.owner}/${gh.repo}/branches/${branch}/protection`], {
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
timeout: 10000,
|
|
223
|
+
});
|
|
224
|
+
return "exists";
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return stderrOf(err).includes("404") ? "none" : "unknown";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Only ever called when getExistingProtection returned "none". The
|
|
231
|
+
// Update Branch Protection endpoint REPLACES the entire protection
|
|
232
|
+
// object, and its GET/PUT schemas differ for several fields
|
|
233
|
+
// (enforce_admins is {enabled} on GET but a bare boolean on PUT, for
|
|
234
|
+
// example) -- verified against GitHub's own REST API docs before writing
|
|
235
|
+
// this. Deliberately does not attempt to merge into pre-existing
|
|
236
|
+
// protection; that case is handled by the caller bailing out to manual
|
|
237
|
+
// instructions instead of risking a wrong reconstruction that silently
|
|
238
|
+
// drops an unrelated setting (e.g. required PR reviews).
|
|
239
|
+
function createMinimalProtection(gh, branch) {
|
|
240
|
+
const body = JSON.stringify({
|
|
241
|
+
required_status_checks: { strict: false, checks: [{ context: "pattern-gate", app_id: -1 }] },
|
|
242
|
+
enforce_admins: false,
|
|
243
|
+
required_pull_request_reviews: null,
|
|
244
|
+
restrictions: null,
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
execFileSync("gh", ["api", "-X", "PUT", `repos/${gh.owner}/${gh.repo}/branches/${branch}/protection`, "--input", "-"], {
|
|
248
|
+
input: body,
|
|
249
|
+
encoding: "utf8",
|
|
250
|
+
timeout: 10000,
|
|
251
|
+
});
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function maybeSetupBranchProtection(root, options) {
|
|
259
|
+
const gh = isGitHubRepo(root);
|
|
260
|
+
if (!gh)
|
|
261
|
+
return;
|
|
262
|
+
console.log("");
|
|
263
|
+
// Never implied by --yes, and never defaults to yes -- see BACKLOG's
|
|
264
|
+
// "strictest confirmation of the four steps" note. This is the one
|
|
265
|
+
// step that reaches outside the local filesystem into real, shared
|
|
266
|
+
// GitHub config.
|
|
267
|
+
const wantsIt = await confirm(" Mark the pattern-gate check as required in branch protection?", options, false);
|
|
268
|
+
if (!wantsIt) {
|
|
269
|
+
console.log(` Skipped. To do this later, add "pattern-gate" as a required status check in\n` +
|
|
270
|
+
` https://github.com/${gh.owner}/${gh.repo}/settings/branches`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!ghCliAvailable()) {
|
|
274
|
+
console.log(" `gh` CLI not found -- install it (https://cli.github.com) and rerun, or configure manually.");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!ghAuthenticated()) {
|
|
278
|
+
console.log(" `gh` CLI isn't authenticated -- run `gh auth login` and rerun, or configure manually.");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const branch = getDefaultBranch(gh);
|
|
282
|
+
if (!branch) {
|
|
283
|
+
console.log(" Couldn't determine the default branch -- configure manually.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const existing = getExistingProtection(gh, branch);
|
|
287
|
+
if (existing !== "none") {
|
|
288
|
+
console.log(existing === "exists"
|
|
289
|
+
? ` Branch protection already exists on "${branch}". To avoid overwriting your other protection\n` +
|
|
290
|
+
` settings (this endpoint replaces the whole configuration, not just required checks), add\n` +
|
|
291
|
+
` "pattern-gate" to your required status checks manually instead of through this command.`
|
|
292
|
+
: ` Couldn't determine "${branch}"'s current protection state -- configure manually rather than\n` +
|
|
293
|
+
` risk overwriting settings this command can't see.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const ok = createMinimalProtection(gh, branch);
|
|
297
|
+
console.log(ok
|
|
298
|
+
? ` Required "pattern-gate" check added to "${branch}" branch protection.`
|
|
299
|
+
: " Failed to update branch protection -- configure manually.");
|
|
300
|
+
}
|
|
301
|
+
export async function runInit(root, options) {
|
|
302
|
+
console.log("Setting up Pattern's enforcement boundary (hook + CI gate)...\n");
|
|
303
|
+
const derivedId = deriveProjectId(root);
|
|
304
|
+
const projectId = await promptText("Project id", derivedId, options);
|
|
305
|
+
const projectIdOverride = projectId !== derivedId ? projectId : null;
|
|
306
|
+
try {
|
|
307
|
+
await setupClaudeSettings(root, projectIdOverride, options);
|
|
308
|
+
await setupWorkflowFile(root, options);
|
|
309
|
+
await maybeSetupBranchProtection(root, options);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
// Always close, even on error -- an open readline interface keeps
|
|
313
|
+
// the process alive waiting on stdin otherwise.
|
|
314
|
+
closeRl();
|
|
315
|
+
}
|
|
316
|
+
console.log("\nDone. Review the changes with `git status` / `git diff`, then commit when ready.");
|
|
317
|
+
}
|
|
318
|
+
// Option B from BACKLOG.md's "Enforcement boundary setup" entry: piggyback
|
|
319
|
+
// on the moment someone's already setting Pattern up, rather than leaving
|
|
320
|
+
// enforcement as something only the README mentions. Called once from
|
|
321
|
+
// index.ts's main(), right alongside printTelemetryNoticeOnce, before the
|
|
322
|
+
// stdio transport connects.
|
|
323
|
+
//
|
|
324
|
+
// The literal original phrasing of this option ("extend the first-run
|
|
325
|
+
// notice into a [y/N] prompt") turns out not to be safely buildable as
|
|
326
|
+
// written: stdin is the live JSON-RPC channel a real MCP client uses to
|
|
327
|
+
// talk to this process (see telemetry.ts's printTelemetryNoticeOnce for
|
|
328
|
+
// the same constraint, stated first). Blocking it on a keypress here would
|
|
329
|
+
// fight the protocol handshake, not show a dialog. So this does two
|
|
330
|
+
// different things depending on how stdin is actually connected:
|
|
331
|
+
//
|
|
332
|
+
// - Always (any context, including a real client subprocess): print a
|
|
333
|
+
// one-time, non-blocking mention that the enforcement boundary exists
|
|
334
|
+
// and how to set it up. Same "print once, gated by a marker file"
|
|
335
|
+
// pattern as the telemetry notice, deliberately a separate marker/
|
|
336
|
+
// message so the two stay independently legible in a terminal.
|
|
337
|
+
// - Only when process.stdin.isTTY is true -- which a real MCP client's
|
|
338
|
+
// spawned subprocess never has, since it always pipes stdio to speak
|
|
339
|
+
// JSON-RPC over it, but a human running `npx pattern-mcp` bare in
|
|
340
|
+
// their own terminal does -- also offer a real interactive prompt,
|
|
341
|
+
// reusing runInit itself rather than duplicating its logic.
|
|
342
|
+
const ENFORCEMENT_NOTICE_PATH = process.env.PATTERN_ENFORCEMENT_NOTICE_PATH ?? join(homedir(), ".pattern", "enforcement_notice_shown");
|
|
343
|
+
export async function offerEnforcementSetupOnce(root) {
|
|
344
|
+
if (process.env.PATTERN_NO_ENFORCEMENT_NOTICE)
|
|
345
|
+
return;
|
|
346
|
+
try {
|
|
347
|
+
readFileSync(ENFORCEMENT_NOTICE_PATH, "utf8");
|
|
348
|
+
return; // Already shown -- never repeat, same discipline as the telemetry notice.
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// No marker yet -- fall through and show it.
|
|
352
|
+
}
|
|
353
|
+
console.error([
|
|
354
|
+
"",
|
|
355
|
+
"Pattern -- enforcement boundary available (this will not print again)",
|
|
356
|
+
"By default, Pattern is something the calling agent chooses to use.",
|
|
357
|
+
"An opt-in hook + CI check can require it instead: run `npx pattern-check-gate init`",
|
|
358
|
+
"in your repo to set it up.",
|
|
359
|
+
"Full details: https://github.com/donaldrichard19-LVD/pattern-mcp#enforcement-boundary-hook--ci-gate",
|
|
360
|
+
"",
|
|
361
|
+
].join("\n"));
|
|
362
|
+
try {
|
|
363
|
+
mkdirSync(dirname(ENFORCEMENT_NOTICE_PATH), { recursive: true });
|
|
364
|
+
writeFileSync(ENFORCEMENT_NOTICE_PATH, new Date().toISOString(), "utf8");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Couldn't persist the marker -- worst case this prints again next
|
|
368
|
+
// run. Never blocks startup over it, same as the telemetry notice.
|
|
369
|
+
}
|
|
370
|
+
if (!process.stdin.isTTY)
|
|
371
|
+
return;
|
|
372
|
+
try {
|
|
373
|
+
const setUpNow = await confirm("Set it up now?", { yes: false }, false);
|
|
374
|
+
if (setUpNow) {
|
|
375
|
+
await runInit(root, { yes: false }); // closes the shared readline itself, in its own finally block
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
// closeRl() is safe to call even if runInit already closed it (checks
|
|
380
|
+
// rl?.close() and no-ops on null) -- this just guarantees stdin is
|
|
381
|
+
// always released back before main() connects the stdio transport,
|
|
382
|
+
// whether the answer was no or runInit already cleaned up after itself.
|
|
383
|
+
closeRl();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -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,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pattern-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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
8
|
"pattern-mcp": "dist/index.js",
|
|
9
|
-
"pattern-check-gate": "dist/check-gate.js"
|
|
9
|
+
"pattern-check-gate": "dist/check-gate.js",
|
|
10
|
+
"pattern-check-gate-hook": "dist/check-gate-hook.js"
|
|
10
11
|
},
|
|
11
12
|
"author": "Don Richard",
|
|
12
13
|
"license": "MIT",
|
|
@@ -1,89 +0,0 @@
|
|
|
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();
|