@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/dist/hooks.js CHANGED
@@ -16,6 +16,33 @@
16
16
  // Inputs are fed by helpers that read the actual git working state.
17
17
  // Each runner returns a list of violations + a summary; the CLI maps that
18
18
  // onto exit codes + audit events + human/machine output.
19
+ //
20
+ // ---------------------------------------------------------------------------
21
+ // ACCEPTED LIMITS — out-of-scope by design (do NOT classify these as bugs)
22
+ // ---------------------------------------------------------------------------
23
+ // 1. `git commit --no-verify` — documented git escape hatch. Cannot be
24
+ // caught at the hook layer (git skips ALL hooks under that flag).
25
+ // Mitigation lives upstream of git (CODEOWNERS / branch protection /
26
+ // server-side push rules), not here.
27
+ // 2. Workflow ID format (`wf_x`) is NOT verified against actual
28
+ // audit-log entries — adding that would require a server-side query
29
+ // against the OpsContext audit-log service. Deferred to a later
30
+ // iteration. We only enforce the text-shape of the citation, not that
31
+ // it corresponds to a real workflow.
32
+ // 3. Direct SSH edits, cron edits, Cloudflare UI edits — out of scope
33
+ // by design. If the change never enters git, no commit hook can
34
+ // observe it. Defense-in-depth is via sidecar collectors (PM2,
35
+ // systemd, cron, Cloudflare audit log) feeding the central audit log.
36
+ // 4. Glob case-sensitivity on case-insensitive filesystems (APFS / NTFS)
37
+ // — git itself does NOT normalize this. A rule scoped to
38
+ // `server/Deploy.sh` will not match a path `server/deploy.sh` on
39
+ // Linux but WILL match on macOS-default APFS. Mitigation: author
40
+ // policy.json with case-sensitive paths that match git's stored
41
+ // paths exactly.
42
+ // 5. Sibling-project deploy scripts (KONIVE / PLANK / etc.) — each
43
+ // project gets its own `.contextengine/policy.json`. This rule only
44
+ // applies to the repo it lives in. Cross-repo enforcement is a
45
+ // separate concern (org-policy distribution via `extends:`).
19
46
  import { execSync } from "child_process";
20
47
  import { existsSync, readFileSync } from "fs";
21
48
  import { createHash } from "crypto";
@@ -279,6 +306,219 @@ export function formatDocCoverageViolations(violations) {
279
306
  return lines.join("\n");
280
307
  }
281
308
  // ---------------------------------------------------------------------------
309
+ // Commit-message-required checker (Agent B — Option B consumer)
310
+ // ---------------------------------------------------------------------------
311
+ //
312
+ // Consumes the `commit_message_required` rule type added by Agent A
313
+ // (CommitMessageRequiredSchema in src/policy.ts, sample rule
314
+ // `multi-agent-for-shared-infra` in .contextengine/policy.json).
315
+ //
316
+ // Trigger: any staged file path matches the rule's `paths` globs.
317
+ // Pass: the commit message matches the rule's `pattern` (ERE regex).
318
+ // Bypass: the commit body contains `--skip-multi-agent-reason: <reason>`
319
+ // — the bypass IS recorded as a separate kind (`bypass`) so the
320
+ // CLI can emit a `policy.skipped` audit event with the reason.
321
+ //
322
+ // We intentionally do NOT swallow the bypass into "no violation":
323
+ // surfacing it as a `bypass` kind lets the CLI keep the audit-log append
324
+ // in the same place as `hook.block` events, preserving symmetry.
325
+ /** Canonical bypass marker. Lives at the top of the checker so editors
326
+ * can grep for it when reviewing the audit story. */
327
+ export const COMMIT_BYPASS_PREFIX = "--skip-multi-agent-reason:";
328
+ /** Minimum reason length when bypassing. Hardened 2026-06-26 (verifier
329
+ * Bypass #11): the previous 5-char floor passed `12345` as a "valid"
330
+ * reason — no semantic content, just a placeholder to defeat the gate.
331
+ * Now 20 chars + at least one whitespace character (real reasons are
332
+ * prose with words, not concatenated tokens). Matches
333
+ * BypassTokenSchema.requires_reason_min_length default of 20. */
334
+ const MIN_BYPASS_REASON_LENGTH = 20;
335
+ /** Extract a bypass reason from the commit message. Returns the
336
+ * reason text (trimmed) or null when absent.
337
+ *
338
+ * Hardened 2026-06-26 (verifier Bypass #5 + #11):
339
+ * - The bypass-marker MUST appear at the start of its own line
340
+ * (only leading whitespace allowed). Mid-line or commented-out
341
+ * markers like `# Note: do NOT use --skip-multi-agent-reason: ever`
342
+ * are REJECTED — the `#` (or any non-whitespace char) before the
343
+ * prefix on the same line disqualifies the line.
344
+ * - Reason must be ≥ MIN_BYPASS_REASON_LENGTH chars (20).
345
+ * - Reason must contain at least one whitespace character — real
346
+ * reasons are prose ("emergency rollback at 03:00 UTC"), not
347
+ * alphanumeric placeholders ("12345" / "abc123def456…"). A token
348
+ * without spaces is almost certainly a defeat attempt.
349
+ */
350
+ export function extractBypassReason(commitMessage) {
351
+ for (const rawLine of commitMessage.split(/\r?\n/)) {
352
+ // Allow ONLY leading whitespace before the prefix (no `#`, no quote,
353
+ // no other prose). Verifier Bypass #5 attack vector closed here.
354
+ const leadingMatch = rawLine.match(/^(\s*)/);
355
+ const leading = leadingMatch ? leadingMatch[1] : "";
356
+ const afterLeading = rawLine.slice(leading.length);
357
+ if (!afterLeading.startsWith(COMMIT_BYPASS_PREFIX))
358
+ continue;
359
+ const reason = afterLeading.slice(COMMIT_BYPASS_PREFIX.length).trim();
360
+ if (reason.length < MIN_BYPASS_REASON_LENGTH)
361
+ continue;
362
+ // Real reasons contain at least one space — reject "12345",
363
+ // "abcdef1234567890ABCDE", and other word-less placeholders.
364
+ if (!/\s/.test(reason))
365
+ continue;
366
+ return reason;
367
+ }
368
+ return null;
369
+ }
370
+ /**
371
+ * Strip any top-level alternation branch from a policy regex pattern
372
+ * that contains the literal COMMIT_BYPASS_PREFIX. Verifier Bypass #3:
373
+ * the canonical policy pattern
374
+ *
375
+ * Multi-agent: wf_[a-z0-9-]+|--skip-multi-agent-reason: .+
376
+ *
377
+ * had two branches in one pattern; the second branch matched the literal
378
+ * bypass marker anywhere in the message (including mid-line, commented
379
+ * out, etc.), short-circuiting the strict line-anchored validation in
380
+ * extractBypassReason. Fix: the matcher in runCommitMessageRequired
381
+ * ignores the bypass-marker branch entirely — bypass MUST go through
382
+ * extractBypassReason's hardened validation.
383
+ *
384
+ * Returns the cleaned pattern. If every branch is bypass-related (rare
385
+ * footgun), returns null — caller treats as "no positive branch to
386
+ * satisfy", which is the safe default.
387
+ */
388
+ export function stripBypassBranchFromPattern(pattern) {
389
+ // Top-level alternation split. This is a SIMPLE split — patterns with
390
+ // nested grouping that uses `|` inside `(...)` aren't decomposed, but
391
+ // the canonical policy patterns don't use that shape. If a future
392
+ // policy needs nested alternation, the safer move is to author the
393
+ // bypass branch out of the policy entirely (it belongs to
394
+ // extractBypassReason, not to the pattern).
395
+ const branches = pattern.split("|");
396
+ const cleaned = branches.filter((b) => !b.includes(COMMIT_BYPASS_PREFIX));
397
+ if (cleaned.length === 0)
398
+ return null;
399
+ return cleaned.join("|");
400
+ }
401
+ /**
402
+ * Apply policy.commit_message_required rules to a staged-file list +
403
+ * commit message. Returns one entry per fired rule (either a
404
+ * missing-pattern violation OR a bypass acknowledgement).
405
+ *
406
+ * Empty list = nothing fired (either no rule's `paths` matched, or every
407
+ * fired rule was satisfied by the message).
408
+ *
409
+ * IMPORTANT: This does NOT itself decide exit codes. The caller (CLI)
410
+ * decides: missing-pattern + severity=block → exit 1 + hook.block event;
411
+ * bypass → exit 0 + policy.skipped event with the reason.
412
+ */
413
+ export function runCommitMessageRequired(policy, files, commitMessage) {
414
+ const results = [];
415
+ const bypassReason = extractBypassReason(commitMessage);
416
+ for (const rule of policy.commit_message_required) {
417
+ const matchedFiles = files
418
+ .map((f) => f.path)
419
+ .filter((p) => matchesAnyGlob(p, rule.paths));
420
+ if (matchedFiles.length === 0)
421
+ continue; // rule did not fire
422
+ // Bypass takes precedence — record it, do not block.
423
+ if (bypassReason !== null) {
424
+ results.push({
425
+ kind: "bypass",
426
+ severity: rule.severity,
427
+ ruleId: rule.id,
428
+ matchedFiles,
429
+ pattern: rule.pattern,
430
+ description: rule.description,
431
+ bypassReason,
432
+ });
433
+ continue;
434
+ }
435
+ // No bypass — check the pattern. Strip any bypass-marker alternation
436
+ // branch first (verifier Bypass #3): the bypass path is owned by
437
+ // extractBypassReason ONLY. Pattern-matching the bypass marker text
438
+ // is forbidden because the policy regex has no line-anchoring and
439
+ // therefore cannot distinguish a real bypass from a quoted/commented
440
+ // mention of the marker.
441
+ const cleanedPattern = stripBypassBranchFromPattern(rule.pattern);
442
+ if (cleanedPattern === null) {
443
+ // Pattern was 100% bypass-branches. Treat as missing-pattern —
444
+ // the rule has no positive enforcement branch left after
445
+ // sanitization.
446
+ results.push({
447
+ kind: "missing-pattern",
448
+ severity: rule.severity,
449
+ ruleId: rule.id,
450
+ matchedFiles,
451
+ pattern: rule.pattern,
452
+ description: rule.description,
453
+ });
454
+ continue;
455
+ }
456
+ let re;
457
+ try {
458
+ re = new RegExp(cleanedPattern);
459
+ }
460
+ catch {
461
+ // Malformed pattern in policy.json — surface as a missing-pattern
462
+ // violation rather than crashing the hook. The CLI will print the
463
+ // rule.description, which should explain the contract.
464
+ results.push({
465
+ kind: "missing-pattern",
466
+ severity: rule.severity,
467
+ ruleId: rule.id,
468
+ matchedFiles,
469
+ pattern: rule.pattern,
470
+ description: rule.description,
471
+ });
472
+ continue;
473
+ }
474
+ if (!re.test(commitMessage)) {
475
+ results.push({
476
+ kind: "missing-pattern",
477
+ severity: rule.severity,
478
+ ruleId: rule.id,
479
+ matchedFiles,
480
+ pattern: rule.pattern,
481
+ description: rule.description,
482
+ });
483
+ }
484
+ }
485
+ return results;
486
+ }
487
+ export function formatCommitMessageViolations(violations) {
488
+ if (violations.length === 0)
489
+ return "✅ All commit-message-required rules satisfied.";
490
+ const lines = [];
491
+ const blocking = violations.filter((v) => v.kind === "missing-pattern" && v.severity === "block").length;
492
+ const bypasses = violations.filter((v) => v.kind === "bypass").length;
493
+ lines.push(`📝 COMMIT MESSAGE POLICY: ${violations.length} rule(s) fired — ${blocking} blocking, ${bypasses} bypassed.`);
494
+ for (const v of violations) {
495
+ if (v.kind === "bypass") {
496
+ lines.push(` [bypass] ${v.ruleId} on ${v.matchedFiles.join(", ")} — reason: "${v.bypassReason}" (logged to audit)`);
497
+ continue;
498
+ }
499
+ lines.push(` [${v.severity}] ${v.ruleId} on ${v.matchedFiles.join(", ")} — commit body must match /${v.pattern}/`);
500
+ if (v.description)
501
+ lines.push(` ${v.description}`);
502
+ }
503
+ return lines.join("\n");
504
+ }
505
+ export function formatCommitMessageViolationsJson(violations) {
506
+ return JSON.stringify({
507
+ check: "commit-message-required",
508
+ violations_total: violations.length,
509
+ blocking: violations.filter((v) => v.kind === "missing-pattern" && v.severity === "block").length,
510
+ bypasses: violations.filter((v) => v.kind === "bypass").length,
511
+ violations: violations.map((v) => ({
512
+ kind: v.kind,
513
+ severity: v.severity,
514
+ rule_id: v.ruleId,
515
+ matched_files: v.matchedFiles,
516
+ pattern: v.pattern,
517
+ bypass_reason: v.bypassReason,
518
+ })),
519
+ });
520
+ }
521
+ // ---------------------------------------------------------------------------
282
522
  // Machine-readable output (for CI pipelines)
283
523
  // ---------------------------------------------------------------------------
284
524
  export function formatSecretViolationsJson(violations) {
package/dist/index.js CHANGED
@@ -14,11 +14,14 @@ import { verifyChain, readAuditLog, filterByRange } from "./audit.js";
14
14
  import { startEventIngestServer } from "./http-server.js";
15
15
  import { detect } from "./detector.js";
16
16
  import { saveLearning, searchLearnings, listLearnings, deleteLearning, learningsToChunks, learningsStats, formatLearnings, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
17
- import { readFileSync, existsSync, watch, statSync } from "fs";
17
+ import { communityRulesToChunks, mergeWithDedup, loadCommunityStore, } from "./community-sync.js";
18
+ import { readFileSync, existsSync, watch, statSync, writeFileSync, mkdirSync } from "fs";
18
19
  import { basename, join, dirname } from "path";
20
+ import { homedir } from "os";
19
21
  import { execSync } from "child_process";
20
22
  import { scanCodeDir } from "./code-chunker.js";
21
23
  import { fileURLToPath } from "url";
24
+ import { TOOL_COUNT, FREE_TOOL_COUNT, PREMIUM_TOOL_NAMES } from "./tools-manifest.js";
22
25
  // Read version from package.json at startup
23
26
  let PKG_VERSION = "1.21.3";
24
27
  try {
@@ -112,6 +115,19 @@ async function reindex() {
112
115
  chunks.push(...learningChunks);
113
116
  console.error(`[ContextEngine] 💡 Injected ${learningChunks.length} learning chunks into search index (scoped to ${activeProjectNames.length} projects)`);
114
117
  }
118
+ // Inject community rules from the cached store (best-effort — no network
119
+ // touched here; the daily `sync-community-rules` CLI keeps the cache fresh).
120
+ // Deduped against the local learnings so identical content never double-emits.
121
+ const communityChunks = communityRulesToChunks();
122
+ if (communityChunks.length > 0) {
123
+ const before = chunks.length;
124
+ chunks = mergeWithDedup(chunks, communityChunks);
125
+ const added = chunks.length - before;
126
+ const skipped = communityChunks.length - added;
127
+ const store = loadCommunityStore();
128
+ console.error(`[ContextEngine] 🌐 Injected ${added} community rule chunks ` +
129
+ `(${skipped} dedup'd vs local; ${store.rules.length} total in cache)`);
130
+ }
115
131
  // Collect from plugin adapters
116
132
  if (config.adapters && config.adapters.length > 0) {
117
133
  const adapterChunks = await collectFromAdapters(config.adapters);
@@ -551,7 +567,7 @@ server.tool("delete_session", "Delete a saved session by name. Returns success/n
551
567
  // ---------------------------------------------------------------------------
552
568
  // Tool: audit_verify (Compliance — tamper-evident audit log)
553
569
  // ---------------------------------------------------------------------------
554
- server.tool("audit_verify", "Verify the integrity of the local audit log chain. Returns OK + record count, or BROKEN + break index when a record has been edited or the chain otherwise diverges. Compliance basis: SOC2 CC7.2, ISO 27001 A.12.4.1. The audit log lives at ~/.contextengine/audit.log and records every state-changing operation (learning save/delete/import, session save/delete, activation activate/deactivate) as a hash-chained JSONL line.", {
570
+ server.tool("audit_verify", "Verify the integrity of the local audit log chain. Returns OK + record count, or BROKEN + break index when a record has been edited or the chain otherwise diverges. Produces evidence aligned with SOC 2 CC7.2 (change monitoring) and ISO 27001 A.12.4.1 (event logging) — evidence artifacts, not a certification (OpsContext is not itself SOC 2– or ISO 27001–certified; see docs/compliance/). The audit log lives at ~/.contextengine/audit.log and records every state-changing operation (learning save/delete/import, session save/delete, activation activate/deactivate) as a hash-chained JSONL line.", {
555
571
  since: z.string().optional().describe("ISO date — restrict integrity report counters to records on/after this timestamp (chain still verified end-to-end)"),
556
572
  until: z.string().optional().describe("ISO date — restrict counters to records on/before this timestamp"),
557
573
  }, async ({ since, until }) => {
@@ -1090,6 +1106,23 @@ async function main() {
1090
1106
  const transport = new StdioServerTransport();
1091
1107
  await server.connect(transport);
1092
1108
  console.error("[ContextEngine] 🚀 MCP server running on stdio (keyword search ready)");
1109
+ // 3b. Write server-meta.json so the VS Code extension can read tool count
1110
+ // without needing an active MCP session. Single source of truth =
1111
+ // src/tools-manifest.ts (asserted by tests/tools-manifest.test.ts).
1112
+ try {
1113
+ const metaDir = join(homedir(), ".contextengine");
1114
+ mkdirSync(metaDir, { recursive: true });
1115
+ writeFileSync(join(metaDir, "server-meta.json"), JSON.stringify({
1116
+ toolCount: TOOL_COUNT,
1117
+ freeCount: FREE_TOOL_COUNT,
1118
+ premiumCount: PREMIUM_TOOL_NAMES.length,
1119
+ version: PKG_VERSION,
1120
+ generatedAt: new Date().toISOString(),
1121
+ }, null, 2));
1122
+ }
1123
+ catch (err) {
1124
+ console.error("[ContextEngine] ⚠ Failed to write server-meta.json:", err);
1125
+ }
1093
1126
  // 4. Load embeddings — try cache first, then model (non-blocking)
1094
1127
  const cached = loadCache(chunks);
1095
1128
  if (cached) {
@@ -18,6 +18,22 @@ import { existsSync, writeFileSync, mkdirSync } from "fs";
18
18
  import { join, dirname } from "path";
19
19
  import { homedir, platform } from "os";
20
20
  import { execSync } from "child_process";
21
+ import { createRequire } from "module";
22
+ import { fileURLToPath } from "url";
23
+ // 🔒 LOCKED [M2-ESM-FILENAME-FIX] — 2026-06-24
24
+ // ⛔ NEVER reference bare `__filename` in this file — the package is
25
+ // `"type": "module"` so __filename is `undefined` at runtime and
26
+ // `dirname(__filename || "")` was returning dirname("") = "." which
27
+ // silently broke the dev-tree fallback. Audit FRESH_USER_AUDIT_
28
+ // 2026-06-23.md finding M2.
29
+ // FIX: Resolve module path via fileURLToPath(import.meta.url). For
30
+ // cross-package resolution (e.g. when running via npx and the
31
+ // @compr/opscontext-mcp tarball is in npx's transient cache), also
32
+ // try createRequire(import.meta.url).resolve("@compr/opscontext-mcp/
33
+ // dist/index.js") which works inside npx.
34
+ const __filename_esm = fileURLToPath(import.meta.url);
35
+ const __dirname_esm = dirname(__filename_esm);
36
+ const requireFromHere = createRequire(import.meta.url);
21
37
  const LABEL = "com.opscontext.mcp";
22
38
  const PLIST_FILE = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
23
39
  const LOG_DIR = join(homedir(), ".contextengine", "logs");
@@ -34,7 +50,7 @@ function detectNodePath() {
34
50
  * (2) ./dist/index.js next to this module (dev tree). Falls through with
35
51
  * null if neither is found. */
36
52
  function detectOpscontextEntry() {
37
- // Try global install via `npm root -g`
53
+ // (1) Try global install via `npm root -g`
38
54
  try {
39
55
  const globalRoot = execSync("npm root -g 2>/dev/null", { encoding: "utf-8" }).trim();
40
56
  const candidate = join(globalRoot, "@compr", "opscontext-mcp", "dist", "index.js");
@@ -44,19 +60,31 @@ function detectOpscontextEntry() {
44
60
  catch {
45
61
  /* no npm root available; fall through */
46
62
  }
47
- // Try dev tree relative to this module's location (dist/install-autostart.js)
48
- // → __dirname/.. would be dist/, then ../ would be repo root, then dist/index.js
63
+ // (2) Try dev tree relative to this module's location (dist/install-autostart.js)
64
+ // → __dirname_esm IS dist/, so dist/index.js is a sibling. ESM-safe (uses
65
+ // fileURLToPath(import.meta.url), not the broken `__filename` reference
66
+ // that the M2 audit caught).
49
67
  try {
50
- // import.meta.url style would be nicer but cli.ts is CommonJS-ish; use __dirname
51
- // via require.resolve fallback. We're loaded from dist/, so look at sibling.
52
- const here = dirname(__filename || "");
53
- const candidate = join(here, "index.js");
68
+ const candidate = join(__dirname_esm, "index.js");
54
69
  if (existsSync(candidate))
55
70
  return { kind: "devtree", path: candidate };
56
71
  }
57
72
  catch {
58
73
  /* ignore */
59
74
  }
75
+ // (3) NEW: Try resolve via createRequire (works inside npx's transient
76
+ // install — when the user runs `npx -y @compr/opscontext-mcp
77
+ // install-autostart` the package is in npx's cache, not npm's global root,
78
+ // so step (1) misses. createRequire walks Node's resolution algorithm and
79
+ // finds the cache copy. Audit M2 fix.
80
+ try {
81
+ const resolved = requireFromHere.resolve("@compr/opscontext-mcp/dist/index.js");
82
+ if (existsSync(resolved))
83
+ return { kind: "npx", path: resolved };
84
+ }
85
+ catch {
86
+ /* not resolvable — caller will print the install-globally hint */
87
+ }
60
88
  return null;
61
89
  }
62
90
  function buildPlist(nodePath, entryPath, nodeBinDir) {
@@ -163,12 +191,40 @@ To view server logs: tail -f ~/.contextengine/logs/mcp-stderr.log
163
191
  console.error(` Pass --force to overwrite, or run: opscontext autostart-status`);
164
192
  process.exit(1);
165
193
  }
194
+ // Allow operator to pin the entry path explicitly — escape hatch when
195
+ // detection fails (e.g. monorepo / private registry / unconventional layout).
196
+ // Audit M2 follow-up: was originally a CLI flag suggestion.
197
+ const entryFlagIdx = args.findIndex((a) => a === "--entry" || a.startsWith("--entry="));
198
+ let entry = null;
199
+ if (entryFlagIdx >= 0) {
200
+ const raw = args[entryFlagIdx].includes("=")
201
+ ? args[entryFlagIdx].split("=")[1]
202
+ : args[entryFlagIdx + 1];
203
+ if (!raw) {
204
+ console.error(`❌ --entry requires a path. Usage: --entry=/path/to/dist/index.js`);
205
+ process.exit(1);
206
+ }
207
+ if (!existsSync(raw)) {
208
+ console.error(`❌ --entry path does not exist: ${raw}`);
209
+ process.exit(1);
210
+ }
211
+ entry = { kind: "manual", path: raw };
212
+ }
166
213
  const nodePath = detectNodePath();
167
- const entry = detectOpscontextEntry();
214
+ if (!entry)
215
+ entry = detectOpscontextEntry();
168
216
  if (!entry) {
169
- console.error(`❌ Could not locate opscontext entrypoint.`);
170
- console.error(` Either install globally: npm install -g @compr/opscontext-mcp`);
217
+ console.error(`❌ Could not locate opscontext entrypoint. Tried 3 paths:`);
218
+ console.error(` (1) npm global root @compr/opscontext-mcp/dist/index.js`);
219
+ console.error(` (2) dev tree sibling (this script's dist/ dir)`);
220
+ console.error(` (3) Node resolution of "@compr/opscontext-mcp/dist/index.js" via createRequire (npx cache)`);
221
+ console.error(``);
222
+ console.error(` If you installed with npx, install globally first:`);
223
+ console.error(` npm install -g @compr/opscontext-mcp`);
224
+ console.error(``);
171
225
  console.error(` Or run from a clone: cd .../ContextEngine && npm run build`);
226
+ console.error(``);
227
+ console.error(` Or pin a path explicitly: opscontext install-autostart --entry=/full/path/to/dist/index.js`);
172
228
  process.exit(1);
173
229
  }
174
230
  // Ensure log dir
package/dist/policy.d.ts CHANGED
@@ -42,6 +42,38 @@ export declare const DeployVerifyHostSchema: z.ZodObject<{
42
42
  description: z.ZodOptional<z.ZodString>;
43
43
  }, z.core.$strip>;
44
44
  export type DeployVerifyHost = z.infer<typeof DeployVerifyHostSchema>;
45
+ /**
46
+ * A staged-path → required-commit-message-pattern rule. Fires when a
47
+ * commit touches any path matching `paths` AND the commit message does
48
+ * NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
49
+ * encodes the Session 15 / Sprint 16 lesson: shared production
50
+ * infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
51
+ * must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
52
+ * OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
53
+ * that gets recorded in the audit log.
54
+ *
55
+ * Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
56
+ * caught 5 design errors + 2 structural blockers in an Option B blue/green
57
+ * rollout that would otherwise have shipped and crashed sibling apps on
58
+ * the multi-tenant VPS. The rule turns that one-time discipline into a
59
+ * machine-enforced gate.
60
+ *
61
+ * NOTE: processor implementation (parsing staged paths + scanning the
62
+ * commit message buffer in the prepare-commit-msg or commit-msg hook)
63
+ * is intentionally left to a follow-up — this file only declares the
64
+ * shape so policy.json validation accepts the new rule today.
65
+ */
66
+ export declare const CommitMessageRequiredSchema: z.ZodObject<{
67
+ id: z.ZodString;
68
+ paths: z.ZodArray<z.ZodString>;
69
+ pattern: z.ZodString;
70
+ severity: z.ZodDefault<z.ZodEnum<{
71
+ warn: "warn";
72
+ block: "block";
73
+ }>>;
74
+ description: z.ZodOptional<z.ZodString>;
75
+ }, z.core.$strip>;
76
+ export type CommitMessageRequired = z.infer<typeof CommitMessageRequiredSchema>;
45
77
  /**
46
78
  * A documented escape hatch for the hook. Beats undocumented `touch` /
47
79
  * `--no-verify` workarounds. Bypass token requires a reason and lives in
@@ -85,6 +117,16 @@ export declare const PolicySchema: z.ZodObject<{
85
117
  within_seconds: z.ZodDefault<z.ZodNumber>;
86
118
  description: z.ZodOptional<z.ZodString>;
87
119
  }, z.core.$strip>>>;
120
+ commit_message_required: z.ZodDefault<z.ZodArray<z.ZodObject<{
121
+ id: z.ZodString;
122
+ paths: z.ZodArray<z.ZodString>;
123
+ pattern: z.ZodString;
124
+ severity: z.ZodDefault<z.ZodEnum<{
125
+ warn: "warn";
126
+ block: "block";
127
+ }>>;
128
+ description: z.ZodOptional<z.ZodString>;
129
+ }, z.core.$strip>>>;
88
130
  bypass_tokens: z.ZodDefault<z.ZodArray<z.ZodObject<{
89
131
  id: z.ZodString;
90
132
  ttl_seconds: z.ZodDefault<z.ZodNumber>;
package/dist/policy.js CHANGED
@@ -58,6 +58,40 @@ export const DeployVerifyHostSchema = z.object({
58
58
  within_seconds: z.number().int().positive().default(60),
59
59
  description: z.string().optional(),
60
60
  });
61
+ /**
62
+ * A staged-path → required-commit-message-pattern rule. Fires when a
63
+ * commit touches any path matching `paths` AND the commit message does
64
+ * NOT match `pattern`. The canonical case (`multi-agent-for-shared-infra`)
65
+ * encodes the Session 15 / Sprint 16 lesson: shared production
66
+ * infrastructure changes (deploy scripts, ecosystem.config, nginx confs)
67
+ * must cite a multi-agent diagnostic workflow ID (`Multi-agent: wf_…`)
68
+ * OR carry an explicit bypass reason (`--skip-multi-agent-reason: …`)
69
+ * that gets recorded in the audit log.
70
+ *
71
+ * Rationale: the Sprint 16 multi-agent diagnostic (workflow `wdcraou93`)
72
+ * caught 5 design errors + 2 structural blockers in an Option B blue/green
73
+ * rollout that would otherwise have shipped and crashed sibling apps on
74
+ * the multi-tenant VPS. The rule turns that one-time discipline into a
75
+ * machine-enforced gate.
76
+ *
77
+ * NOTE: processor implementation (parsing staged paths + scanning the
78
+ * commit message buffer in the prepare-commit-msg or commit-msg hook)
79
+ * is intentionally left to a follow-up — this file only declares the
80
+ * shape so policy.json validation accepts the new rule today.
81
+ */
82
+ export const CommitMessageRequiredSchema = z.object({
83
+ id: z.string().min(1).describe("Stable identifier for audit-log attribution"),
84
+ paths: z
85
+ .array(z.string())
86
+ .min(1)
87
+ .describe("Glob patterns of staged files that trigger this rule (e.g. server/deploy.sh)"),
88
+ pattern: z
89
+ .string()
90
+ .min(1)
91
+ .describe("ERE regex the commit message MUST match for the commit to proceed"),
92
+ severity: z.enum(["block", "warn"]).default("block"),
93
+ description: z.string().optional(),
94
+ });
61
95
  /**
62
96
  * A documented escape hatch for the hook. Beats undocumented `touch` /
63
97
  * `--no-verify` workarounds. Bypass token requires a reason and lives in
@@ -82,6 +116,7 @@ export const PolicySchema = z.object({
82
116
  secret_patterns: z.array(SecretPatternSchema).default([]),
83
117
  doc_coverage: z.array(DocCoverageSchema).default([]),
84
118
  deploy_verify_hosts: z.array(DeployVerifyHostSchema).default([]),
119
+ commit_message_required: z.array(CommitMessageRequiredSchema).default([]),
85
120
  bypass_tokens: z.array(BypassTokenSchema).default([]),
86
121
  });
87
122
  export function validatePolicy(raw) {
@@ -165,6 +200,11 @@ export function formatPolicySummary(policy) {
165
200
  lines.push(` - ${h.host} → probe within ${h.within_seconds}s: ${h.require_probe}`);
166
201
  }
167
202
  lines.push("");
203
+ lines.push(`Commit-message-required rules: ${policy.commit_message_required.length}`);
204
+ for (const r of policy.commit_message_required) {
205
+ lines.push(` - [${r.severity}] ${r.id} → paths ${r.paths.join(", ")} must match /${r.pattern}/`);
206
+ }
207
+ lines.push("");
168
208
  lines.push(`Bypass tokens: ${policy.bypass_tokens.length}`);
169
209
  for (const b of policy.bypass_tokens) {
170
210
  lines.push(` - ${b.id} → TTL ${b.ttl_seconds}s, reason ≥ ${b.requires_reason_min_length} chars`);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Tools Manifest — single source of truth for the MCP tool catalog.
3
+ *
4
+ * Why: the info panel in the VS Code extension (and the README) historically
5
+ * hardcoded the tool count ("Active on all 17 MCP tools"). When new tools
6
+ * shipped (drift_status was the 21st), those displays silently drifted.
7
+ *
8
+ * Fix: every tool exposed via `server.tool(...)` in `index.ts` MUST appear
9
+ * in `ALL_TOOLS` below. A regression test in `tests/tools-manifest.test.ts`
10
+ * counts `^server.tool(` lines in `index.ts` and asserts the count matches
11
+ * `ALL_TOOLS.length`. If you add or remove a tool, this list + the test
12
+ * fail together — no silent drift possible.
13
+ *
14
+ * Consumers:
15
+ * - `index.ts` writes `~/.contextengine/server-meta.json` on startup with
16
+ * `{ toolCount, premiumCount, freeCount, version, generatedAt }` so the
17
+ * VS Code extension can read it without needing an active MCP session.
18
+ * - `activation.ts` imports `PREMIUM_TOOL_NAMES` (the subset that requires
19
+ * a PRO license).
20
+ *
21
+ * @module tools-manifest
22
+ */
23
+ /**
24
+ * Every tool name registered on the MCP server, in registration order.
25
+ * Order is not load-bearing — kept stable for easier diffs.
26
+ */
27
+ export declare const ALL_TOOLS: readonly ["search_context", "list_sources", "read_source", "reindex", "list_projects", "check_ports", "run_audit", "score_project", "save_session", "load_session", "list_sessions", "delete_session", "audit_verify", "drift_status", "end_session", "save_learning", "list_learnings", "delete_learning", "import_learnings", "activate", "activation_status"];
28
+ /**
29
+ * The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
30
+ * Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
31
+ */
32
+ export declare const PREMIUM_TOOL_NAMES: readonly ["score_project", "run_audit", "check_ports", "list_projects"];
33
+ /** Total count — what users see as "Active on all N MCP tools". */
34
+ export declare const TOOL_COUNT: 21;
35
+ /** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
36
+ export declare const FREE_TOOL_COUNT: number;
37
+ //# sourceMappingURL=tools-manifest.d.ts.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Tools Manifest — single source of truth for the MCP tool catalog.
3
+ *
4
+ * Why: the info panel in the VS Code extension (and the README) historically
5
+ * hardcoded the tool count ("Active on all 17 MCP tools"). When new tools
6
+ * shipped (drift_status was the 21st), those displays silently drifted.
7
+ *
8
+ * Fix: every tool exposed via `server.tool(...)` in `index.ts` MUST appear
9
+ * in `ALL_TOOLS` below. A regression test in `tests/tools-manifest.test.ts`
10
+ * counts `^server.tool(` lines in `index.ts` and asserts the count matches
11
+ * `ALL_TOOLS.length`. If you add or remove a tool, this list + the test
12
+ * fail together — no silent drift possible.
13
+ *
14
+ * Consumers:
15
+ * - `index.ts` writes `~/.contextengine/server-meta.json` on startup with
16
+ * `{ toolCount, premiumCount, freeCount, version, generatedAt }` so the
17
+ * VS Code extension can read it without needing an active MCP session.
18
+ * - `activation.ts` imports `PREMIUM_TOOL_NAMES` (the subset that requires
19
+ * a PRO license).
20
+ *
21
+ * @module tools-manifest
22
+ */
23
+ /**
24
+ * Every tool name registered on the MCP server, in registration order.
25
+ * Order is not load-bearing — kept stable for easier diffs.
26
+ */
27
+ export const ALL_TOOLS = [
28
+ "search_context",
29
+ "list_sources",
30
+ "read_source",
31
+ "reindex",
32
+ "list_projects",
33
+ "check_ports",
34
+ "run_audit",
35
+ "score_project",
36
+ "save_session",
37
+ "load_session",
38
+ "list_sessions",
39
+ "delete_session",
40
+ "audit_verify",
41
+ "drift_status",
42
+ "end_session",
43
+ "save_learning",
44
+ "list_learnings",
45
+ "delete_learning",
46
+ "import_learnings",
47
+ "activate",
48
+ "activation_status",
49
+ ];
50
+ /**
51
+ * The 4 tools gated behind PRO activation. Subset of `ALL_TOOLS`.
52
+ * Must match `PREMIUM_TOOLS` in `src/activation.ts` (asserted by test).
53
+ */
54
+ export const PREMIUM_TOOL_NAMES = [
55
+ "score_project",
56
+ "run_audit",
57
+ "check_ports",
58
+ "list_projects",
59
+ ];
60
+ /** Total count — what users see as "Active on all N MCP tools". */
61
+ export const TOOL_COUNT = ALL_TOOLS.length;
62
+ /** Free-tier tool count — everything except `PREMIUM_TOOL_NAMES`. */
63
+ export const FREE_TOOL_COUNT = ALL_TOOLS.length - PREMIUM_TOOL_NAMES.length;
64
+ //# sourceMappingURL=tools-manifest.js.map