@compr/opscontext-mcp 2.2.0 → 2.3.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
@@ -104,6 +104,11 @@ export interface ScoreCheck {
104
104
  maxPoints: number;
105
105
  status: "pass" | "partial" | "fail" | "unknown";
106
106
  detail: string;
107
+ /**
108
+ * A failure here is disqualifying: it caps the overall grade regardless of the other points.
109
+ * Reserved for actual secret exposure — see [SECURITY-IS-DISQUALIFYING].
110
+ */
111
+ disqualifying?: boolean;
107
112
  }
108
113
  /**
109
114
  * 🔒 LOCKED [ABSENCE-IS-NOT-A-VERDICT] — 2026-08-13
package/dist/agents.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { execSync } from "child_process";
2
- import { readFileSync, existsSync, readdirSync, lstatSync, readlinkSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs";
2
+ import { readFileSync, existsSync, readdirSync, statSync, lstatSync, readlinkSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs";
3
3
  import { resolve, join, dirname } from "path";
4
4
  import { tmpdir } from "os";
5
5
  import { fileURLToPath } from "url";
6
+ import { RUBRIC } from "./rubric.js";
6
7
  // Read version from package.json at module load
7
8
  const __agents_dirname = dirname(fileURLToPath(import.meta.url));
8
9
  let AGENTS_VERSION = "1.23.0";
@@ -70,6 +71,21 @@ function statusIcon(status) {
70
71
  return "❔";
71
72
  return "❌";
72
73
  }
74
+ /**
75
+ * Topics an agent doc must cover to be useful. Matched case-insensitively anywhere in the
76
+ * document, so a project's own heading vocabulary still counts — this rewards covering the
77
+ * subject, not adopting our wording. See [SCORE-CONTENT-NOT-LENGTH].
78
+ */
79
+ const AGENT_DOC_TOPICS = [
80
+ { label: "architecture", patterns: /\b(architecture|structure|layout|stack|components?|modules?)\b/i },
81
+ { label: "commands", patterns: /\b(commands?|scripts?|npm run|yarn |pnpm |make |getting started|setup|install)\b/i },
82
+ { label: "rules", patterns: /\b(rules?|conventions?|guidelines?|standards?|policy|policies|do not|never|always)\b/i },
83
+ { label: "key files", patterns: /\b(key files?|important files?|entry ?point|file (map|tree)|directory)\b/i },
84
+ ];
85
+ /** Labels of the topics this document covers. */
86
+ function matchDocTopics(content, topics) {
87
+ return topics.filter(t => t.patterns.test(content)).map(t => t.label);
88
+ }
73
89
  /** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
74
90
  function readlinkSafe(filePath) {
75
91
  try {
@@ -1003,8 +1019,15 @@ export function runScoreCanary() {
1003
1019
  dir = mkdtempSync(join(tmpdir(), "ce-canary-"));
1004
1020
  // Deliberately awkward, all legal, each one a past or plausible failure:
1005
1021
  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"));
1022
+ // doc in .github/ — covers all required topics, so it must score full marks on CONTENT.
1023
+ // Deliberately short: length must not be what earns it. See [SCORE-CONTENT-NOT-LENGTH].
1024
+ writeFileSync(join(dir, ".github", "copilot-instructions.md"), [
1025
+ "# Canary", "", "## Architecture", "Two modules and a CLI entry point.", "",
1026
+ "## Commands", "`npm run build`, `npm test`.", "",
1027
+ "## Rules", "Never commit secrets.", "",
1028
+ "## Key files", "`src/index.ts` is the entry point.", "",
1029
+ ...Array.from({ length: 20 }, (_, i) => `filler ${i + 1}`),
1030
+ ].join("\n"));
1008
1031
  // doc at repo ROOT — the [DOC-PATH-DUAL] case, should score the full 3
1009
1032
  writeFileSync(join(dir, "SKILLS.md"), Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n"));
1010
1033
  // hook installed but BROKEN — the 2026-06-10 case, must read as fail-broken not fail-absent
@@ -1019,8 +1042,12 @@ export function runScoreCanary() {
1019
1042
  if (actual !== wanted)
1020
1043
  deviations.push(`${label}: expected ${JSON.stringify(wanted)}, got ${JSON.stringify(actual)}`);
1021
1044
  };
1022
- expect("copilot-instructions.md points", by("copilot-instructions.md")?.points, 10);
1045
+ expect("copilot-instructions.md points", by("copilot-instructions.md")?.points, 6);
1046
+ expect("copilot scored on topics not length", by("copilot-instructions.md")?.detail.includes("covers"), true);
1023
1047
  expect("SKILLS.md points (repo root)", by("SKILLS.md")?.points, 3);
1048
+ // No .git worktree here, so freshness is unmeasurable — it must be unknown, never a pass.
1049
+ expect("Doc freshness status", by("Doc freshness")?.status, "unknown");
1050
+ expect("Doc freshness points", by("Doc freshness")?.points, 0);
1024
1051
  expect("Git hooks status", by("Git hooks")?.status, "fail");
1025
1052
  expect("Git hooks names the break", by("Git hooks")?.detail.includes("BROKEN"), true);
1026
1053
  expect("Secrets exposure status", by("Secrets exposure")?.status, "unknown");
@@ -1055,71 +1082,126 @@ export function scoreProject(dir) {
1055
1082
  const checks = [];
1056
1083
  const p = dir.path;
1057
1084
  // --- Documentation (30 points max) ---
1058
- // copilot-instructions.md (10 pts) — .github/ or repo root, see resolveDocPath LOCK
1085
+ // copilot-instructions.md (6 pts) — scored on CONTENT, not length.
1086
+ // 🔒 LOCKED [SCORE-CONTENT-NOT-LENGTH] — 2026-08-14
1087
+ // ⛔ NEVER score an agent doc on line count alone.
1088
+ // WHY: this was 10 points — a tenth of the entire score — awarded for ">50 lines". Fifty lines
1089
+ // of anything earned full marks, so the largest single check in the rubric was also the
1090
+ // easiest to satisfy without doing the work. Length is a proxy for effort; it measures
1091
+ // typing, not usefulness to an agent.
1092
+ // FIX: score the sections an agent actually needs — architecture, commands, rules/conventions,
1093
+ // key files. Length survives only as a floor to reject stubs. Same approach the
1094
+ // .env.example check already used (counting real variables, not lines).
1059
1095
  const copilot = resolveDocPath(p, "copilot-instructions.md");
1060
1096
  if (copilot) {
1061
1097
  const copilotIsSymlink = isSymlink(copilot.path);
1062
1098
  const content = readFileSync(copilot.path, "utf-8");
1063
1099
  const lines = content.split("\n").length;
1064
- const at = `${lines} lines (${copilot.rel})`;
1100
+ const found = matchDocTopics(content, AGENT_DOC_TOPICS);
1101
+ const at = `${copilot.rel}, ${lines} lines`;
1065
1102
  if (copilotIsSymlink) {
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` });
1103
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 2, maxPoints: 6, status: "partial", detail: `⚠ Symlink (${at}) — should be a real file with project-specific context` });
1067
1104
  }
1068
- else if (lines > 50) {
1069
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 10, maxPoints: 10, status: "pass", detail: `${at} comprehensive` });
1105
+ else if (found.length >= AGENT_DOC_TOPICS.length) {
1106
+ // Content decides, and it decides FIRST. A short doc that covers everything an agent needs
1107
+ // beats a long one that covers nothing — that inversion is the whole point of this rework,
1108
+ // so the stub floor below must never be allowed to override a complete document.
1109
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `${at} — covers ${found.join(", ")}` });
1070
1110
  }
1071
- else if (lines > 15) {
1072
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 10, status: "partial", detail: `${at} — could be more detailed` });
1111
+ else if (lines <= RUBRIC.copilotPartial) {
1112
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 1, maxPoints: 6, status: "partial", detail: `${at} — a stub; add architecture, commands, rules, key files` });
1113
+ }
1114
+ else if (found.length > 0) {
1115
+ const missing = AGENT_DOC_TOPICS.filter(t => !found.includes(t.label)).map(t => t.label);
1116
+ const pts = found.length >= AGENT_DOC_TOPICS.length - 1 ? 4 : 2;
1117
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: pts, maxPoints: 6, status: "partial", detail: `${at} — has ${found.join(", ")}; missing ${missing.join(", ")}` });
1073
1118
  }
1074
1119
  else {
1075
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 3, maxPoints: 10, status: "partial", detail: `${at} — too sparse, add architecture, rules, key files` });
1120
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 1, maxPoints: 6, status: "partial", detail: `${at} — none of ${AGENT_DOC_TOPICS.map(t => t.label).join("/")} found; length without structure` });
1076
1121
  }
1077
1122
  }
1078
1123
  else {
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" });
1124
+ checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "Missing from .github/ and repo root — AI agents lack project context" });
1125
+ }
1126
+ // Doc freshness (4 pts) — is the agent doc keeping up with the code?
1127
+ // 🔒 LOCKED [SCORE-DOC-FRESHNESS] — 2026-08-14
1128
+ // ⛔ NEVER treat a doc's existence as evidence that it is current.
1129
+ // WHY: a 500-line copilot-instructions.md last touched a year ago scored identically to one
1130
+ // updated yesterday. Stale agent docs are worse than missing ones — an agent trusts them.
1131
+ // FIX: count commits that touched the repo since the doc was last modified, excluding the doc
1132
+ // itself. The same signal firewall.ts already computes for its staleness nudges.
1133
+ // [EXEC-FAILURE-IS-NOT-EMPTY]: if git cannot answer, this is "unknown", never a pass.
1134
+ if (copilot && existsSync(join(p, ".git"))) {
1135
+ const since = new Date(statSync(copilot.path).mtimeMs).toISOString();
1136
+ // NEVER pipe this through `| wc -l`: a shell pipeline reports the LAST command's exit status,
1137
+ // so a failed `git log` still exits 0 with output "0" — which reads as "0 commits since the
1138
+ // doc changed → fully current". Caught by [SCORE-CANARY]. Count in JS where failure is visible.
1139
+ const { ok, output } = execChecked(`git --no-pager log --oneline --since="${since}" -- . ":!${copilot.rel}"`, p);
1140
+ const drift = ok ? (output ? output.split("\n").length : 0) : NaN;
1141
+ if (!ok || Number.isNaN(drift)) {
1142
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "unknown", detail: "❔ git log failed here — cannot tell whether the agent doc is current" });
1143
+ }
1144
+ else if (drift === 0) {
1145
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: "Agent doc is the most recent change — fully current" });
1146
+ }
1147
+ else if (drift <= RUBRIC.freshnessGood) {
1148
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: `${drift} commit(s) since the agent doc was updated` });
1149
+ }
1150
+ else if (drift <= RUBRIC.freshnessStale) {
1151
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 2, maxPoints: 4, status: "partial", detail: `${drift} commits since the agent doc was updated — drifting` });
1152
+ }
1153
+ else {
1154
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: `${drift} commits since the agent doc was updated — agents are reading stale context` });
1155
+ }
1156
+ }
1157
+ else if (copilot) {
1158
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "unknown", detail: "❔ Not a git repo — cannot measure doc drift" });
1159
+ }
1160
+ else {
1161
+ checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: "No agent doc to keep fresh" });
1080
1162
  }
1081
1163
  // README.md (8 pts)
1082
1164
  const readmePath = join(p, "README.md");
1083
1165
  if (existsSync(readmePath)) {
1084
1166
  const content = readFileSync(readmePath, "utf-8");
1085
1167
  const readmeLines = content.split("\n").length;
1086
- if (readmeLines > 30) {
1087
- checks.push({ name: "README.md", category: "Documentation", points: 8, maxPoints: 8, status: "pass", detail: `${readmeLines} lines` });
1168
+ if (readmeLines > RUBRIC.readmeFull) {
1169
+ checks.push({ name: "README.md", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `${readmeLines} lines` });
1088
1170
  }
1089
1171
  else {
1090
- checks.push({ name: "README.md", category: "Documentation", points: 4, maxPoints: 8, status: "partial", detail: `${readmeLines} lines — sparse` });
1172
+ checks.push({ name: "README.md", category: "Documentation", points: 3, maxPoints: 6, status: "partial", detail: `${readmeLines} lines — sparse` });
1091
1173
  }
1092
1174
  }
1093
1175
  else {
1094
- checks.push({ name: "README.md", category: "Documentation", points: 0, maxPoints: 8, status: "fail", detail: "Missing" });
1176
+ checks.push({ name: "README.md", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "Missing" });
1095
1177
  }
1096
1178
  // CLAUDE.md / .cursorrules / AGENTS.md (6 pts)
1097
1179
  const altPatterns = ["CLAUDE.md", ".cursorrules", ".cursor/rules", "AGENTS.md"];
1098
1180
  const foundAlt = altPatterns.filter(pat => existsSync(join(p, pat)));
1099
1181
  const realAlt = foundAlt.filter(pat => !isSymlink(join(p, pat)));
1100
1182
  const symlinkAlt = foundAlt.filter(pat => isSymlink(join(p, pat)));
1101
- if (realAlt.length >= 2) {
1102
- checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `Found: ${realAlt.join(", ")}` });
1183
+ if (realAlt.length >= RUBRIC.multiAgentFull) {
1184
+ checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: `Found: ${realAlt.join(", ")}` });
1103
1185
  }
1104
1186
  else if (realAlt.length === 1 && symlinkAlt.length >= 1) {
1105
- checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 4, maxPoints: 6, status: "partial", detail: `${realAlt[0]} + ${symlinkAlt.length} symlink(s) — symlinks count as partial` });
1187
+ checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 3, maxPoints: 4, status: "partial", detail: `${realAlt[0]} + ${symlinkAlt.length} symlink(s) — symlinks count as partial` });
1106
1188
  }
1107
- else if (foundAlt.length >= 2 && realAlt.length === 0) {
1108
- checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 2, maxPoints: 6, status: "partial", detail: `${foundAlt.join(", ")} — all symlinks, create real per-agent files` });
1189
+ else if (foundAlt.length >= RUBRIC.multiAgentFull && realAlt.length === 0) {
1190
+ checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 1, maxPoints: 4, status: "partial", detail: `${foundAlt.join(", ")} — all symlinks, create real per-agent files` });
1109
1191
  }
1110
1192
  else if (foundAlt.length === 1) {
1111
1193
  const pts = isSymlink(join(p, foundAlt[0])) ? 1 : 3;
1112
1194
  checks.push({ name: "Multi-agent patterns", category: "Documentation", points: pts, maxPoints: 6, status: "partial", detail: `Found: ${foundAlt[0]}${isSymlink(join(p, foundAlt[0])) ? " (symlink)" : ""} only` });
1113
1195
  }
1114
1196
  else {
1115
- checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "No CLAUDE.md, .cursorrules, or AGENTS.md" });
1197
+ checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: "No CLAUDE.md, .cursorrules, or AGENTS.md" });
1116
1198
  }
1117
1199
  // SKILLS.md (3 pts) — .github/ or repo root, see resolveDocPath LOCK
1118
1200
  const skills = resolveDocPath(p, "SKILLS.md");
1119
1201
  if (skills) {
1120
1202
  const skillsContent = readFileSync(skills.path, "utf-8");
1121
1203
  const skillsLines = skillsContent.split("\n").length;
1122
- if (skillsLines > 10 && !isSymlink(skills.path)) {
1204
+ if (skillsLines > RUBRIC.skillsFull && !isSymlink(skills.path)) {
1123
1205
  checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines (${skills.rel})` });
1124
1206
  }
1125
1207
  else {
@@ -1134,23 +1216,23 @@ export function scoreProject(dir) {
1134
1216
  if (existsSync(envExamplePath)) {
1135
1217
  const envContent = readFileSync(envExamplePath, "utf-8");
1136
1218
  const envVarLines = envContent.split("\n").filter(l => /^[A-Z_]+=/.test(l.trim())).length;
1137
- if (envVarLines >= 3) {
1138
- checks.push({ name: ".env.example", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${envVarLines} env vars documented` });
1219
+ if (envVarLines >= RUBRIC.envExampleVars) {
1220
+ checks.push({ name: ".env.example", category: "Documentation", points: 2, maxPoints: 2, status: "pass", detail: `${envVarLines} env vars documented` });
1139
1221
  }
1140
1222
  else {
1141
- checks.push({ name: ".env.example", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `Only ${envVarLines} env var(s) — add all required vars` });
1223
+ checks.push({ name: ".env.example", category: "Documentation", points: 1, maxPoints: 2, status: "partial", detail: `Only ${envVarLines} env var(s) — add all required vars` });
1142
1224
  }
1143
1225
  }
1144
1226
  else {
1145
- checks.push({ name: ".env.example", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing — agents can't set up env" });
1227
+ checks.push({ name: ".env.example", category: "Documentation", points: 0, maxPoints: 2, status: "fail", detail: "Missing — agents can't set up env" });
1146
1228
  }
1147
1229
  // --- Infrastructure (30 points max) ---
1148
1230
  // Git repo (5 pts)
1149
1231
  if (existsSync(join(p, ".git"))) {
1150
- checks.push({ name: "Git repository", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Initialized" });
1232
+ checks.push({ name: "Git repository", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Initialized" });
1151
1233
  }
1152
1234
  else {
1153
- checks.push({ name: "Git repository", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "Not a git repo" });
1235
+ checks.push({ name: "Git repository", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "Not a git repo" });
1154
1236
  }
1155
1237
  // .gitignore (3 pts) — validates essential patterns, not just existence
1156
1238
  const gitignoreScorePath = join(p, ".gitignore");
@@ -1158,18 +1240,18 @@ export function scoreProject(dir) {
1158
1240
  const giContent = readFileSync(gitignoreScorePath, "utf-8");
1159
1241
  const essentialPatterns = [".env", "node_modules", "dist", "vendor", ".DS_Store", "*.log"];
1160
1242
  const foundPatterns = essentialPatterns.filter(pat => giContent.includes(pat));
1161
- if (foundPatterns.length >= 3) {
1162
- checks.push({ name: ".gitignore", category: "Infrastructure", points: 3, maxPoints: 3, status: "pass", detail: `${foundPatterns.length} essential patterns` });
1243
+ if (foundPatterns.length >= RUBRIC.gitignoreFull) {
1244
+ checks.push({ name: ".gitignore", category: "Infrastructure", points: 2, maxPoints: 2, status: "pass", detail: `${foundPatterns.length} essential patterns` });
1163
1245
  }
1164
- else if (foundPatterns.length >= 1) {
1165
- checks.push({ name: ".gitignore", category: "Infrastructure", points: 2, maxPoints: 3, status: "partial", detail: `Only ${foundPatterns.length} essential pattern(s) — add .env, node_modules, dist` });
1246
+ else if (foundPatterns.length >= RUBRIC.gitignorePartial) {
1247
+ checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 2, status: "partial", detail: `Only ${foundPatterns.length} essential pattern(s) — add .env, node_modules, dist` });
1166
1248
  }
1167
1249
  else {
1168
- checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 3, status: "partial", detail: "Exists but missing essential patterns (.env, node_modules)" });
1250
+ checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 2, status: "partial", detail: "Exists but missing essential patterns (.env, node_modules)" });
1169
1251
  }
1170
1252
  }
1171
1253
  else {
1172
- checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "Missing" });
1254
+ checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 2, status: "fail", detail: "Missing" });
1173
1255
  }
1174
1256
  // Git hooks (5 pts) — see [ABSENCE-IS-NOT-A-VERDICT]: "installed but broken" is not "not installed"
1175
1257
  const repoHook = join(p, "hooks", "post-commit");
@@ -1179,14 +1261,14 @@ export function scoreProject(dir) {
1179
1261
  const live = [repoHook, gitHook].filter(h => existsSync(h));
1180
1262
  if (live.length > 0) {
1181
1263
  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})` });
1264
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: `post-commit hook configured (${where})` });
1183
1265
  }
1184
1266
  else if (dangling.length > 0) {
1185
1267
  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` });
1268
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: `⚠ Hook installed but BROKEN — dangling symlink: ${broken}. Auto-push is silently dead; restore the target, don't re-install` });
1187
1269
  }
1188
1270
  else {
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" });
1271
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "No post-commit hook at hooks/ or .git/hooks/ — consider auto-push" });
1190
1272
  }
1191
1273
  // Docker / containerization (5 pts)
1192
1274
  // Context-aware: only award points if Docker is actually used for deployment.
@@ -1227,21 +1309,21 @@ export function scoreProject(dir) {
1227
1309
  existsSync(join(p, "fly.toml")) ||
1228
1310
  existsSync(join(p, "railway.json"));
1229
1311
  if (usesDockerForReal && hasDockerfile && hasCompose) {
1230
- checks.push({ name: "Docker", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Dockerfile + compose (active deployment)" });
1312
+ checks.push({ name: "Docker", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Dockerfile + compose (active deployment)" });
1231
1313
  }
1232
1314
  else if (usesDockerForReal && (hasDockerfile || hasCompose)) {
1233
- checks.push({ name: "Docker", category: "Infrastructure", points: 3, maxPoints: 5, status: "partial", detail: hasDockerfile ? "Dockerfile only" : "Compose only" });
1315
+ checks.push({ name: "Docker", category: "Infrastructure", points: 2, maxPoints: 4, status: "partial", detail: hasDockerfile ? "Dockerfile only" : "Compose only" });
1234
1316
  }
1235
1317
  else if ((hasDockerfile || hasCompose) && !usesDockerForReal) {
1236
1318
  // Files exist but look like stubs/placeholders — minimal credit
1237
- checks.push({ name: "Docker", category: "Infrastructure", points: 1, maxPoints: 5, status: "partial", detail: "Docker files exist but appear to be placeholders — not used in deployment" });
1319
+ checks.push({ name: "Docker", category: "Infrastructure", points: 1, maxPoints: 4, status: "partial", detail: "Docker files exist but appear to be placeholders — not used in deployment" });
1238
1320
  }
1239
1321
  else if (hasAltDeploy) {
1240
1322
  // Project uses a different deploy platform — Docker is N/A, award full points
1241
- checks.push({ name: "Containerization", category: "Infrastructure", points: 5, maxPoints: 5, status: "pass", detail: "Uses managed platform (Vercel/Netlify/Render/Fly)" });
1323
+ checks.push({ name: "Containerization", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Uses managed platform (Vercel/Netlify/Render/Fly)" });
1242
1324
  }
1243
1325
  else {
1244
- checks.push({ name: "Docker", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "Not containerized" });
1326
+ checks.push({ name: "Docker", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "Not containerized" });
1245
1327
  }
1246
1328
  // CI config (5 pts) — validates workflows have real actions, not empty stubs
1247
1329
  const ciPaths = [".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci", ".travis.yml"];
@@ -1284,14 +1366,14 @@ export function scoreProject(dir) {
1284
1366
  const deployContent = readFileSync(deployFile, "utf-8");
1285
1367
  const deployLines = deployContent.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")).length;
1286
1368
  if (deployLines >= 3) {
1287
- checks.push({ name: "Deploy script", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: `${foundDeploy[0]} (${deployLines} effective lines)` });
1369
+ checks.push({ name: "Deploy script", category: "Infrastructure", points: 3, maxPoints: 3, status: "pass", detail: `${foundDeploy[0]} (${deployLines} effective lines)` });
1288
1370
  }
1289
1371
  else {
1290
- checks.push({ name: "Deploy script", category: "Infrastructure", points: 1, maxPoints: 4, status: "partial", detail: `${foundDeploy[0]} — only ${deployLines} effective lines, looks like a placeholder` });
1372
+ checks.push({ name: "Deploy script", category: "Infrastructure", points: 1, maxPoints: 3, status: "partial", detail: `${foundDeploy[0]} — only ${deployLines} effective lines, looks like a placeholder` });
1291
1373
  }
1292
1374
  }
1293
1375
  else {
1294
- checks.push({ name: "Deploy script", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "No deploy automation" });
1376
+ checks.push({ name: "Deploy script", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "No deploy automation" });
1295
1377
  }
1296
1378
  // PM2 / process manager (3 pts)
1297
1379
  if (existsSync(join(p, "ecosystem.config.js")) || existsSync(join(p, "ecosystem.config.cjs"))) {
@@ -1311,10 +1393,10 @@ export function scoreProject(dir) {
1311
1393
  if (testIsSymlink) {
1312
1394
  checks.push({ name: "Tests", category: "Code Quality", points: 3, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ is a symlink (${testFileCount} test files) — should be real test directory` });
1313
1395
  }
1314
- else if (testFileCount >= 5) {
1396
+ else if (testFileCount >= RUBRIC.testsFull) {
1315
1397
  checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${foundTests[0]}/ — ${testFileCount} test files` });
1316
1398
  }
1317
- else if (testFileCount > 0) {
1399
+ else if (testFileCount > RUBRIC.testsPartial) {
1318
1400
  checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ — only ${testFileCount} test files` });
1319
1401
  }
1320
1402
  else {
@@ -1341,7 +1423,7 @@ export function scoreProject(dir) {
1341
1423
  const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
1342
1424
  const tsconfigIsSymlink = isSymlink(tsconfigPath);
1343
1425
  // Detect minimal/reference-only tsconfigs (just project references with no real config)
1344
- const isSubstantive = tsconfigContent.length > 50 && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1426
+ const isSubstantive = tsconfigContent.length > RUBRIC.tsconfigSubstantive && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1345
1427
  if (tsconfigIsSymlink) {
1346
1428
  checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "tsconfig.json is a symlink — create root config" });
1347
1429
  }
@@ -1399,14 +1481,14 @@ export function scoreProject(dir) {
1399
1481
  if (existsSync(gitignorePath)) {
1400
1482
  const gitignore = readFileSync(gitignorePath, "utf-8");
1401
1483
  if (gitignore.includes(".env")) {
1402
- checks.push({ name: ".env in .gitignore", category: "Security", points: 8, maxPoints: 8, status: "pass", detail: ".env is gitignored" });
1484
+ checks.push({ name: ".env in .gitignore", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: ".env is gitignored" });
1403
1485
  }
1404
1486
  else {
1405
- checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 8, status: "fail", detail: ".env NOT in .gitignore — secrets at risk!" });
1487
+ checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 10, status: "fail", disqualifying: true, detail: ".env NOT in .gitignore — secrets at risk! Caps this project's grade at C" });
1406
1488
  }
1407
1489
  }
1408
1490
  else {
1409
- checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 8, status: "fail", detail: "No .gitignore at all" });
1491
+ checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 10, status: "fail", detail: "No .gitignore at all" });
1410
1492
  }
1411
1493
  // No secrets in tracked files (6 pts)
1412
1494
  // [ABSENCE-IS-NOT-A-VERDICT]: "no .env" and "can't run git here" are different states.
@@ -1417,41 +1499,41 @@ export function scoreProject(dir) {
1417
1499
  // [EXEC-FAILURE-IS-NOT-EMPTY]: a failed `git ls-files` must never read as "not tracked".
1418
1500
  const { ok, output: tracked } = execChecked("git ls-files .env", p);
1419
1501
  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" });
1502
+ checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "unknown", detail: "❔ .env present but `git ls-files` failed here — cannot verify whether it is tracked" });
1421
1503
  }
1422
1504
  else if (tracked === ".env") {
1423
- checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "fail", detail: ".env is tracked by git!" });
1505
+ checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "fail", disqualifying: true, detail: ".env is tracked by git! Caps this project's grade at C" });
1424
1506
  }
1425
1507
  else {
1426
- checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: ".env present and not tracked by git" });
1508
+ checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: ".env present and not tracked by git" });
1427
1509
  }
1428
1510
  }
1429
1511
  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" });
1512
+ checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: "No .env at repo root — nothing to leak" });
1431
1513
  }
1432
1514
  else {
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" });
1515
+ checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "unknown", detail: "❔ .env exists but this is not a git repo — cannot verify whether it is tracked" });
1434
1516
  }
1435
1517
  // Lockfile present (3 pts)
1436
1518
  const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock"];
1437
1519
  const foundLock = lockfiles.filter(l => existsSync(join(p, l)));
1438
1520
  if (foundLock.length > 0) {
1439
- checks.push({ name: "Lockfile", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: foundLock.join(", ") });
1521
+ checks.push({ name: "Lockfile", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: foundLock.join(", ") });
1440
1522
  }
1441
1523
  else if (existsSync(pkgPath) || existsSync(join(p, "composer.json"))) {
1442
- checks.push({ name: "Lockfile", category: "Security", points: 0, maxPoints: 3, status: "fail", detail: "No lockfile — deps not pinned" });
1524
+ checks.push({ name: "Lockfile", category: "Security", points: 0, maxPoints: 5, status: "fail", detail: "No lockfile — deps not pinned" });
1443
1525
  }
1444
1526
  // node_modules in .gitignore (3 pts)
1445
1527
  if (existsSync(gitignorePath)) {
1446
1528
  const gitignore = readFileSync(gitignorePath, "utf-8");
1447
1529
  if (gitignore.includes("node_modules") || gitignore.includes("vendor")) {
1448
- checks.push({ name: "Deps gitignored", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: "node_modules/vendor gitignored" });
1530
+ checks.push({ name: "Deps gitignored", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: "node_modules/vendor gitignored" });
1449
1531
  }
1450
1532
  else if (!existsSync(join(p, "package.json")) && !existsSync(join(p, "composer.json"))) {
1451
- checks.push({ name: "Deps gitignored", category: "Security", points: 3, maxPoints: 3, status: "pass", detail: "N/A — no package manager" });
1533
+ checks.push({ name: "Deps gitignored", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: "N/A — no package manager" });
1452
1534
  }
1453
1535
  else {
1454
- checks.push({ name: "Deps gitignored", category: "Security", points: 0, maxPoints: 3, status: "fail", detail: "node_modules/vendor not in .gitignore" });
1536
+ checks.push({ name: "Deps gitignored", category: "Security", points: 0, maxPoints: 5, status: "fail", detail: "node_modules/vendor not in .gitignore" });
1455
1537
  }
1456
1538
  }
1457
1539
  // --- Calculate totals ---
@@ -1500,6 +1582,21 @@ export function scoreProject(dir) {
1500
1582
  grade = "D";
1501
1583
  else
1502
1584
  grade = "F";
1585
+ // 🔒 LOCKED [SECURITY-IS-DISQUALIFYING] — 2026-08-14
1586
+ // ⛔ NEVER let exposed secrets be averaged away into a good grade.
1587
+ // WHY: security was 20 of 100, so a project could commit its .env and still score 80% on the
1588
+ // strength of good docs. Weighting alone cannot express "this one thing is not a rounding
1589
+ // error" — at any weight below ~50 the arithmetic still lets other categories outvote it.
1590
+ // FIX: a failed secret-exposure check CAPS the grade at C no matter the point total. The
1591
+ // percentage stays honest (it still reports what was earned); only the grade is capped, and
1592
+ // the report says why. Reserved for actual secret exposure — a missing lockfile is hygiene,
1593
+ // not a disqualification, and must never set this flag.
1594
+ const disqualified = checks.filter(c => c.disqualifying && c.status === "fail");
1595
+ const GRADE_CAP = "C";
1596
+ const gradeOrder = ["F", "D", "C", "B", "A", "A+"];
1597
+ if (disqualified.length > 0 && gradeOrder.indexOf(grade) > gradeOrder.indexOf(GRADE_CAP)) {
1598
+ grade = GRADE_CAP;
1599
+ }
1503
1600
  return {
1504
1601
  project: dir.name,
1505
1602
  path: dir.path,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Scoring thresholds — the numbers that decide what earns what.
3
+ *
4
+ * 🔒 LOCKED [RUBRIC-SINGLE-TABLE] — 2026-08-14
5
+ * ⛔ NEVER inline a scoring threshold back into src/agents.ts.
6
+ * WHY: these values are what makes a score gameable — knowing ">50 lines earns the full 10"
7
+ * lets anyone pad a file to a number instead of writing the content. They were scattered
8
+ * as bare literals across ~10 call sites in agents.ts, so they shipped readable in
9
+ * dist/agents.js and could not be obscured without rewriting the whole scorer.
10
+ * FIX: one table, one module. `scripts/obfuscate-rubric.mjs` rewrites dist/rubric.js at build
11
+ * time into an encoded blob, so the published package carries no readable thresholds.
12
+ * Adding a literal back into agents.ts silently re-exposes it — the obfuscator only
13
+ * touches this file.
14
+ *
15
+ * NOTE ON STRENGTH: this stops casual reading, not determined analysis. The surrounding
16
+ * comparison logic still ships, and 2.1.3 remains on npm forever as a plaintext reference.
17
+ * It is a speed bump for score-gaming, deliberately, and must never be described as more.
18
+ * The real protections are the activation gate and BSL-1.1. See CLAUDE.md rules 1-3.
19
+ */
20
+ export interface Rubric {
21
+ /** copilot-instructions.md line counts for the 10 / 6 point tiers */
22
+ copilotFull: number;
23
+ copilotPartial: number;
24
+ /** README.md line count for the full 8 points */
25
+ readmeFull: number;
26
+ /** SKILLS.md line count for the full 3 points */
27
+ skillsFull: number;
28
+ /** .env.example — documented variables needed for full marks */
29
+ envExampleVars: number;
30
+ /** .gitignore — essential patterns matched for full / partial credit */
31
+ gitignoreFull: number;
32
+ gitignorePartial: number;
33
+ /** Test files present for the 8 / 5 point tiers */
34
+ testsFull: number;
35
+ testsPartial: number;
36
+ /** tsconfig.json byte length below which it is treated as a stub */
37
+ tsconfigSubstantive: number;
38
+ /** Real (non-symlink) agent-pattern files for full credit */
39
+ multiAgentFull: number;
40
+ /** Commits since the agent doc was updated: at or below = current, above `freshnessStale` = stale */
41
+ freshnessGood: number;
42
+ freshnessStale: number;
43
+ }
44
+ export declare const RUBRIC: Rubric;
45
+ //# sourceMappingURL=rubric.d.ts.map
package/dist/rubric.js ADDED
@@ -0,0 +1,9 @@
1
+ /*__RUBRIC_ENCODED__*/
2
+ const _k = "ce-rubric-v1";
3
+ const _d = (b) => {
4
+ const raw = Buffer.from(b, "base64").toString("binary");
5
+ let s = "";
6
+ for (let i = 0; i < raw.length; i++) s += String.fromCharCode(raw.charCodeAt(i) ^ _k.charCodeAt(i % _k.length));
7
+ return JSON.parse(s);
8
+ };
9
+ export const RUBRIC = Object.freeze(_d("GEdOHQULHgYXawNdD0cXR0VOUAoMXR9dDBF9EwcWGwgPD0wAVkkPABADFgQGawNdD0cXQUVOUBoIRBpdECNYHhlASFhTAVRUDRNoChQPAgUGexdDEEcXQVlAFQAXRBFfDBdINAAOHktZHloTBAxZGxIMHRsGfRdDFwxMHldYQ0VBWRNCFxZrBxkOUFNWAVRFBhZZASUDAB0KTBoTWVUBUAEREQYNSx9WMBBPAQEDHB0KWxMTWVAdXlcPBwUXRDdWBgtZNAAOHktZH1oTBRdIAR0MFxoQahleB0cXQ0VOUA8RSAVZDQBeASYWEwUGD0wFUxg="));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",
@@ -17,7 +17,7 @@
17
17
  "test": "vitest run",
18
18
  "test:watch": "vitest",
19
19
  "lint": "eslint src/",
20
- "prepublishOnly": "node scripts/check-npm-token-expiry.mjs && npm run build",
20
+ "prepublishOnly": "node scripts/check-npm-token-expiry.mjs && npm run build && node scripts/obfuscate-rubric.mjs",
21
21
  "check-token": "node scripts/check-npm-token-expiry.mjs"
22
22
  },
23
23
  "keywords": [