@compr/opscontext-mcp 2.1.3 → 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.d.ts CHANGED
@@ -102,9 +102,27 @@ export interface ScoreCheck {
102
102
  category: string;
103
103
  points: number;
104
104
  maxPoints: number;
105
- status: "pass" | "partial" | "fail";
105
+ status: "pass" | "partial" | "fail" | "unknown";
106
106
  detail: string;
107
107
  }
108
+ /**
109
+ * šŸ”’ LOCKED [ABSENCE-IS-NOT-A-VERDICT] — 2026-08-13
110
+ * ā›” NEVER emit "pass" or "fail" for a condition the check could not actually determine,
111
+ * and NEVER write a detail string that hides WHICH locations were inspected.
112
+ * WHY: three live bugs, all the same shape — the scorer wrote *absence of evidence* down as
113
+ * a *verdict*, and the verdict pointed at the wrong fix.
114
+ * 1. `copilot-instructions.md`/`SKILLS.md` looked only in .github/ and reported files that
115
+ * existed at the repo root as "Missing" (fixed ffa5914, [DOC-PATH-DUAL]).
116
+ * 2. `Git hooks` reported "No hooks — consider auto-push" for a hook that WAS installed but
117
+ * whose symlink target had been deleted. existsSync() follows symlinks, so dangling and
118
+ * absent are indistinguishable. CE's own Drive backup drifted while the row said
119
+ * "consider auto-push" — advice for a problem the repo did not have.
120
+ * 3. `Secrets exposure` awarded a full 6/6 PASS with the detail "No .env or not a git repo" —
121
+ * full marks for a state it openly could not distinguish.
122
+ * FIX: a check that cannot determine its condition emits status "unknown" (0 points, rendered ā”,
123
+ * excluded from the remediation list — it is a gap in the CHECK, not in the project). Every
124
+ * pass/fail detail must name what was inspected. Absence is a measurement, not a decision.
125
+ */
108
126
  export interface ProjectScore {
109
127
  project: string;
110
128
  path: string;
@@ -114,6 +132,14 @@ export interface ProjectScore {
114
132
  grade: string;
115
133
  checks: ScoreCheck[];
116
134
  }
135
+ export interface CanaryResult {
136
+ ok: boolean;
137
+ /** Human-readable deviations from the pinned expectation. Empty when ok. */
138
+ deviations: string[];
139
+ /** True when the canary itself could not be built — an unknown, not a failure. */
140
+ inconclusive: boolean;
141
+ }
142
+ export declare function runScoreCanary(): CanaryResult;
117
143
  /**
118
144
  * Score a project's AI-readiness (0-100%).
119
145
  * Checks how well-prepared a project is for AI coding agents.
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/cli.js CHANGED
@@ -522,7 +522,7 @@ import { ingestSources } from "./ingest.js";
522
522
  import { searchChunks } from "./search.js";
523
523
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
524
524
  import { scanCodeDir } from "./code-chunker.js";
525
- import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
525
+ import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
526
526
  import { listLearnings, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
527
527
  import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
528
528
  import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
@@ -703,6 +703,19 @@ async function cliScore(project, html = false, save = true) {
703
703
  console.error(gate);
704
704
  process.exit(1);
705
705
  }
706
+ // [SCORE-CANARY] — every health signal must read exactly as pinned before we are allowed to
707
+ // write a single SCORE.md. A drifting scorer that silently rewrites 37 reports is the failure
708
+ // this blocks; it does not test for one known bug, it refuses to proceed on ANY deviation.
709
+ const canary = runScoreCanary();
710
+ if (!canary.ok) {
711
+ console.error("\n🚨 Scoring canary FAILED — refusing to write any SCORE.md.\n");
712
+ for (const d of canary.deviations)
713
+ console.error(` • ${d}`);
714
+ console.error(canary.inconclusive
715
+ ? "\nThe canary fixture could not be built, so the scorer is unverified. This is an unknown, not a pass.\n"
716
+ : "\nThe scorer no longer behaves as pinned. Fix the deviation or update the pin deliberately.\n");
717
+ process.exit(1);
718
+ }
706
719
  const projectDirs = loadProjectDirs();
707
720
  let scores;
708
721
  if (project) {
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ const DEFAULT_PATTERNS = [
7
7
  ".github/copilot-instructions.md",
8
8
  ".github/instructions/copilot-instructions.md",
9
9
  ".github/SKILLS.md",
10
+ "SKILLS.md", // `contextengine init` writes SKILLS.md at the repo root — index both
10
11
  // Claude Code
11
12
  "CLAUDE.md",
12
13
  // Cursor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",