@compr/opscontext-mcp 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execSync } from "child_process";
2
- import { readFileSync, existsSync, readdirSync, lstatSync } from "fs";
2
+ import { readFileSync, existsSync, readdirSync, lstatSync, readlinkSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs";
3
3
  import { resolve, join, dirname } from "path";
4
+ import { tmpdir } from "os";
4
5
  import { fileURLToPath } from "url";
5
6
  // Read version from package.json at module load
6
7
  const __agents_dirname = dirname(fileURLToPath(import.meta.url));
@@ -14,16 +15,34 @@ catch { /* fallback */ }
14
15
  // Helpers
15
16
  // ---------------------------------------------------------------------------
16
17
  function exec(cmd, cwd) {
18
+ return execChecked(cmd, cwd).output;
19
+ }
20
+ /**
21
+ * exec() that distinguishes "ran, produced nothing" from "did not run".
22
+ *
23
+ * 🔒 LOCKED [EXEC-FAILURE-IS-NOT-EMPTY] — 2026-08-13
24
+ * ⛔ NEVER let a caller treat exec()'s "" as a factual answer for a check that can FAIL SAFE.
25
+ * WHY: exec() returns "" for every failure mode — command not found, not a git repo, timeout,
26
+ * permission denied — indistinguishable from a successful empty result. The `Secrets exposure`
27
+ * check read `git ls-files .env` → "" as ".env is not tracked" and awarded a full 6/6 PASS.
28
+ * A security check that hands out full marks precisely when it cannot run is worse than one
29
+ * that fails: it manufactures reassurance. Found by [SCORE-CANARY] on its first run, against
30
+ * a fixture with a .git directory that is not a real repository.
31
+ * FIX: security- and correctness-relevant callers use execChecked() and emit "unknown" when
32
+ * ok === false. Absence of output is a measurement, not a decision.
33
+ */
34
+ function execChecked(cmd, cwd) {
17
35
  try {
18
- return execSync(cmd, {
36
+ const output = execSync(cmd, {
19
37
  cwd,
20
38
  encoding: "utf-8",
21
39
  timeout: 10_000,
22
40
  stdio: ["pipe", "pipe", "pipe"],
23
41
  }).trim();
42
+ return { ok: true, output };
24
43
  }
25
44
  catch {
26
- return "";
45
+ return { ok: false, output: "" };
27
46
  }
28
47
  }
29
48
  /**
@@ -37,6 +56,51 @@ function isSymlink(filePath) {
37
56
  return false;
38
57
  }
39
58
  }
59
+ /**
60
+ * Status icon for a score check. "unknown" is ❔ — the check could not determine its
61
+ * condition, which is a gap in the audit, not a verdict on the project.
62
+ * See [ABSENCE-IS-NOT-A-VERDICT].
63
+ */
64
+ function statusIcon(status) {
65
+ if (status === "pass")
66
+ return "✅";
67
+ if (status === "partial")
68
+ return "🟡";
69
+ if (status === "unknown")
70
+ return "❔";
71
+ return "❌";
72
+ }
73
+ /** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
74
+ function readlinkSafe(filePath) {
75
+ try {
76
+ return readlinkSync(filePath);
77
+ }
78
+ catch {
79
+ return "?";
80
+ }
81
+ }
82
+ /**
83
+ * Resolve an agent doc that may live in `.github/` or at the repo root.
84
+ * Returns the first location that exists (`.github/` wins), or null.
85
+ *
86
+ * 🔒 LOCKED [DOC-PATH-DUAL] — 2026-08-07
87
+ * ⛔ NEVER collapse a caller back to a single hardcoded join(p, ".github", file).
88
+ * WHY: the scorer read `.github/` only, while `contextengine init` writes SKILLS.md
89
+ * at the repo root and the generated pre-commit hook accepts both. Every project
90
+ * keeping these at root scored 0/10 + 0/3 for files that existed, so SCORE.md
91
+ * rows had to be hand-corrected after every run (caught 2026-08-07 on this repo).
92
+ * FIX: score whichever location exists — `.github/` first (Copilot's official read
93
+ * path), repo root second. Presence is what earns the points, not the directory.
94
+ */
95
+ function resolveDocPath(projectPath, file) {
96
+ const inGithub = join(projectPath, ".github", file);
97
+ if (existsSync(inGithub))
98
+ return { path: inGithub, rel: `.github/${file}` };
99
+ const atRoot = join(projectPath, file);
100
+ if (existsSync(atRoot))
101
+ return { path: atRoot, rel: file };
102
+ return null;
103
+ }
40
104
  /**
41
105
  * Check if an ESLint config is actually backed by installed packages.
42
106
  * Returns true if at least `eslint` itself is installed in node_modules.
@@ -909,6 +973,80 @@ export function formatPortMap(ports, conflicts) {
909
973
  }
910
974
  return lines.join("\n");
911
975
  }
976
+ /**
977
+ * 🔒 LOCKED [SCORE-CANARY] — 2026-08-13
978
+ * ⛔ NEVER weaken this to "test one known bug", and never let a deviation be a warning only.
979
+ * WHY: every guard in this file was written AFTER the failure it prevents — the doc-path bug, the
980
+ * dangling-hook bug, the shrinking-denominator bug. A wall of named guards is a museum of past
981
+ * mistakes: valuable, but it does nothing about the next one. The canary is the only check here
982
+ * that can catch a failure nobody predicted, because it does not test for a specific defect —
983
+ * it demands that EVERY health signal still reads exactly as pinned before the scorer is
984
+ * allowed to write a single file.
985
+ * FIX: build a synthetic project whose every awkward case is represented (doc at repo root, doc in
986
+ * .github/, dangling hook symlink, .env with no git, no package manager), score it, and compare
987
+ * against the pinned expectation. Any drift blocks the run. If the fixture cannot be built at
988
+ * all, that is `inconclusive` — an unknown, per [ABSENCE-IS-NOT-A-VERDICT] — and must not be
989
+ * silently treated as a pass.
990
+ *
991
+ * Runs in production on every scoring run, not just in CI. Costs a few milliseconds.
992
+ */
993
+ /**
994
+ * True while the canary fixture is being scored. The fixture deliberately leaves 9 points
995
+ * unassessable (no package manager, no .gitignore), so its invariant log line is EXPECTED —
996
+ * printing it on every run would train the reader to ignore the warning that matters.
997
+ */
998
+ let canaryRunning = false;
999
+ export function runScoreCanary() {
1000
+ let dir = null;
1001
+ canaryRunning = true;
1002
+ try {
1003
+ dir = mkdtempSync(join(tmpdir(), "ce-canary-"));
1004
+ // Deliberately awkward, all legal, each one a past or plausible failure:
1005
+ mkdirSync(join(dir, ".github"), { recursive: true });
1006
+ // doc in .github/ — 60 lines, should score the full 10
1007
+ writeFileSync(join(dir, ".github", "copilot-instructions.md"), Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join("\n"));
1008
+ // doc at repo ROOT — the [DOC-PATH-DUAL] case, should score the full 3
1009
+ writeFileSync(join(dir, "SKILLS.md"), Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n"));
1010
+ // hook installed but BROKEN — the 2026-06-10 case, must read as fail-broken not fail-absent
1011
+ mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
1012
+ symlinkSync(join(dir, "hooks", "post-commit"), join(dir, ".git", "hooks", "post-commit"));
1013
+ // .env with no git repo — unverifiable, must be `unknown` and must NOT collect points
1014
+ writeFileSync(join(dir, ".env"), "SECRET=canary\n");
1015
+ const score = scoreProject({ name: "ce-canary", path: dir });
1016
+ const by = (n) => score.checks.find(c => c.name === n);
1017
+ const deviations = [];
1018
+ const expect = (label, actual, wanted) => {
1019
+ if (actual !== wanted)
1020
+ deviations.push(`${label}: expected ${JSON.stringify(wanted)}, got ${JSON.stringify(actual)}`);
1021
+ };
1022
+ expect("copilot-instructions.md points", by("copilot-instructions.md")?.points, 10);
1023
+ expect("SKILLS.md points (repo root)", by("SKILLS.md")?.points, 3);
1024
+ expect("Git hooks status", by("Git hooks")?.status, "fail");
1025
+ expect("Git hooks names the break", by("Git hooks")?.detail.includes("BROKEN"), true);
1026
+ expect("Secrets exposure status", by("Secrets exposure")?.status, "unknown");
1027
+ expect("Secrets exposure points", by("Secrets exposure")?.points, 0);
1028
+ expect("denominator pinned to 100", score.maxScore, 100);
1029
+ expect("maxPoints sum to 100", score.checks.reduce((s, c) => s + c.maxPoints, 0), 100);
1030
+ expect("percentage matches score/100", score.percentage, Math.round(score.score));
1031
+ return { ok: deviations.length === 0, deviations, inconclusive: false };
1032
+ }
1033
+ catch (err) {
1034
+ return {
1035
+ ok: false,
1036
+ inconclusive: true,
1037
+ deviations: [`canary fixture could not be built: ${err instanceof Error ? err.message : String(err)}`],
1038
+ };
1039
+ }
1040
+ finally {
1041
+ canaryRunning = false;
1042
+ if (dir) {
1043
+ try {
1044
+ rmSync(dir, { recursive: true, force: true });
1045
+ }
1046
+ catch { /* temp dir cleanup is best-effort */ }
1047
+ }
1048
+ }
1049
+ }
912
1050
  /**
913
1051
  * Score a project's AI-readiness (0-100%).
914
1052
  * Checks how well-prepared a project is for AI coding agents.
@@ -917,27 +1055,28 @@ export function scoreProject(dir) {
917
1055
  const checks = [];
918
1056
  const p = dir.path;
919
1057
  // --- Documentation (30 points max) ---
920
- // copilot-instructions.md (10 pts)
921
- const copilotPath = join(p, ".github", "copilot-instructions.md");
922
- if (existsSync(copilotPath)) {
923
- const copilotIsSymlink = isSymlink(copilotPath);
924
- const content = readFileSync(copilotPath, "utf-8");
1058
+ // copilot-instructions.md (10 pts) — .github/ or repo root, see resolveDocPath LOCK
1059
+ const copilot = resolveDocPath(p, "copilot-instructions.md");
1060
+ if (copilot) {
1061
+ const copilotIsSymlink = isSymlink(copilot.path);
1062
+ const content = readFileSync(copilot.path, "utf-8");
925
1063
  const lines = content.split("\n").length;
1064
+ const at = `${lines} lines (${copilot.rel})`;
926
1065
  if (copilotIsSymlink) {
927
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 4, maxPoints: 10, status: "partial", detail: `⚠ Symlink (${lines} lines) — should be a real file with project-specific context` });
1066
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 4, maxPoints: 10, status: "partial", detail: `⚠ Symlink ${at} — should be a real file with project-specific context` });
928
1067
  }
929
1068
  else if (lines > 50) {
930
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 10, maxPoints: 10, status: "pass", detail: `${lines} lines — comprehensive` });
1069
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 10, maxPoints: 10, status: "pass", detail: `${at} — comprehensive` });
931
1070
  }
932
1071
  else if (lines > 15) {
933
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 10, status: "partial", detail: `${lines} lines — could be more detailed` });
1072
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 10, status: "partial", detail: `${at} — could be more detailed` });
934
1073
  }
935
1074
  else {
936
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 3, maxPoints: 10, status: "partial", detail: `${lines} lines — too sparse, add architecture, rules, key files` });
1075
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 3, maxPoints: 10, status: "partial", detail: `${at} — too sparse, add architecture, rules, key files` });
937
1076
  }
938
1077
  }
939
1078
  else {
940
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 10, status: "fail", detail: "Missing — AI agents lack project context" });
1079
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 10, status: "fail", detail: "Missing from .github/ and repo root — AI agents lack project context" });
941
1080
  }
942
1081
  // README.md (8 pts)
943
1082
  const readmePath = join(p, "README.md");
@@ -975,20 +1114,20 @@ export function scoreProject(dir) {
975
1114
  else {
976
1115
  checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "No CLAUDE.md, .cursorrules, or AGENTS.md" });
977
1116
  }
978
- // .github/SKILLS.md (3 pts)
979
- const skillsPath = join(p, ".github", "SKILLS.md");
980
- if (existsSync(skillsPath)) {
981
- const skillsContent = readFileSync(skillsPath, "utf-8");
1117
+ // SKILLS.md (3 pts) — .github/ or repo root, see resolveDocPath LOCK
1118
+ const skills = resolveDocPath(p, "SKILLS.md");
1119
+ if (skills) {
1120
+ const skillsContent = readFileSync(skills.path, "utf-8");
982
1121
  const skillsLines = skillsContent.split("\n").length;
983
- if (skillsLines > 10 && !isSymlink(skillsPath)) {
984
- checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines` });
1122
+ if (skillsLines > 10 && !isSymlink(skills.path)) {
1123
+ checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines (${skills.rel})` });
985
1124
  }
986
1125
  else {
987
- checks.push({ name: "SKILLS.md", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `${skillsLines} lines${isSymlink(skillsPath) ? " (symlink)" : ""} — add real skill descriptions` });
1126
+ checks.push({ name: "SKILLS.md", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `${skillsLines} lines (${skills.rel})${isSymlink(skills.path) ? " symlink" : ""} — add real skill descriptions` });
988
1127
  }
989
1128
  }
990
1129
  else {
991
- checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing — agents can't discover capabilities" });
1130
+ checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing from .github/ and repo root — agents can't discover capabilities" });
992
1131
  }
993
1132
  // .env.example (3 pts) — validates actual content, not just existence
994
1133
  const envExamplePath = join(p, ".env.example");
@@ -1032,15 +1171,22 @@ export function scoreProject(dir) {
1032
1171
  else {
1033
1172
  checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "Missing" });
1034
1173
  }
1035
- // Git hooks (5 pts)
1036
- const hookDir = join(p, "hooks");
1037
- const gitHookDir = join(p, ".git", "hooks");
1038
- const hasPostCommit = existsSync(join(hookDir, "post-commit")) || existsSync(join(gitHookDir, "post-commit"));
1039
- if (hasPostCommit) {
1040
- checks.push({ name: "Git hooks", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "post-commit hook configured" });
1174
+ // Git hooks (5 pts) — see [ABSENCE-IS-NOT-A-VERDICT]: "installed but broken" is not "not installed"
1175
+ const repoHook = join(p, "hooks", "post-commit");
1176
+ const gitHook = join(p, ".git", "hooks", "post-commit");
1177
+ // existsSync() follows symlinks — a dangling link reads as absent. Check the link itself first.
1178
+ const dangling = [repoHook, gitHook].filter(h => isSymlink(h) && !existsSync(h));
1179
+ const live = [repoHook, gitHook].filter(h => existsSync(h));
1180
+ if (live.length > 0) {
1181
+ const where = live.map(h => h === gitHook ? ".git/hooks/post-commit" : "hooks/post-commit").join(" + ");
1182
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: `post-commit hook configured (${where})` });
1183
+ }
1184
+ else if (dangling.length > 0) {
1185
+ const broken = dangling.map(h => `${h === gitHook ? ".git/hooks" : "hooks"}/post-commit → ${readlinkSafe(h)}`).join(", ");
1186
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: `⚠ Hook installed but BROKEN — dangling symlink: ${broken}. Auto-push is silently dead; restore the target, don't re-install` });
1041
1187
  }
1042
1188
  else {
1043
- checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "No hooks — consider auto-push" });
1189
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "No post-commit hook at hooks/ or .git/hooks/ — consider auto-push" });
1044
1190
  }
1045
1191
  // Docker / containerization (5 pts)
1046
1192
  // Context-aware: only award points if Docker is actually used for deployment.
@@ -1263,17 +1409,28 @@ export function scoreProject(dir) {
1263
1409
  checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 8, status: "fail", detail: "No .gitignore at all" });
1264
1410
  }
1265
1411
  // No secrets in tracked files (6 pts)
1266
- if (existsSync(join(p, ".env")) && existsSync(join(p, ".git"))) {
1267
- const tracked = exec("git ls-files .env", p);
1268
- if (tracked === ".env") {
1412
+ // [ABSENCE-IS-NOT-A-VERDICT]: "no .env" and "can't run git here" are different states.
1413
+ // The second is unverifiable and must NOT collect a 6/6 pass.
1414
+ const hasEnv = existsSync(join(p, ".env"));
1415
+ const hasGit = existsSync(join(p, ".git"));
1416
+ if (hasEnv && hasGit) {
1417
+ // [EXEC-FAILURE-IS-NOT-EMPTY]: a failed `git ls-files` must never read as "not tracked".
1418
+ const { ok, output: tracked } = execChecked("git ls-files .env", p);
1419
+ if (!ok) {
1420
+ checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "unknown", detail: "❔ .env present but `git ls-files` failed here — cannot verify whether it is tracked" });
1421
+ }
1422
+ else if (tracked === ".env") {
1269
1423
  checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "fail", detail: ".env is tracked by git!" });
1270
1424
  }
1271
1425
  else {
1272
- checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: ".env not tracked" });
1426
+ checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: ".env present and not tracked by git" });
1273
1427
  }
1274
1428
  }
1429
+ else if (!hasEnv) {
1430
+ checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: "No .env at repo root — nothing to leak" });
1431
+ }
1275
1432
  else {
1276
- checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: "No .env or not a git repo" });
1433
+ checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "unknown", detail: " .env exists but this is not a git repo — cannot verify whether it is tracked" });
1277
1434
  }
1278
1435
  // Lockfile present (3 pts)
1279
1436
  const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock"];
@@ -1298,8 +1455,37 @@ export function scoreProject(dir) {
1298
1455
  }
1299
1456
  }
1300
1457
  // --- Calculate totals ---
1458
+ // 🔒 LOCKED [SCORE-ARITHMETIC-INVARIANT] — 2026-08-13
1459
+ // ⛔ NEVER compute the percentage against a summed maxScore without checking it equals 100 first.
1460
+ // WHY: several checks only push a result inside an `if` (e.g. "Deps gitignored" is skipped
1461
+ // entirely when a project has no .gitignore). A skipped check silently SHRANK the
1462
+ // denominator, so a project got a percentage out of 97 while every report — and every
1463
+ // cross-project comparison — presented it as if it were out of 100. Nothing was wrong on
1464
+ // screen; the number was just quietly measuring something else. A missing check is an
1465
+ // unknown, not a smaller exam.
1466
+ // FIX: the denominator is pinned to EXPECTED_MAX_SCORE. Any shortfall becomes a visible
1467
+ // "Scoring completeness" unknown row worth the missing points, and is logged to stderr
1468
+ // (never stdout — MCP protocol stream). Exact, free, unarguable, runs every time.
1469
+ const EXPECTED_MAX_SCORE = 100;
1301
1470
  const totalScore = checks.reduce((sum, c) => sum + c.points, 0);
1302
- const maxScore = checks.reduce((sum, c) => sum + c.maxPoints, 0);
1471
+ const emittedMax = checks.reduce((sum, c) => sum + c.maxPoints, 0);
1472
+ if (emittedMax !== EXPECTED_MAX_SCORE) {
1473
+ const gap = EXPECTED_MAX_SCORE - emittedMax;
1474
+ if (!canaryRunning) {
1475
+ console.error(`[contextengine] ⚠ scoring invariant: ${dir.name} emitted ${emittedMax}/${EXPECTED_MAX_SCORE} max points (${gap > 0 ? "missing" : "excess"} ${Math.abs(gap)}) — see the "Could Not Be Verified" section`);
1476
+ }
1477
+ if (gap > 0) {
1478
+ checks.push({
1479
+ name: "Scoring completeness",
1480
+ category: "Meta",
1481
+ points: 0,
1482
+ maxPoints: gap,
1483
+ status: "unknown",
1484
+ detail: `❔ ${gap} point(s) never assessed — a check did not run for this project. Scored against the full ${EXPECTED_MAX_SCORE}, so this is a gap in the audit, not a penalty you can fix`,
1485
+ });
1486
+ }
1487
+ }
1488
+ const maxScore = Math.max(EXPECTED_MAX_SCORE, emittedMax);
1303
1489
  const percentage = Math.round((totalScore / maxScore) * 100);
1304
1490
  let grade;
1305
1491
  if (percentage >= 90)
@@ -1372,7 +1558,7 @@ export function formatScoreReport(scores) {
1372
1558
  lines.push("| Check | Category | Score | Status | Detail |");
1373
1559
  lines.push("|-------|----------|-------|--------|--------|");
1374
1560
  for (const c of s.checks) {
1375
- const icon = c.status === "pass" ? "✅" : c.status === "partial" ? "🟡" : "❌";
1561
+ const icon = statusIcon(c.status);
1376
1562
  lines.push(`| ${c.name} | ${c.category} | ${c.points}/${c.maxPoints} | ${icon} | ${c.detail} |`);
1377
1563
  }
1378
1564
  lines.push("");
@@ -1414,7 +1600,7 @@ export function generateProjectScoreMD(score) {
1414
1600
  lines.push("| Check | Category | Score | Max | Status | Detail |");
1415
1601
  lines.push("|---|---|---|---|---|---|");
1416
1602
  for (const c of score.checks) {
1417
- const icon = c.status === "pass" ? "✅" : c.status === "partial" ? "🟡" : "❌";
1603
+ const icon = statusIcon(c.status);
1418
1604
  lines.push(`| ${c.name} | ${c.category} | ${c.points} | ${c.maxPoints} | ${icon} | ${c.detail} |`);
1419
1605
  }
1420
1606
  // Failures and improvements
@@ -1429,6 +1615,16 @@ export function generateProjectScoreMD(score) {
1429
1615
  lines.push(`- 🟡 **${p.name}**: ${p.detail}`);
1430
1616
  }
1431
1617
  }
1618
+ // Unverifiable — kept OUT of "Improvements Needed" on purpose. These are checks that could not
1619
+ // reach a verdict; listing them as project failures is what [ABSENCE-IS-NOT-A-VERDICT] forbids.
1620
+ const unknowns = score.checks.filter(c => c.status === "unknown");
1621
+ if (unknowns.length > 0) {
1622
+ lines.push("\n## Could Not Be Verified\n");
1623
+ lines.push("_Gaps in the audit, not defects in the project — the scorer could not determine these._\n");
1624
+ for (const u of unknowns) {
1625
+ lines.push(`- ❔ **${u.name}**: ${u.detail}`);
1626
+ }
1627
+ }
1432
1628
  lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
1433
1629
  return lines.join("\n");
1434
1630
  }
@@ -1449,13 +1645,8 @@ export function generateScoreHTML(scores) {
1449
1645
  return "#f97316";
1450
1646
  return "#ef4444";
1451
1647
  }
1452
- function statusIcon(status) {
1453
- if (status === "pass")
1454
- return "✅";
1455
- if (status === "partial")
1456
- return "🟡";
1457
- return "❌";
1458
- }
1648
+ // statusIcon is module-level — see [ABSENCE-IS-NOT-A-VERDICT]. A local copy here previously
1649
+ // rendered "unknown" as ❌, which is the exact conflation this work removed.
1459
1650
  function categoryByScore(checks) {
1460
1651
  const m = new Map();
1461
1652
  for (const c of checks) {
package/dist/audit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired";
1
+ export type AuditEvent = "learning.save" | "learning.delete" | "learning.import" | "learning.export" | "session.save" | "session.delete" | "activation.activate" | "activation.deactivate" | "activation.heartbeat" | "activation.signature_reject" | "activation.legacy_signature" | "firewall.escalate" | "hook.block" | "hook.bypass" | "policy.skipped" | "browser.prompt" | "browser.response" | "browser.tool_call" | "browser.session_start" | "browser.session_end" | "browser.capture_miss" | "vscode.prompt_submit" | "vscode.tool_call" | "vscode.session_start" | "drift.detected" | "notification.fired" | "community.sync_ok" | "community.sync_error";
2
2
  export interface AuditRecord {
3
3
  ts: string;
4
4
  event: AuditEvent;
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