@compr/opscontext-mcp 2.1.1 → 2.2.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.
@@ -0,0 +1,506 @@
1
+ /**
2
+ * community-sync.ts — fetch & cache community-contributed learnings.
3
+ *
4
+ * Two tiers:
5
+ * A) Public — raw.githubusercontent.com (no auth, ETag cached)
6
+ * B) Pro — api.compr.ch (license-token auth, server-signed payload)
7
+ *
8
+ * Both tiers produce CommunityRule records that share the local store at
9
+ * ~/.contextengine/community-learnings.json. At search-init time the
10
+ * MCP server merges these into the same chunk pipeline as the local
11
+ * Learnings Store so they surface inside search_context with a
12
+ * "(community)" badge.
13
+ *
14
+ * Auth shape for Tier B matches /heartbeat exactly (see activation.ts):
15
+ * POST { license_token, machine_id }
16
+ *
17
+ * Design constraints:
18
+ * - Node built-in `https` only (no fetch wrapper deps)
19
+ * - Network failures NEVER crash search — fall back to cached store
20
+ * - Tier B is best-effort; on 401 we log + return zero, don't throw
21
+ * - The signed payload from Tier B is verified before merge using the
22
+ * existing Ed25519 verifyLicenseSignature() helper
23
+ */
24
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
25
+ import { join } from "path";
26
+ import { homedir } from "os";
27
+ import { createHash } from "crypto";
28
+ import * as https from "https";
29
+ import { URL } from "url";
30
+ import { safeAppend } from "./audit.js";
31
+ import { verifyLicenseSignature, } from "./license-sig.js";
32
+ // ---------------------------------------------------------------------------
33
+ // Constants
34
+ // ---------------------------------------------------------------------------
35
+ export const TIER_A_URL = "https://raw.githubusercontent.com/FASTPROD/opscontext-community-rules/main/rules.json";
36
+ export const TIER_B_URL = "https://api.compr.ch/contextengine/community-rules/fetch";
37
+ export const STORE_PATH = join(homedir(), ".contextengine", "community-learnings.json");
38
+ const STORE_DIR = join(homedir(), ".contextengine");
39
+ // ---------------------------------------------------------------------------
40
+ // Machine fingerprint — duplicated from activation.ts so this module has
41
+ // zero internal coupling. Both must produce identical output; if you
42
+ // change one, change both.
43
+ // ---------------------------------------------------------------------------
44
+ export function getMachineId() {
45
+ const components = [
46
+ process.platform,
47
+ process.arch,
48
+ homedir().split("/").slice(0, 3).join("/"),
49
+ process.env.USER || process.env.USERNAME || "unknown",
50
+ ];
51
+ return createHash("sha256")
52
+ .update(components.join("|"))
53
+ .digest("hex")
54
+ .slice(0, 16);
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Store I/O
58
+ // ---------------------------------------------------------------------------
59
+ function ensureDir() {
60
+ if (!existsSync(STORE_DIR)) {
61
+ mkdirSync(STORE_DIR, { recursive: true });
62
+ }
63
+ }
64
+ function emptyStore() {
65
+ return {
66
+ version: 1,
67
+ fetched_at: new Date(0).toISOString(),
68
+ rules: [],
69
+ };
70
+ }
71
+ export function loadCommunityStore() {
72
+ if (!existsSync(STORE_PATH))
73
+ return emptyStore();
74
+ try {
75
+ const raw = readFileSync(STORE_PATH, "utf-8");
76
+ const parsed = JSON.parse(raw);
77
+ if (!parsed ||
78
+ typeof parsed !== "object" ||
79
+ parsed.version !== 1 ||
80
+ !Array.isArray(parsed.rules)) {
81
+ return emptyStore();
82
+ }
83
+ return parsed;
84
+ }
85
+ catch {
86
+ return emptyStore();
87
+ }
88
+ }
89
+ function saveCommunityStore(store) {
90
+ ensureDir();
91
+ writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
92
+ }
93
+ export function httpRequest(url, opts = {}) {
94
+ return new Promise((resolve, reject) => {
95
+ let parsed;
96
+ try {
97
+ parsed = new URL(url);
98
+ }
99
+ catch (e) {
100
+ reject(e);
101
+ return;
102
+ }
103
+ const requestFn = opts.requestFn || https.request;
104
+ const reqOpts = {
105
+ method: opts.method || "GET",
106
+ hostname: parsed.hostname,
107
+ port: parsed.port || 443,
108
+ path: parsed.pathname + parsed.search,
109
+ headers: opts.headers || {},
110
+ };
111
+ const req = requestFn(reqOpts, (res) => {
112
+ const chunks = [];
113
+ res.on("data", (chunk) => chunks.push(chunk));
114
+ res.on("end", () => {
115
+ resolve({
116
+ statusCode: res.statusCode || 0,
117
+ headers: res.headers,
118
+ body: Buffer.concat(chunks).toString("utf-8"),
119
+ });
120
+ });
121
+ res.on("error", reject);
122
+ });
123
+ req.on("error", reject);
124
+ if (opts.body)
125
+ req.write(opts.body);
126
+ req.end();
127
+ });
128
+ }
129
+ // Test seam — community-sync.test.ts sets these to intercept network I/O
130
+ // without monkey-patching globals.
131
+ let _httpForTest = null;
132
+ export function __setHttpForTesting(fn) {
133
+ _httpForTest = fn;
134
+ }
135
+ function http(url, opts = {}) {
136
+ if (_httpForTest)
137
+ return _httpForTest(url, opts);
138
+ return httpRequest(url, opts);
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Validation
142
+ // ---------------------------------------------------------------------------
143
+ function isValidRuleShape(r) {
144
+ if (!r || typeof r !== "object")
145
+ return false;
146
+ const rec = r;
147
+ return (typeof rec.id === "string" &&
148
+ typeof rec.category === "string" &&
149
+ typeof rec.rule === "string" &&
150
+ rec.rule.length >= 10 &&
151
+ typeof rec.context === "string" &&
152
+ Array.isArray(rec.tags) &&
153
+ rec.tags.every((t) => typeof t === "string"));
154
+ }
155
+ function normalizeIncoming(source, rawRules) {
156
+ const now = new Date().toISOString();
157
+ const out = [];
158
+ for (const r of rawRules) {
159
+ if (!isValidRuleShape(r))
160
+ continue;
161
+ const rec = r;
162
+ out.push({
163
+ id: rec.id,
164
+ source,
165
+ category: rec.category,
166
+ rule: rec.rule,
167
+ context: rec.context,
168
+ tags: rec.tags,
169
+ project_cluster: typeof rec.project_cluster === "string"
170
+ ? rec.project_cluster
171
+ : undefined,
172
+ fetched_at: now,
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+ /**
178
+ * Replace all rules from a given source with the freshly-fetched set.
179
+ * (Rules from the *other* source are preserved.)
180
+ */
181
+ function mergeRules(store, source, freshRules) {
182
+ const keep = store.rules.filter((r) => r.source !== source);
183
+ return {
184
+ ...store,
185
+ fetched_at: new Date().toISOString(),
186
+ rules: [...keep, ...freshRules],
187
+ };
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Tier A — public GitHub-hosted rules
191
+ // ---------------------------------------------------------------------------
192
+ export async function syncTierA(opts = {}) {
193
+ const store = loadCommunityStore();
194
+ const headers = {
195
+ "User-Agent": "opscontext-community-sync/1",
196
+ Accept: "application/json",
197
+ };
198
+ if (!opts.force && store.source_tier_a_etag) {
199
+ headers["If-None-Match"] = store.source_tier_a_etag;
200
+ }
201
+ let res;
202
+ try {
203
+ res = await http(TIER_A_URL, { method: "GET", headers });
204
+ }
205
+ catch (e) {
206
+ // Network failure — fall back to cache, don't crash.
207
+ process.stderr.write(`[community-sync] Tier A fetch failed (${e instanceof Error ? e.message : String(e)}); using cached store.\n`);
208
+ safeAppend("community.sync_error", {
209
+ tier: "A",
210
+ reason: e instanceof Error ? e.message : String(e),
211
+ });
212
+ return { fetched: 0, cached: true };
213
+ }
214
+ if (res.statusCode === 304) {
215
+ return { fetched: 0, cached: true };
216
+ }
217
+ if (res.statusCode !== 200) {
218
+ process.stderr.write(`[community-sync] Tier A unexpected status ${res.statusCode}; using cached store.\n`);
219
+ safeAppend("community.sync_error", {
220
+ tier: "A",
221
+ status: res.statusCode,
222
+ });
223
+ return { fetched: 0, cached: true };
224
+ }
225
+ let parsed;
226
+ try {
227
+ parsed = JSON.parse(res.body);
228
+ }
229
+ catch {
230
+ process.stderr.write(`[community-sync] Tier A JSON parse failed; using cache.\n`);
231
+ safeAppend("community.sync_error", { tier: "A", reason: "json_parse" });
232
+ return { fetched: 0, cached: true };
233
+ }
234
+ const rawRules = Array.isArray(parsed)
235
+ ? parsed
236
+ : Array.isArray(parsed?.rules)
237
+ ? parsed.rules
238
+ : [];
239
+ const fresh = normalizeIncoming("tier-A-public", rawRules);
240
+ const merged = mergeRules(store, "tier-A-public", fresh);
241
+ const etag = readEtag(res.headers);
242
+ if (etag)
243
+ merged.source_tier_a_etag = etag;
244
+ saveCommunityStore(merged);
245
+ safeAppend("community.sync_ok", { tier: "A", fetched: fresh.length });
246
+ return { fetched: fresh.length, cached: false };
247
+ }
248
+ function readEtag(headers) {
249
+ const v = headers["etag"] || headers["ETag"] || headers["Etag"];
250
+ if (!v)
251
+ return null;
252
+ if (Array.isArray(v))
253
+ return v[0];
254
+ return v;
255
+ }
256
+ export async function syncTierB(licenseToken, opts = {}) {
257
+ if (!licenseToken) {
258
+ process.stderr.write(`[community-sync] Tier B skipped: no license token provided.\n`);
259
+ return { fetched: 0, cached: false };
260
+ }
261
+ const store = loadCommunityStore();
262
+ const headers = {
263
+ "User-Agent": "opscontext-community-sync/1",
264
+ "Content-Type": "application/json",
265
+ Accept: "application/json",
266
+ };
267
+ if (!opts.force && store.source_tier_b_etag) {
268
+ headers["If-None-Match"] = store.source_tier_b_etag;
269
+ }
270
+ const body = JSON.stringify({
271
+ license_token: licenseToken,
272
+ machine_id: getMachineId(),
273
+ });
274
+ let res;
275
+ try {
276
+ res = await http(TIER_B_URL, { method: "POST", headers, body });
277
+ }
278
+ catch (e) {
279
+ process.stderr.write(`[community-sync] Tier B fetch failed (${e instanceof Error ? e.message : String(e)}); using cached store.\n`);
280
+ safeAppend("community.sync_error", {
281
+ tier: "B",
282
+ reason: e instanceof Error ? e.message : String(e),
283
+ });
284
+ return { fetched: 0, cached: true };
285
+ }
286
+ if (res.statusCode === 304) {
287
+ return { fetched: 0, cached: true };
288
+ }
289
+ if (res.statusCode === 401 || res.statusCode === 403) {
290
+ process.stderr.write(`[community-sync] Tier B auth rejected (HTTP ${res.statusCode}). ` +
291
+ `Subscription may have expired — visit https://api.compr.ch/contextengine/pricing to renew. ` +
292
+ `Continuing with cached community store.\n`);
293
+ safeAppend("community.sync_error", {
294
+ tier: "B",
295
+ status: res.statusCode,
296
+ reason: "auth_rejected",
297
+ });
298
+ return { fetched: 0, cached: false };
299
+ }
300
+ if (res.statusCode !== 200) {
301
+ process.stderr.write(`[community-sync] Tier B unexpected status ${res.statusCode}; using cached store.\n`);
302
+ safeAppend("community.sync_error", {
303
+ tier: "B",
304
+ status: res.statusCode,
305
+ });
306
+ return { fetched: 0, cached: true };
307
+ }
308
+ let parsed;
309
+ try {
310
+ parsed = JSON.parse(res.body);
311
+ }
312
+ catch {
313
+ process.stderr.write(`[community-sync] Tier B JSON parse failed; using cache.\n`);
314
+ safeAppend("community.sync_error", { tier: "B", reason: "json_parse" });
315
+ return { fetched: 0, cached: true };
316
+ }
317
+ if (!parsed || !Array.isArray(parsed.rules) || !parsed.signature) {
318
+ process.stderr.write(`[community-sync] Tier B payload missing rules or signature; rejecting.\n`);
319
+ safeAppend("community.sync_error", { tier: "B", reason: "shape_invalid" });
320
+ return { fetched: 0, cached: true };
321
+ }
322
+ // Verify the server's signature on its signable-payload field. We reuse
323
+ // verifyLicenseSignature() rather than inventing a new envelope — the
324
+ // server signs the same SignableLicensePayload shape it already signs
325
+ // for /activate, so the public key + canonicalPayload are byte-identical.
326
+ const verify = verifyLicenseSignature({
327
+ ...parsed.signature_payload,
328
+ signature: parsed.signature,
329
+ });
330
+ if (!verify.ok) {
331
+ process.stderr.write(`[community-sync] Tier B signature rejected (${verify.reason}); discarding fetched rules.\n`);
332
+ safeAppend("community.sync_error", {
333
+ tier: "B",
334
+ reason: "signature_invalid",
335
+ detail: verify.reason,
336
+ });
337
+ return { fetched: 0, cached: true };
338
+ }
339
+ // 🔒 LOCKED [COMMUNITY-SYNC-REPLAY-GUARD] — 2026-06-25
340
+ // ⛔ NEVER trust a valid signature alone — also bind it to THIS request.
341
+ // The signed payload must reference OUR license token + OUR machine_id +
342
+ // a fresh timestamp. Without these checks, a Tier B response captured
343
+ // by any past PRO subscriber could be replayed against any other
344
+ // subscriber's machine indefinitely.
345
+ // WHY: Round-1 verifier of workflow wy5qwwp1q flagged "no signature_payload
346
+ // binding — signed Tier-B blobs are replayable forever" as a blocking
347
+ // safety gap. This guard closes it.
348
+ // FIX: If the server's payload schema changes (e.g. binds something other
349
+ // than license-token to signed responses), update BOTH the server's
350
+ // signResponsePayload AND this client guard in the same commit.
351
+ const machineId = getMachineId();
352
+ const sigP = parsed.signature_payload;
353
+ if (sigP.key !== licenseToken) {
354
+ process.stderr.write(`[community-sync] Tier B signature payload license-token mismatch; replay or wrong-customer response. Rejecting.\n`);
355
+ safeAppend("community.sync_error", {
356
+ tier: "B",
357
+ reason: "replay_license_mismatch",
358
+ });
359
+ return { fetched: 0, cached: true };
360
+ }
361
+ if (sigP.machineId !== machineId) {
362
+ process.stderr.write(`[community-sync] Tier B signature payload machine-id mismatch; cross-machine replay attempt. Rejecting.\n`);
363
+ safeAppend("community.sync_error", {
364
+ tier: "B",
365
+ reason: "replay_machine_mismatch",
366
+ });
367
+ return { fetched: 0, cached: true };
368
+ }
369
+ // expiresAt must be in the future, AND within a 24h freshness window so
370
+ // long-lived signed blobs can't be replayed weeks later. The server's
371
+ // signResponsePayload should set expiresAt = now + 24h; we enforce that
372
+ // ceiling here.
373
+ const now = Date.now();
374
+ const expiresAtMs = Date.parse(sigP.expiresAt);
375
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs < now) {
376
+ process.stderr.write(`[community-sync] Tier B signature expired (${sigP.expiresAt}); discarding fetched rules.\n`);
377
+ safeAppend("community.sync_error", {
378
+ tier: "B",
379
+ reason: "signature_expired",
380
+ });
381
+ return { fetched: 0, cached: true };
382
+ }
383
+ if (expiresAtMs - now > 36 * 60 * 60 * 1000) {
384
+ // > 36h ahead = server is over-issuing or the response is a replay
385
+ // from a past server config. Reject — keeps the replay window small.
386
+ process.stderr.write(`[community-sync] Tier B signature expires too far in the future (${sigP.expiresAt}); rejecting.\n`);
387
+ safeAppend("community.sync_error", {
388
+ tier: "B",
389
+ reason: "signature_freshness_violation",
390
+ });
391
+ return { fetched: 0, cached: true };
392
+ }
393
+ const fresh = normalizeIncoming("tier-B-pro", parsed.rules);
394
+ const merged = mergeRules(store, "tier-B-pro", fresh);
395
+ const etag = readEtag(res.headers);
396
+ if (etag)
397
+ merged.source_tier_b_etag = etag;
398
+ saveCommunityStore(merged);
399
+ safeAppend("community.sync_ok", { tier: "B", fetched: fresh.length });
400
+ return { fetched: fresh.length, cached: false };
401
+ }
402
+ /**
403
+ * Resolve a license token from the activation store. Returns null if the
404
+ * user has no license loaded (free user). We deliberately avoid throwing
405
+ * here so the caller can skip Tier B gracefully.
406
+ */
407
+ function resolveLicenseToken() {
408
+ try {
409
+ // Lazy import — keeps community-sync.ts importable in tests without
410
+ // dragging the full activation surface.
411
+ const licenseFile = join(homedir(), ".contextengine", "license.json");
412
+ if (!existsSync(licenseFile))
413
+ return null;
414
+ const data = JSON.parse(readFileSync(licenseFile, "utf-8"));
415
+ return typeof data.key === "string" && data.key.length > 0
416
+ ? data.key
417
+ : null;
418
+ }
419
+ catch {
420
+ return null;
421
+ }
422
+ }
423
+ export async function syncAll(opts = {}) {
424
+ const tierA = await syncTierA(opts);
425
+ const token = resolveLicenseToken();
426
+ if (!token) {
427
+ process.stderr.write(`[community-sync] Tier B skipped: no license loaded (free tier). ` +
428
+ `Activate Pro to receive curated community rules: ` +
429
+ `https://api.compr.ch/contextengine/pricing\n`);
430
+ return { tierA, tierB: null };
431
+ }
432
+ const tierB = await syncTierB(token, opts);
433
+ return { tierA, tierB };
434
+ }
435
+ // ---------------------------------------------------------------------------
436
+ // Chunk integration — feed the search pipeline
437
+ // ---------------------------------------------------------------------------
438
+ /**
439
+ * Convert the community store into Chunks shaped exactly like
440
+ * learningsToChunks() so search.ts can index them through the same
441
+ * BM25 / vector pipeline.
442
+ *
443
+ * Each chunk's `section` is prefixed with "[community:tier-A]" /
444
+ * "[community:tier-B]" so the UI can render a "(community)" badge by
445
+ * pattern-matching the section, and so duplicate detection against the
446
+ * local Learnings Store doesn't merge identical-looking content.
447
+ */
448
+ export function communityRulesToChunks() {
449
+ const store = loadCommunityStore();
450
+ return store.rules.map((r) => {
451
+ const tierBadge = r.source === "tier-A-public" ? "tier-A" : "tier-B";
452
+ return {
453
+ source: `🌐 Community Rules (${tierBadge})`,
454
+ section: `[community:${tierBadge}] [${r.category}] ${r.rule}`,
455
+ content: [
456
+ `**Rule:** ${r.rule}`,
457
+ `**Category:** ${r.category}`,
458
+ `**Source:** community / ${r.source}`,
459
+ r.context ? `**Context:** ${r.context}` : "",
460
+ r.tags?.length ? `**Tags:** ${r.tags.join(", ")}` : "",
461
+ r.project_cluster ? `**Project cluster:** ${r.project_cluster}` : "",
462
+ `_Fetched: ${r.fetched_at.split("T")[0]}_`,
463
+ ]
464
+ .filter(Boolean)
465
+ .join("\n"),
466
+ lineStart: 0,
467
+ lineEnd: 0,
468
+ indexedAt: r.fetched_at,
469
+ };
470
+ });
471
+ }
472
+ /**
473
+ * Deduplicate a chunk list against the community chunks by SHA-256 of
474
+ * the rule content. The local Learnings Store wins (we drop the
475
+ * community chunk that matches). Use this where the engine combines
476
+ * local + community chunks into one search corpus.
477
+ */
478
+ export function mergeWithDedup(localChunks, communityChunks) {
479
+ if (communityChunks.length === 0)
480
+ return localChunks;
481
+ const localHashes = new Set();
482
+ for (const c of localChunks) {
483
+ const ruleLine = extractRuleLine(c.content);
484
+ if (ruleLine)
485
+ localHashes.add(hashContent(ruleLine));
486
+ }
487
+ const filtered = [];
488
+ for (const c of communityChunks) {
489
+ const ruleLine = extractRuleLine(c.content);
490
+ if (ruleLine && localHashes.has(hashContent(ruleLine)))
491
+ continue;
492
+ filtered.push(c);
493
+ }
494
+ return [...localChunks, ...filtered];
495
+ }
496
+ function extractRuleLine(content) {
497
+ // Both learningsToChunks and communityRulesToChunks render "**Rule:** ..."
498
+ const m = content.match(/\*\*Rule:\*\*\s*(.+)/);
499
+ if (!m)
500
+ return null;
501
+ return m[1].trim().toLowerCase();
502
+ }
503
+ function hashContent(s) {
504
+ return createHash("sha256").update(s).digest("hex");
505
+ }
506
+ //# sourceMappingURL=community-sync.js.map
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ const DEFAULT_PATTERNS = [
7
7
  ".github/copilot-instructions.md",
8
8
  ".github/instructions/copilot-instructions.md",
9
9
  ".github/SKILLS.md",
10
+ "SKILLS.md", // `contextengine init` writes SKILLS.md at the repo root — index both
10
11
  // Claude Code
11
12
  "CLAUDE.md",
12
13
  // Cursor
package/dist/hooks.d.ts CHANGED
@@ -71,6 +71,70 @@ export declare function runDocCoverage(policy: Policy, files: StagedFile[], repo
71
71
  export declare function hashDocSection(filePath: string, anchor: string): string | null;
72
72
  export declare function formatSecretViolations(violations: SecretViolation[]): string;
73
73
  export declare function formatDocCoverageViolations(violations: DocCoverageViolation[]): string;
74
+ /** Canonical bypass marker. Lives at the top of the checker so editors
75
+ * can grep for it when reviewing the audit story. */
76
+ export declare const COMMIT_BYPASS_PREFIX = "--skip-multi-agent-reason:";
77
+ export interface CommitMessageViolation {
78
+ kind: "missing-pattern" | "bypass";
79
+ severity: "block" | "warn";
80
+ ruleId: string;
81
+ matchedFiles: string[];
82
+ pattern: string;
83
+ description?: string;
84
+ /** Only set when kind === "bypass". The free-text reason captured
85
+ * after `--skip-multi-agent-reason:` for the audit log. */
86
+ bypassReason?: string;
87
+ }
88
+ /** Extract a bypass reason from the commit message. Returns the
89
+ * reason text (trimmed) or null when absent.
90
+ *
91
+ * Hardened 2026-06-26 (verifier Bypass #5 + #11):
92
+ * - The bypass-marker MUST appear at the start of its own line
93
+ * (only leading whitespace allowed). Mid-line or commented-out
94
+ * markers like `# Note: do NOT use --skip-multi-agent-reason: ever`
95
+ * are REJECTED — the `#` (or any non-whitespace char) before the
96
+ * prefix on the same line disqualifies the line.
97
+ * - Reason must be ≥ MIN_BYPASS_REASON_LENGTH chars (20).
98
+ * - Reason must contain at least one whitespace character — real
99
+ * reasons are prose ("emergency rollback at 03:00 UTC"), not
100
+ * alphanumeric placeholders ("12345" / "abc123def456…"). A token
101
+ * without spaces is almost certainly a defeat attempt.
102
+ */
103
+ export declare function extractBypassReason(commitMessage: string): string | null;
104
+ /**
105
+ * Strip any top-level alternation branch from a policy regex pattern
106
+ * that contains the literal COMMIT_BYPASS_PREFIX. Verifier Bypass #3:
107
+ * the canonical policy pattern
108
+ *
109
+ * Multi-agent: wf_[a-z0-9-]+|--skip-multi-agent-reason: .+
110
+ *
111
+ * had two branches in one pattern; the second branch matched the literal
112
+ * bypass marker anywhere in the message (including mid-line, commented
113
+ * out, etc.), short-circuiting the strict line-anchored validation in
114
+ * extractBypassReason. Fix: the matcher in runCommitMessageRequired
115
+ * ignores the bypass-marker branch entirely — bypass MUST go through
116
+ * extractBypassReason's hardened validation.
117
+ *
118
+ * Returns the cleaned pattern. If every branch is bypass-related (rare
119
+ * footgun), returns null — caller treats as "no positive branch to
120
+ * satisfy", which is the safe default.
121
+ */
122
+ export declare function stripBypassBranchFromPattern(pattern: string): string | null;
123
+ /**
124
+ * Apply policy.commit_message_required rules to a staged-file list +
125
+ * commit message. Returns one entry per fired rule (either a
126
+ * missing-pattern violation OR a bypass acknowledgement).
127
+ *
128
+ * Empty list = nothing fired (either no rule's `paths` matched, or every
129
+ * fired rule was satisfied by the message).
130
+ *
131
+ * IMPORTANT: This does NOT itself decide exit codes. The caller (CLI)
132
+ * decides: missing-pattern + severity=block → exit 1 + hook.block event;
133
+ * bypass → exit 0 + policy.skipped event with the reason.
134
+ */
135
+ export declare function runCommitMessageRequired(policy: Policy, files: StagedFile[], commitMessage: string): CommitMessageViolation[];
136
+ export declare function formatCommitMessageViolations(violations: CommitMessageViolation[]): string;
137
+ export declare function formatCommitMessageViolationsJson(violations: CommitMessageViolation[]): string;
74
138
  export declare function formatSecretViolationsJson(violations: SecretViolation[]): string;
75
139
  export declare function formatDocCoverageViolationsJson(violations: DocCoverageViolation[]): string;
76
140
  //# sourceMappingURL=hooks.d.ts.map