@compr/opscontext-mcp 2.1.1 → 2.1.3
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/CHANGELOG.md +117 -2
- package/README.md +73 -8
- package/dist/activation.js +29 -7
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +133 -14
- package/dist/cli.js +319 -16
- package/dist/community-export.d.ts +84 -0
- package/dist/community-export.js +400 -0
- package/dist/community-sync.d.ts +100 -0
- package/dist/community-sync.js +506 -0
- package/dist/hooks.d.ts +64 -0
- package/dist/hooks.js +240 -0
- package/dist/index.js +35 -2
- package/dist/install-autostart.js +66 -10
- package/dist/policy.d.ts +42 -0
- package/dist/policy.js +40 -0
- package/dist/tools-manifest.d.ts +37 -0
- package/dist/tools-manifest.js +64 -0
- package/package.json +4 -3
- package/skills/opscontext/SKILL.md +1 -1
|
@@ -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/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
|