@compr/opscontext-mcp 2.1.0 → 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/audit.js CHANGED
@@ -6,26 +6,119 @@
6
6
  // ⛔ NEVER catch errors inside appendAudit() — silent failures defeat the
7
7
  // entire compliance story. Use safeAppend() at call sites if you need
8
8
  // failure isolation; appendAudit() must surface problems loudly.
9
- // WHY: This is the SOC2 CC7.2 / ISO 27001 A.12.4.1 compliance bedrock. The
10
- // audit log is the foundation that licence-signature verification,
11
- // compliance reporting, and enforcement telemetry all build on. Any
12
- // silent break here destroys evidence value across years of records.
9
+ // WHY: This is the bedrock for evidence aligned with SOC 2 CC7.2 (change
10
+ // monitoring) and ISO 27001 A.12.4.1 (event logging). These are
11
+ // EVIDENCE ARTIFACTS OpsContext is NOT itself SOC 2– or ISO 27001–
12
+ // certified; the chain helps a deploying org's auditor satisfy those
13
+ // controls. See docs/compliance/cc7.2.md + docs/compliance/a.12.4.1.md.
14
+ // Any silent break here destroys evidence value across years of
15
+ // records and invalidates the chain integrity property downstream
16
+ // code (verifyChain, license signatures, enforcement telemetry)
17
+ // depends on.
13
18
  // FIX: If you need to evolve the record format, version the chain
14
19
  // (add a "v":2 field) and keep verifyChain() backward-compatible by
15
20
  // dispatching on the v field. Don't mutate the v=1 contract.
16
21
  //
22
+ // 🔒 LOCKED [AUDIT-001-WRITE-RACE-FIX] — 2026-06-24
23
+ // ⛔ NEVER remove the file-lock acquisition in appendAudit(). The chain
24
+ // was broken at index 2826 (Sessions 11-13) by concurrent writers
25
+ // (activation server + main MCP) reading the same prev_hash before
26
+ // either had flushed. The lock serializes the read-then-write
27
+ // window across processes.
28
+ // ⛔ NEVER trust cachedLastHash without verifying file size hasn't
29
+ // grown since cachedSize. Another process may have written between
30
+ // OUR last write and OUR next read.
31
+ // WHY: audit-001-write-race documented in Session 11 SCORE.md. The
32
+ // in-process chain cache is a perf optimization, NOT a correctness
33
+ // guarantee — correctness comes from the lock + the size-mismatch
34
+ // re-read.
35
+ // FIX: To raise throughput further (if profiling proves the stat() per
36
+ // append is hot), batch appends within a process behind a single
37
+ // lock acquisition. Don't remove the lock.
38
+ //
17
39
  // Tamper-evident audit log — hash-chained JSONL at ~/.contextengine/audit.log.
18
40
  //
19
- // Compliance basis: SOC2 CC7.2 (audit logging), ISO 27001 A.12.4.1 (event logs).
41
+ // Compliance: produces evidence aligned with SOC 2 CC7.2 + ISO 27001 A.12.4.1
42
+ // (evidence artifacts, not certifications — see docs/compliance/).
20
43
  //
21
44
  // Records every state-changing operation. Each line carries the SHA-256 hash
22
45
  // of the previous line's canonical content, so mutation of any historical
23
46
  // record breaks chain verification at that index.
24
- import { existsSync, mkdirSync, readFileSync, appendFileSync } from "fs";
47
+ import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, constants, } from "fs";
25
48
  import { join } from "path";
26
49
  import { homedir } from "os";
27
50
  import { createHash } from "crypto";
28
51
  const GENESIS_HASH = "0".repeat(64);
52
+ // ─── File lock primitives ───────────────────────────────────────────────────
53
+ // O_EXCL + O_CREAT is atomic across processes on POSIX and on Windows NTFS,
54
+ // so creating the lockfile is the synchronization primitive. Stale-lock
55
+ // recovery: if the lockfile is older than STALE_LOCK_MS, treat it as
56
+ // orphaned (process crashed mid-append) and unlink it.
57
+ const LOCK_TIMEOUT_MS = 2000; // total wait before giving up
58
+ const LOCK_RETRY_MS = 5; // poll interval
59
+ const STALE_LOCK_MS = 10_000; // lockfile older than this = orphan
60
+ function lockPath() {
61
+ return join(auditDir(), "audit.lock");
62
+ }
63
+ /** Synchronous sleep that doesn't burn CPU — uses Atomics.wait on a
64
+ * throwaway SharedArrayBuffer. Accurate to ~1ms. */
65
+ function syncSleep(ms) {
66
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
67
+ }
68
+ /** Acquire an exclusive file lock. Returns a release function. Throws if
69
+ * unable to acquire within LOCK_TIMEOUT_MS. */
70
+ function acquireLockSync() {
71
+ const path = lockPath();
72
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
73
+ while (Date.now() < deadline) {
74
+ try {
75
+ // O_EXCL fails atomically if the file already exists.
76
+ const fd = openSync(path,
77
+ // eslint-disable-next-line no-bitwise
78
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
79
+ // Write PID + ts so a debugger can see who's holding the lock.
80
+ try {
81
+ writeSync(fd, `${process.pid}\n${new Date().toISOString()}\n`);
82
+ }
83
+ catch {
84
+ /* lock file is what matters; the contents are nice-to-have */
85
+ }
86
+ closeSync(fd);
87
+ return () => {
88
+ try {
89
+ unlinkSync(path);
90
+ }
91
+ catch {
92
+ /* already gone — another cleaner won the race */
93
+ }
94
+ };
95
+ }
96
+ catch (e) {
97
+ const code = e.code;
98
+ if (code !== "EEXIST")
99
+ throw e;
100
+ // Lockfile exists. Check if it's stale.
101
+ try {
102
+ const st = statSync(path);
103
+ if (Date.now() - st.mtimeMs > STALE_LOCK_MS) {
104
+ // Orphaned — force-unlink and retry.
105
+ try {
106
+ unlinkSync(path);
107
+ }
108
+ catch {
109
+ /* another process just cleaned it; retry */
110
+ }
111
+ continue;
112
+ }
113
+ }
114
+ catch {
115
+ /* lockfile vanished between check and stat; just retry */
116
+ }
117
+ syncSleep(LOCK_RETRY_MS);
118
+ }
119
+ }
120
+ throw new Error(`Failed to acquire audit lock at ${path} within ${LOCK_TIMEOUT_MS}ms`);
121
+ }
29
122
  function auditDir() {
30
123
  // CONTEXTENGINE_HOME lets tests run against a temp dir without touching ~/.contextengine
31
124
  return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
@@ -62,16 +155,41 @@ function computeHash(prevHash, ts, event, actor, payload) {
62
155
  return createHash("sha256").update(canonical).digest("hex");
63
156
  }
64
157
  let cachedLastHash = null;
158
+ /** File size at our last successful write. If statSync(path).size differs
159
+ * on the next call, another process wrote in between → invalidate cache. */
160
+ let cachedSize = 0;
65
161
  export function appendAudit(event, payload, actor = "system") {
66
162
  ensureDir();
67
- if (cachedLastHash === null)
68
- cachedLastHash = readLastHash();
69
- const ts = new Date().toISOString();
70
- const hash = computeHash(cachedLastHash, ts, event, actor, payload);
71
- const record = { ts, event, actor, payload, prev_hash: cachedLastHash, hash };
72
- appendFileSync(auditPath(), JSON.stringify(record) + "\n");
73
- cachedLastHash = hash;
74
- return record;
163
+ const release = acquireLockSync();
164
+ try {
165
+ const path = auditPath();
166
+ // Cache validity check: if file size grew since OUR last write, another
167
+ // process appended re-read prev hash from disk (the cache is stale).
168
+ // Also handles first-ever call (cachedLastHash === null).
169
+ const currentSize = existsSync(path) ? statSync(path).size : 0;
170
+ if (cachedLastHash === null || currentSize !== cachedSize) {
171
+ cachedLastHash = readLastHash();
172
+ cachedSize = currentSize;
173
+ }
174
+ const ts = new Date().toISOString();
175
+ const hash = computeHash(cachedLastHash, ts, event, actor, payload);
176
+ const record = {
177
+ ts,
178
+ event,
179
+ actor,
180
+ payload,
181
+ prev_hash: cachedLastHash,
182
+ hash,
183
+ };
184
+ const line = JSON.stringify(record) + "\n";
185
+ appendFileSync(path, line);
186
+ cachedLastHash = hash;
187
+ cachedSize += Buffer.byteLength(line, "utf-8");
188
+ return record;
189
+ }
190
+ finally {
191
+ release();
192
+ }
75
193
  }
76
194
  export function readAuditLog() {
77
195
  const path = auditPath();
@@ -147,6 +265,7 @@ export function toCsv(records) {
147
265
  // Test-only — flush in-memory chain cache so a fresh path is re-read.
148
266
  export function resetCacheForTest() {
149
267
  cachedLastHash = null;
268
+ cachedSize = 0;
150
269
  }
151
270
  // Safe wrapper that never throws into hot paths. Use this from production
152
271
  // call sites so a failed audit append cannot break a learning save or
package/dist/cli.js CHANGED
@@ -503,6 +503,12 @@ async function runInit() {
503
503
  console.log(" 2. Start a Copilot chat — ContextEngine tools are now available");
504
504
  console.log(" 3. Run `contextengine score` to get your AI-readiness baseline");
505
505
  console.log("");
506
+ // Browser-capture nudge — fires once, never blocks init
507
+ const extensionSecretPath = join(homedir(), ".contextengine", "extension-secret");
508
+ if (!existsSync(extensionSecretPath)) {
509
+ console.log(" 💡 Next: run `opscontext init-extension-secret` to enable browser capture from Claude.ai and ChatGPT.");
510
+ console.log("");
511
+ }
506
512
  }
507
513
  finally {
508
514
  rl.close();
@@ -520,9 +526,10 @@ import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreP
520
526
  import { listLearnings, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
521
527
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
522
528
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
529
+ import { syncTierA, syncTierB, loadCommunityStore, communityRulesToChunks, mergeWithDedup, STORE_PATH as COMMUNITY_STORE_PATH, } from "./community-sync.js";
523
530
  import { readAuditLog, verifyChain, filterByRange, toCsv, } from "./audit.js";
524
531
  import { loadRepoPolicy, parsePolicy, formatPolicySummary, formatValidationErrors, repoPolicyPath, } from "./policy.js";
525
- import { getStagedFiles, runSecretScan, runDocCoverage, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, } from "./hooks.js";
532
+ import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, } from "./hooks.js";
526
533
  import { safeAppend } from "./audit.js";
527
534
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
528
535
  import { fileURLToPath } from "url";
@@ -537,7 +544,7 @@ const isNonInteractive = !process.stdin.isTTY || process.argv.includes("--yes")
537
544
  */
538
545
  async function initEngine() {
539
546
  const sources = loadSources();
540
- const chunks = ingestSources(sources);
547
+ let chunks = ingestSources(sources);
541
548
  const config = loadConfig();
542
549
  const projectDirs = loadProjectDirs();
543
550
  // Collect operational data
@@ -567,6 +574,16 @@ async function initEngine() {
567
574
  const projectNames = projectDirs.map((d) => d.name);
568
575
  const learningChunks = learningsToChunks(projectNames);
569
576
  chunks.push(...learningChunks);
577
+ // Inject community rules (Tier A public + Tier B Pro), deduped against
578
+ // the local Learnings Store so identical content doesn't double-emit.
579
+ // Network is NOT touched here — only the cached store at
580
+ // ~/.contextengine/community-learnings.json is read. Use the
581
+ // `sync-community-rules` subcommand to refresh the cache.
582
+ const communityChunks = communityRulesToChunks();
583
+ if (communityChunks.length > 0) {
584
+ const localChunks = chunks;
585
+ chunks = mergeWithDedup(localChunks, communityChunks);
586
+ }
570
587
  return { sources, chunks };
571
588
  }
572
589
  // ---------------------------------------------------------------------------
@@ -828,6 +845,9 @@ async function cliExportLearnings(args) {
828
845
  let category;
829
846
  let format = "json";
830
847
  let universalToo = false;
848
+ let tier;
849
+ let outputPath;
850
+ let review = false;
831
851
  for (let i = 0; i < args.length; i++) {
832
852
  const a = args[i];
833
853
  if ((a === "--project" || a === "-p") && args[i + 1]) {
@@ -851,25 +871,114 @@ async function cliExportLearnings(args) {
851
871
  universalToo = true;
852
872
  continue;
853
873
  }
874
+ if (a === "--tier" && args[i + 1]) {
875
+ const t = args[++i];
876
+ if (t !== "A" && t !== "B") {
877
+ console.error(`Unknown tier: ${t}. Supported: A, B.`);
878
+ process.exit(1);
879
+ }
880
+ tier = t;
881
+ continue;
882
+ }
883
+ if ((a === "--output" || a === "-o") && args[i + 1]) {
884
+ outputPath = args[++i];
885
+ continue;
886
+ }
887
+ if (a === "--review") {
888
+ review = true;
889
+ continue;
890
+ }
854
891
  if (a === "-h" || a === "--help") {
855
- console.log(`Usage: contextengine export-learnings [--project NAME] [--category CAT] [--format json|markdown] [--include-universal]
892
+ console.log(`Usage: contextengine export-learnings [--tier A|B] [--output PATH] [--review]
893
+ [--project NAME] [--category CAT]
894
+ [--format json|markdown] [--include-universal]
895
+
896
+ TWO MODES:
897
+
898
+ 1) Legacy raw export — no --tier flag. Writes the raw, unredacted store
899
+ filtered by --project / --category. Use only for personal backups or
900
+ when sharing inside a trusted team. NOT safe for public distribution.
901
+
902
+ 2) Tier-aware redacted export — pass --tier A or --tier B.
856
903
 
857
- Exports learnings as JSON or Markdown. Use --project to scope to a single
858
- project's learnings (essential for consultants who share artifacts with
859
- clients — the universal store mixes all projects together by default).
904
+ --tier A MIT-publishable subset. Pipeline:
905
+ a) Reuses the secret/PII patterns from
906
+ chrome-extension/src/content/shared/redact.ts
907
+ (cloud-vendor credential shapes, JWT, bearer
908
+ tokens, SSH private-key blocks, generic
909
+ credential-assignment patterns).
910
+ b) Strips PII: emails → [EMAIL], phone digits →
911
+ [PHONE], credit-card-shape → [CC].
912
+ c) Strips personal identifiers ("yannick", "yan",
913
+ "compr.ch/.fr") and project brand names
914
+ (CROWLR / KONIVE / INVOC / PLANK / COMPR / FASTPROD)
915
+ → [project]. Local /Users/yan/Projects/* paths
916
+ become /workspace/*. api.compr.ch → [SERVER].
917
+ d) Filters to allow-list categories
918
+ (debugging / tooling / git / frontend / testing /
919
+ dependencies / performance / other). Drops
920
+ security / deployment / infrastructure / devops
921
+ unless the entry is tagged 'safe' (manual vet).
922
+ e) Hashes IDs and project names — co-clustering
923
+ survives but local UUIDs and brand names do not.
924
+ Safety contract enforced by the LOCK comment block at
925
+ the top of src/community-export.ts. Do NOT relax the
926
+ redactor without re-running the full test suite.
860
927
 
861
- --project NAME Only learnings tagged with this project (case-insensitive)
928
+ --tier B Full corpus for PRO subscribers (api.compr.ch heartbeat).
929
+ Still redacted for secrets + PII (the redactor handles
930
+ that), but security/deployment learnings are INCLUDED
931
+ and original IDs are preserved (authenticated surface).
932
+
933
+ --output PATH Where to write the JSON. Default:
934
+ ./learnings-export-tierA.json / -tierB.json.
935
+ --review Open \$EDITOR (or vi) on the export JSON for a
936
+ manual trim before final write. Skipped if stdin
937
+ is not a TTY.
938
+
939
+ Legacy flags (no --tier):
940
+ --project NAME Only learnings tagged with this project
862
941
  --category CAT Only this category (deployment, security, etc.)
863
942
  --format json|markdown Output format (default: json)
864
- --include-universal Also include unscoped learnings (project=undefined)
865
- alongside the project-filtered ones
943
+ --include-universal Also include unscoped learnings
866
944
 
867
- Cross-client confidentiality: without --project, this exports the FULL store.
868
- Always use --project NAME when sharing exported learnings with anyone outside
869
- the owning project's team.`);
945
+ Cross-client confidentiality: without --tier and without --project, this
946
+ exports the FULL raw store. Always use --tier A for public sharing, or
947
+ --project NAME for trusted-team sharing inside one engagement.`);
870
948
  return;
871
949
  }
872
950
  }
951
+ // Tier-aware sanitized export path.
952
+ if (tier) {
953
+ const { exportLearnings, reviewLoop } = await import("./community-export.js");
954
+ const out = outputPath || join(process.cwd(), `learnings-export-tier${tier}.json`);
955
+ let result = exportLearnings({ tier, outputPath: out, review });
956
+ if (review) {
957
+ const edited = await reviewLoop(result.rules);
958
+ const payload = {
959
+ version: 1,
960
+ tier,
961
+ generatedAt: new Date().toISOString(),
962
+ count: edited.length,
963
+ dropped: result.dropped + (result.count - edited.length),
964
+ rules: edited,
965
+ };
966
+ writeFileSync(out, JSON.stringify(payload, null, 2) + "\n", "utf-8");
967
+ result = { ...result, count: edited.length, rules: edited };
968
+ }
969
+ console.log(`✅ Tier ${tier} export written to ${out}`);
970
+ console.log(` Included: ${result.count}`);
971
+ console.log(` Dropped: ${result.dropped}`);
972
+ if (tier === "A") {
973
+ console.log(` Note: Tier A is MIT-publishable. Safety guarantees enforced by`);
974
+ console.log(` the LOCK [COMMUNITY-EXPORT-SAFETY] block in src/community-export.ts.`);
975
+ }
976
+ else {
977
+ console.log(` Note: Tier B is PRO-only. Distribute via the api.compr.ch`);
978
+ console.log(` license heartbeat, never via a public mirror.`);
979
+ }
980
+ return;
981
+ }
873
982
  let all = listLearnings(category);
874
983
  if (project) {
875
984
  const lower = project.toLowerCase();
@@ -941,7 +1050,7 @@ async function cliAuditExport(args) {
941
1050
  continue;
942
1051
  }
943
1052
  if (a === "-h" || a === "--help") {
944
- console.log(`Usage: contextengine audit-export [--since ISO_DATE] [--until ISO_DATE] [--format jsonl|csv]\n\nExports the hash-chained audit log from ~/.contextengine/audit.log.\nCompliance use: SOC2 CC7.2, ISO 27001 A.12.4.1.`);
1053
+ console.log(`Usage: contextengine audit-export [--since ISO_DATE] [--until ISO_DATE] [--format jsonl|csv]\n\nExports the hash-chained audit log from ~/.contextengine/audit.log.\nCompliance use: produces evidence aligned with SOC 2 CC7.2 + ISO 27001 A.12.4.1\n(evidence artifacts — OpsContext is not itself certified; see docs/compliance/).`);
945
1054
  return;
946
1055
  }
947
1056
  }
@@ -1118,6 +1227,23 @@ Subcommands:
1118
1227
  doc-coverage For each policy.doc_coverage rule, check whether the
1119
1228
  commit touches matching source paths AND the required
1120
1229
  doc section is staged. Exit 1 on blocking violations.
1230
+ commit-message-required [MSG_FILE]
1231
+ For each policy.commit_message_required rule, check
1232
+ whether the commit touches matching source paths AND
1233
+ the commit message matches the required pattern. Exit 1
1234
+ on blocking violations. Bypass: include
1235
+ \`--skip-multi-agent-reason: <reason>\` in the commit
1236
+ body (≥ 20 chars, must contain whitespace, must be at
1237
+ the start of its own line) — bypass is recorded as a
1238
+ policy.skipped audit event (no exit-1).
1239
+
1240
+ MSG_FILE: path to the commit message file. Passed by the
1241
+ commit-msg git hook as \$1. Lookup order:
1242
+ 1. positional arg (preferred, what commit-msg passes)
1243
+ 2. COMMIT_MSG_FILE env (test injection)
1244
+ 3. .git/COMMIT_EDITMSG (legacy fallback — note this is
1245
+ UNRELIABLE from pre-commit; use the commit-msg
1246
+ hook lifecycle).
1121
1247
 
1122
1248
  Env:
1123
1249
  CE_JSON=1 Emit one-line JSON per check instead of human-readable
@@ -1209,6 +1335,69 @@ tamper-evident audit log at ~/.contextengine/audit.log.`);
1209
1335
  process.exit(1);
1210
1336
  return;
1211
1337
  }
1338
+ if (sub === "commit-message-required") {
1339
+ // Locate the commit-message file. Order:
1340
+ // 1. CLI positional arg (`contextengine hook commit-message-required
1341
+ // <path>`) — this is what the commit-msg git hook passes via $1.
1342
+ // Verifier P0 fix (2026-06-26): the previous implementation read
1343
+ // .git/COMMIT_EDITMSG from inside the PRE-COMMIT hook, but git
1344
+ // does not populate that file until AFTER pre-commit returns.
1345
+ // The check now runs from commit-msg, which gets the actual
1346
+ // message file path as its first argument.
1347
+ // 2. COMMIT_MSG_FILE env (for test injection / scripted callers).
1348
+ // 3. .git/COMMIT_EDITMSG in the repo root — backward-compat
1349
+ // fallback ONLY for legacy invocations. Will be empty / stale
1350
+ // when called from pre-commit; that's the documented footgun
1351
+ // this fix retires.
1352
+ const commitMsgFile = args[1] ||
1353
+ process.env.COMMIT_MSG_FILE ||
1354
+ join(repoRoot, ".git", "COMMIT_EDITMSG");
1355
+ let commitMessage = "";
1356
+ if (existsSync(commitMsgFile)) {
1357
+ try {
1358
+ commitMessage = readFileSync(commitMsgFile, "utf-8");
1359
+ }
1360
+ catch {
1361
+ // Fall through — empty message will fail any rule that fires,
1362
+ // surfacing the description to the user.
1363
+ }
1364
+ }
1365
+ const violations = runCommitMessageRequired(policy, stagedFiles, commitMessage);
1366
+ if (jsonMode) {
1367
+ process.stdout.write(formatCommitMessageViolationsJson(violations) + "\n");
1368
+ }
1369
+ else {
1370
+ console.log(formatCommitMessageViolations(violations));
1371
+ }
1372
+ let exitBlock = false;
1373
+ for (const v of violations) {
1374
+ if (v.kind === "bypass") {
1375
+ // Bypass IS the auditable opt-out. Record the reason; let the
1376
+ // commit proceed.
1377
+ safeAppend("policy.skipped", {
1378
+ check: "commit-message-required",
1379
+ rule_id: v.ruleId,
1380
+ matched_files: v.matchedFiles,
1381
+ pattern: v.pattern,
1382
+ bypass_reason: v.bypassReason,
1383
+ });
1384
+ continue;
1385
+ }
1386
+ // missing-pattern
1387
+ safeAppend("hook.block", {
1388
+ check: "commit-message-required",
1389
+ rule_id: v.ruleId,
1390
+ matched_files: v.matchedFiles,
1391
+ pattern: v.pattern,
1392
+ reason: "commit-message-pattern-not-matched",
1393
+ });
1394
+ if (v.severity === "block")
1395
+ exitBlock = true;
1396
+ }
1397
+ if (exitBlock)
1398
+ process.exit(1);
1399
+ return;
1400
+ }
1212
1401
  console.error(`Unknown hook subcommand: ${sub}. Try 'contextengine hook --help'.`);
1213
1402
  process.exit(1);
1214
1403
  }
@@ -1716,6 +1905,107 @@ async function cliImportLearnings(args) {
1716
1905
  }
1717
1906
  }
1718
1907
  // ---------------------------------------------------------------------------
1908
+ // CLI: sync-community-rules — fetch + cache community learnings
1909
+ // ---------------------------------------------------------------------------
1910
+ async function cliSyncCommunityRules(args) {
1911
+ let force = false;
1912
+ let tier = "all";
1913
+ for (let i = 0; i < args.length; i++) {
1914
+ const a = args[i];
1915
+ if (a === "--force" || a === "-f") {
1916
+ force = true;
1917
+ continue;
1918
+ }
1919
+ if (a === "--tier" && args[i + 1]) {
1920
+ const t = args[++i];
1921
+ if (t !== "A" && t !== "B" && t !== "all") {
1922
+ console.error(`Unknown tier: ${t}. Try A | B | all.`);
1923
+ process.exit(1);
1924
+ }
1925
+ tier = t;
1926
+ continue;
1927
+ }
1928
+ if (a === "-h" || a === "--help") {
1929
+ console.log(`Usage: opscontext sync-community-rules [--force] [--tier A|B|all]
1930
+
1931
+ Fetches community-contributed learnings into the local cache at
1932
+ ${COMMUNITY_STORE_PATH}.
1933
+
1934
+ Two tiers:
1935
+ Tier A (public) raw.githubusercontent.com — no license required
1936
+ Tier B (pro) api.compr.ch — requires an activated Pro license
1937
+
1938
+ Behavior:
1939
+ - Daily run recommended. Cron / launchd / periodic CI all fine — the
1940
+ sync is idempotent and ETag-guarded (304 = cheap no-op).
1941
+ - Network failures NEVER crash the search engine. On any HTTP error
1942
+ (timeout, DNS, 5xx, malformed JSON), the cached store is preserved
1943
+ and search continues to work offline.
1944
+ - Tier B is best-effort: with no license loaded (free tier) it is
1945
+ skipped silently with a note on stderr. On HTTP 401 / 403 the
1946
+ error is logged but no exception is thrown — your existing search
1947
+ keeps working.
1948
+ - Tier B responses are Ed25519-signed; an invalid signature causes
1949
+ the fetched payload to be discarded.
1950
+
1951
+ Flags:
1952
+ --force, -f Bypass the ETag cache; always re-fetch.
1953
+ --tier A|B|all Restrict the sync to one tier. Default: all.
1954
+
1955
+ After sync, the new rules are picked up automatically by search_context
1956
+ the next time the MCP server (re)indexes. Restart the server or run
1957
+ 'opscontext reindex' to force an immediate pickup.`);
1958
+ return;
1959
+ }
1960
+ }
1961
+ console.log(`\n🌐 Sync community rules (tier=${tier}, force=${force})\n`);
1962
+ if (tier === "A" || tier === "all") {
1963
+ const r = await syncTierA({ force });
1964
+ if (r.cached) {
1965
+ console.log(` Tier A — cached (no changes since last fetch)`);
1966
+ }
1967
+ else {
1968
+ console.log(` Tier A — fetched ${r.fetched} rule(s)`);
1969
+ }
1970
+ }
1971
+ if (tier === "B" || tier === "all") {
1972
+ // Resolve license through the activation module to keep auth shape
1973
+ // identical to /heartbeat — same machineId, same key.
1974
+ const licenseFile = join(homedir(), ".contextengine", "license.json");
1975
+ let token = null;
1976
+ if (existsSync(licenseFile)) {
1977
+ try {
1978
+ const data = JSON.parse(readFileSync(licenseFile, "utf-8"));
1979
+ token = typeof data.key === "string" ? data.key : null;
1980
+ }
1981
+ catch { /* malformed — treat as no license */ }
1982
+ }
1983
+ if (!token) {
1984
+ if (tier === "B") {
1985
+ console.error(` Tier B — no license loaded. Activate with: opscontext activate <key> <email>`);
1986
+ }
1987
+ else {
1988
+ console.log(` Tier B — skipped (no license loaded)`);
1989
+ }
1990
+ }
1991
+ else {
1992
+ const r = await syncTierB(token, { force });
1993
+ if (r.cached) {
1994
+ console.log(` Tier B — cached (no changes since last fetch)`);
1995
+ }
1996
+ else if (r.fetched === 0) {
1997
+ console.log(` Tier B — 0 rules (auth rejected or empty payload — see stderr)`);
1998
+ }
1999
+ else {
2000
+ console.log(` Tier B — fetched ${r.fetched} rule(s)`);
2001
+ }
2002
+ }
2003
+ }
2004
+ const store = loadCommunityStore();
2005
+ console.log(`\n Store now holds ${store.rules.length} total community rule(s).`);
2006
+ console.log(` Path: ${COMMUNITY_STORE_PATH}\n`);
2007
+ }
2008
+ // ---------------------------------------------------------------------------
1719
2009
  // CLI: stats — show live session stats from MCP server
1720
2010
  // ---------------------------------------------------------------------------
1721
2011
  function cliStats() {
@@ -1770,20 +2060,28 @@ Usage:
1770
2060
  contextengine save-learning <text> -c <category> Save a learning
1771
2061
  contextengine delete-learning <id> Delete a learning by ID
1772
2062
  contextengine import-learnings <file> [-c cat] [-p project] Bulk-import learnings
1773
- contextengine export-learnings [--project NAME] [--category CAT] [--format json|markdown] [--include-universal]
1774
- Export learnings (scope to one project for safe sharing)
2063
+ contextengine export-learnings [--tier A|B] [--output PATH] [--review] [--project NAME] [--category CAT] [--format json|markdown] [--include-universal]
2064
+ Export learnings. --tier A MIT-publishable redacted subset (LOCKed safety pipeline).
2065
+ --tier B → full PRO corpus (still redacted; original IDs preserved).
2066
+ (no --tier) → legacy raw export, NOT safe for public distribution.
1775
2067
  contextengine save-session <name> <key> <value> Save session context
1776
2068
  contextengine load-session <name> Restore session context
1777
2069
  contextengine list-sessions List all saved sessions
1778
2070
  contextengine delete-session <name> Delete a saved session
1779
2071
  contextengine end-session Pre-flight checklist (uncommitted changes, doc freshness)
1780
2072
  contextengine audit-export [--since DATE] [--until DATE] [--format jsonl|csv]
1781
- Export hash-chained audit log (SOC2/ISO27001 evidence)
2073
+ Export hash-chained audit log (evidence aligned with
2074
+ SOC 2 CC7.2 + ISO 27001 A.12.4.1 — not a certification)
1782
2075
  contextengine audit-verify Verify audit log chain integrity (tamper detection)
1783
2076
  contextengine policy <validate|show> [args]
1784
2077
  Author + validate the declarative .contextengine/policy.json
1785
2078
  contextengine init-extension-secret [--force]
1786
2079
  Generate ~/.contextengine/extension-secret for the browser ext
2080
+ contextengine install-autostart [--force]
2081
+ Install macOS LaunchAgent so MCP server auto-starts at login
2082
+ (uninstall-autostart / autostart-status — companion commands)
2083
+ contextengine install-claude-hook Wire Claude Code terminal sessions into the OpsContext audit log
2084
+ (UserPromptSubmit + PostToolUse + SessionStart hook entries)
1787
2085
  contextengine watch [--json] [--severity info|warn|critical] [--once] [--window SECONDS]
1788
2086
  Stream drift / loop / stuck-tool / fabrication alerts from the audit log
1789
2087
  contextengine emit-event <kind> <payload-json> [--actor NAME]
@@ -1796,6 +2094,10 @@ Usage:
1796
2094
  contextengine sync-claude-md [--path CLAUDE.md] [--dry-run]
1797
2095
  Refresh the OpsContext-managed block in CLAUDE.md
1798
2096
  (top learnings + policy summary + recent hook blocks)
2097
+ contextengine sync-community-rules [--force] [--tier A|B|all]
2098
+ Fetch community-contributed learnings (Tier A = GitHub
2099
+ public, Tier B = api.compr.ch Pro). Daily run recommended.
2100
+ Network failures fall back to cached store.
1799
2101
  contextengine score [project] [--html] [--no-save] AI-readiness score (Pro, writes SCORE.md)
1800
2102
  contextengine audit Run compliance audit (Pro)
1801
2103
  contextengine activate <key> <email> Activate a Pro license
@@ -2004,6 +2306,42 @@ else if (command === "init-extension-secret") {
2004
2306
  process.exit(1);
2005
2307
  });
2006
2308
  }
2309
+ else if (command === "install-autostart") {
2310
+ import("./install-autostart.js").then((m) => m.cliInstallAutostart(process.argv.slice(3))).catch((err) => {
2311
+ console.error("Error:", err instanceof Error ? err.message : err);
2312
+ process.exit(1);
2313
+ });
2314
+ }
2315
+ else if (command === "uninstall-autostart") {
2316
+ import("./install-autostart.js").then((m) => m.cliUninstallAutostart(process.argv.slice(3))).catch((err) => {
2317
+ console.error("Error:", err instanceof Error ? err.message : err);
2318
+ process.exit(1);
2319
+ });
2320
+ }
2321
+ else if (command === "autostart-status") {
2322
+ import("./install-autostart.js").then((m) => m.cliAutostartStatus(process.argv.slice(3))).catch((err) => {
2323
+ console.error("Error:", err instanceof Error ? err.message : err);
2324
+ process.exit(1);
2325
+ });
2326
+ }
2327
+ else if (command === "install-claude-hook") {
2328
+ import("./install-claude-hook.js").then((m) => m.cliInstallClaudeHook(process.argv.slice(3))).catch((err) => {
2329
+ console.error("Error:", err instanceof Error ? err.message : err);
2330
+ process.exit(1);
2331
+ });
2332
+ }
2333
+ else if (command === "uninstall-claude-hook") {
2334
+ import("./install-claude-hook.js").then((m) => m.cliUninstallClaudeHook(process.argv.slice(3))).catch((err) => {
2335
+ console.error("Error:", err instanceof Error ? err.message : err);
2336
+ process.exit(1);
2337
+ });
2338
+ }
2339
+ else if (command === "sync-community-rules") {
2340
+ cliSyncCommunityRules(process.argv.slice(3)).catch((err) => {
2341
+ console.error("Error:", err);
2342
+ process.exit(1);
2343
+ });
2344
+ }
2007
2345
  else if (command === "stats") {
2008
2346
  cliStats();
2009
2347
  }