@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 +5 -0
- package/dist/agents.js +160 -63
- package/dist/rubric.d.ts +45 -0
- package/dist/rubric.js +9 -0
- package/package.json +2 -2
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/ —
|
|
1007
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
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:
|
|
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 (
|
|
1069
|
-
|
|
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
|
|
1072
|
-
checks.push({ name: "copilot-instructions.md", category: "Documentation", points:
|
|
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:
|
|
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:
|
|
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 >
|
|
1087
|
-
checks.push({ name: "README.md", category: "Documentation", points:
|
|
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:
|
|
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:
|
|
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 >=
|
|
1102
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points:
|
|
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:
|
|
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 >=
|
|
1108
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points:
|
|
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:
|
|
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 >
|
|
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 >=
|
|
1138
|
-
checks.push({ name: ".env.example", category: "Documentation", points:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 >=
|
|
1162
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points:
|
|
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 >=
|
|
1165
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 >=
|
|
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 >
|
|
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 >
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
package/dist/rubric.d.ts
ADDED
|
@@ -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.
|
|
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": [
|