@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/CHANGELOG.md +150 -1
- package/README.md +73 -8
- package/defaults/claude-code-hook.sh +100 -0
- package/dist/activation.js +29 -7
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +133 -14
- package/dist/cli.js +354 -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.d.ts +4 -0
- package/dist/install-autostart.js +356 -0
- package/dist/install-claude-hook.d.ts +3 -0
- package/dist/install-claude-hook.js +180 -0
- 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
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 [--
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
-
|
|
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
|
|
865
|
-
alongside the project-filtered ones
|
|
943
|
+
--include-universal Also include unscoped learnings
|
|
866
944
|
|
|
867
|
-
Cross-client confidentiality: without --project, this
|
|
868
|
-
Always use --
|
|
869
|
-
|
|
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:
|
|
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
|
|
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 (
|
|
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
|
}
|