@compr/opscontext-mcp 2.1.3 → 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
@@ -102,9 +102,32 @@ 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
+ /**
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
  }
113
+ /**
114
+ * šŸ”’ LOCKED [ABSENCE-IS-NOT-A-VERDICT] — 2026-08-13
115
+ * ā›” NEVER emit "pass" or "fail" for a condition the check could not actually determine,
116
+ * and NEVER write a detail string that hides WHICH locations were inspected.
117
+ * WHY: three live bugs, all the same shape — the scorer wrote *absence of evidence* down as
118
+ * a *verdict*, and the verdict pointed at the wrong fix.
119
+ * 1. `copilot-instructions.md`/`SKILLS.md` looked only in .github/ and reported files that
120
+ * existed at the repo root as "Missing" (fixed ffa5914, [DOC-PATH-DUAL]).
121
+ * 2. `Git hooks` reported "No hooks — consider auto-push" for a hook that WAS installed but
122
+ * whose symlink target had been deleted. existsSync() follows symlinks, so dangling and
123
+ * absent are indistinguishable. CE's own Drive backup drifted while the row said
124
+ * "consider auto-push" — advice for a problem the repo did not have.
125
+ * 3. `Secrets exposure` awarded a full 6/6 PASS with the detail "No .env or not a git repo" —
126
+ * full marks for a state it openly could not distinguish.
127
+ * FIX: a check that cannot determine its condition emits status "unknown" (0 points, rendered ā”,
128
+ * excluded from the remediation list — it is a gap in the CHECK, not in the project). Every
129
+ * pass/fail detail must name what was inspected. Absence is a measurement, not a decision.
130
+ */
108
131
  export interface ProjectScore {
109
132
  project: string;
110
133
  path: string;
@@ -114,6 +137,14 @@ export interface ProjectScore {
114
137
  grade: string;
115
138
  checks: ScoreCheck[];
116
139
  }
140
+ export interface CanaryResult {
141
+ ok: boolean;
142
+ /** Human-readable deviations from the pinned expectation. Empty when ok. */
143
+ deviations: string[];
144
+ /** True when the canary itself could not be built — an unknown, not a failure. */
145
+ inconclusive: boolean;
146
+ }
147
+ export declare function runScoreCanary(): CanaryResult;
117
148
  /**
118
149
  * Score a project's AI-readiness (0-100%).
119
150
  * Checks how well-prepared a project is for AI coding agents.
package/dist/agents.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { execSync } from "child_process";
2
- import { readFileSync, existsSync, readdirSync, lstatSync } 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
+ import { tmpdir } from "os";
4
5
  import { fileURLToPath } from "url";
6
+ import { RUBRIC } from "./rubric.js";
5
7
  // Read version from package.json at module load
6
8
  const __agents_dirname = dirname(fileURLToPath(import.meta.url));
7
9
  let AGENTS_VERSION = "1.23.0";
@@ -14,16 +16,34 @@ catch { /* fallback */ }
14
16
  // Helpers
15
17
  // ---------------------------------------------------------------------------
16
18
  function exec(cmd, cwd) {
19
+ return execChecked(cmd, cwd).output;
20
+ }
21
+ /**
22
+ * exec() that distinguishes "ran, produced nothing" from "did not run".
23
+ *
24
+ * šŸ”’ LOCKED [EXEC-FAILURE-IS-NOT-EMPTY] — 2026-08-13
25
+ * ā›” NEVER let a caller treat exec()'s "" as a factual answer for a check that can FAIL SAFE.
26
+ * WHY: exec() returns "" for every failure mode — command not found, not a git repo, timeout,
27
+ * permission denied — indistinguishable from a successful empty result. The `Secrets exposure`
28
+ * check read `git ls-files .env` → "" as ".env is not tracked" and awarded a full 6/6 PASS.
29
+ * A security check that hands out full marks precisely when it cannot run is worse than one
30
+ * that fails: it manufactures reassurance. Found by [SCORE-CANARY] on its first run, against
31
+ * a fixture with a .git directory that is not a real repository.
32
+ * FIX: security- and correctness-relevant callers use execChecked() and emit "unknown" when
33
+ * ok === false. Absence of output is a measurement, not a decision.
34
+ */
35
+ function execChecked(cmd, cwd) {
17
36
  try {
18
- return execSync(cmd, {
37
+ const output = execSync(cmd, {
19
38
  cwd,
20
39
  encoding: "utf-8",
21
40
  timeout: 10_000,
22
41
  stdio: ["pipe", "pipe", "pipe"],
23
42
  }).trim();
43
+ return { ok: true, output };
24
44
  }
25
45
  catch {
26
- return "";
46
+ return { ok: false, output: "" };
27
47
  }
28
48
  }
29
49
  /**
@@ -37,6 +57,66 @@ function isSymlink(filePath) {
37
57
  return false;
38
58
  }
39
59
  }
60
+ /**
61
+ * Status icon for a score check. "unknown" is ā” — the check could not determine its
62
+ * condition, which is a gap in the audit, not a verdict on the project.
63
+ * See [ABSENCE-IS-NOT-A-VERDICT].
64
+ */
65
+ function statusIcon(status) {
66
+ if (status === "pass")
67
+ return "āœ…";
68
+ if (status === "partial")
69
+ return "🟔";
70
+ if (status === "unknown")
71
+ return "ā”";
72
+ return "āŒ";
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
+ }
89
+ /** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
90
+ function readlinkSafe(filePath) {
91
+ try {
92
+ return readlinkSync(filePath);
93
+ }
94
+ catch {
95
+ return "?";
96
+ }
97
+ }
98
+ /**
99
+ * Resolve an agent doc that may live in `.github/` or at the repo root.
100
+ * Returns the first location that exists (`.github/` wins), or null.
101
+ *
102
+ * šŸ”’ LOCKED [DOC-PATH-DUAL] — 2026-08-07
103
+ * ā›” NEVER collapse a caller back to a single hardcoded join(p, ".github", file).
104
+ * WHY: the scorer read `.github/` only, while `contextengine init` writes SKILLS.md
105
+ * at the repo root and the generated pre-commit hook accepts both. Every project
106
+ * keeping these at root scored 0/10 + 0/3 for files that existed, so SCORE.md
107
+ * rows had to be hand-corrected after every run (caught 2026-08-07 on this repo).
108
+ * FIX: score whichever location exists — `.github/` first (Copilot's official read
109
+ * path), repo root second. Presence is what earns the points, not the directory.
110
+ */
111
+ function resolveDocPath(projectPath, file) {
112
+ const inGithub = join(projectPath, ".github", file);
113
+ if (existsSync(inGithub))
114
+ return { path: inGithub, rel: `.github/${file}` };
115
+ const atRoot = join(projectPath, file);
116
+ if (existsSync(atRoot))
117
+ return { path: atRoot, rel: file };
118
+ return null;
119
+ }
40
120
  /**
41
121
  * Check if an ESLint config is actually backed by installed packages.
42
122
  * Returns true if at least `eslint` itself is installed in node_modules.
@@ -909,6 +989,91 @@ export function formatPortMap(ports, conflicts) {
909
989
  }
910
990
  return lines.join("\n");
911
991
  }
992
+ /**
993
+ * šŸ”’ LOCKED [SCORE-CANARY] — 2026-08-13
994
+ * ā›” NEVER weaken this to "test one known bug", and never let a deviation be a warning only.
995
+ * WHY: every guard in this file was written AFTER the failure it prevents — the doc-path bug, the
996
+ * dangling-hook bug, the shrinking-denominator bug. A wall of named guards is a museum of past
997
+ * mistakes: valuable, but it does nothing about the next one. The canary is the only check here
998
+ * that can catch a failure nobody predicted, because it does not test for a specific defect —
999
+ * it demands that EVERY health signal still reads exactly as pinned before the scorer is
1000
+ * allowed to write a single file.
1001
+ * FIX: build a synthetic project whose every awkward case is represented (doc at repo root, doc in
1002
+ * .github/, dangling hook symlink, .env with no git, no package manager), score it, and compare
1003
+ * against the pinned expectation. Any drift blocks the run. If the fixture cannot be built at
1004
+ * all, that is `inconclusive` — an unknown, per [ABSENCE-IS-NOT-A-VERDICT] — and must not be
1005
+ * silently treated as a pass.
1006
+ *
1007
+ * Runs in production on every scoring run, not just in CI. Costs a few milliseconds.
1008
+ */
1009
+ /**
1010
+ * True while the canary fixture is being scored. The fixture deliberately leaves 9 points
1011
+ * unassessable (no package manager, no .gitignore), so its invariant log line is EXPECTED —
1012
+ * printing it on every run would train the reader to ignore the warning that matters.
1013
+ */
1014
+ let canaryRunning = false;
1015
+ export function runScoreCanary() {
1016
+ let dir = null;
1017
+ canaryRunning = true;
1018
+ try {
1019
+ dir = mkdtempSync(join(tmpdir(), "ce-canary-"));
1020
+ // Deliberately awkward, all legal, each one a past or plausible failure:
1021
+ mkdirSync(join(dir, ".github"), { recursive: true });
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"));
1031
+ // doc at repo ROOT — the [DOC-PATH-DUAL] case, should score the full 3
1032
+ writeFileSync(join(dir, "SKILLS.md"), Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n"));
1033
+ // hook installed but BROKEN — the 2026-06-10 case, must read as fail-broken not fail-absent
1034
+ mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
1035
+ symlinkSync(join(dir, "hooks", "post-commit"), join(dir, ".git", "hooks", "post-commit"));
1036
+ // .env with no git repo — unverifiable, must be `unknown` and must NOT collect points
1037
+ writeFileSync(join(dir, ".env"), "SECRET=canary\n");
1038
+ const score = scoreProject({ name: "ce-canary", path: dir });
1039
+ const by = (n) => score.checks.find(c => c.name === n);
1040
+ const deviations = [];
1041
+ const expect = (label, actual, wanted) => {
1042
+ if (actual !== wanted)
1043
+ deviations.push(`${label}: expected ${JSON.stringify(wanted)}, got ${JSON.stringify(actual)}`);
1044
+ };
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);
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);
1051
+ expect("Git hooks status", by("Git hooks")?.status, "fail");
1052
+ expect("Git hooks names the break", by("Git hooks")?.detail.includes("BROKEN"), true);
1053
+ expect("Secrets exposure status", by("Secrets exposure")?.status, "unknown");
1054
+ expect("Secrets exposure points", by("Secrets exposure")?.points, 0);
1055
+ expect("denominator pinned to 100", score.maxScore, 100);
1056
+ expect("maxPoints sum to 100", score.checks.reduce((s, c) => s + c.maxPoints, 0), 100);
1057
+ expect("percentage matches score/100", score.percentage, Math.round(score.score));
1058
+ return { ok: deviations.length === 0, deviations, inconclusive: false };
1059
+ }
1060
+ catch (err) {
1061
+ return {
1062
+ ok: false,
1063
+ inconclusive: true,
1064
+ deviations: [`canary fixture could not be built: ${err instanceof Error ? err.message : String(err)}`],
1065
+ };
1066
+ }
1067
+ finally {
1068
+ canaryRunning = false;
1069
+ if (dir) {
1070
+ try {
1071
+ rmSync(dir, { recursive: true, force: true });
1072
+ }
1073
+ catch { /* temp dir cleanup is best-effort */ }
1074
+ }
1075
+ }
1076
+ }
912
1077
  /**
913
1078
  * Score a project's AI-readiness (0-100%).
914
1079
  * Checks how well-prepared a project is for AI coding agents.
@@ -917,101 +1082,157 @@ export function scoreProject(dir) {
917
1082
  const checks = [];
918
1083
  const p = dir.path;
919
1084
  // --- 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");
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).
1095
+ const copilot = resolveDocPath(p, "copilot-instructions.md");
1096
+ if (copilot) {
1097
+ const copilotIsSymlink = isSymlink(copilot.path);
1098
+ const content = readFileSync(copilot.path, "utf-8");
925
1099
  const lines = content.split("\n").length;
1100
+ const found = matchDocTopics(content, AGENT_DOC_TOPICS);
1101
+ const at = `${copilot.rel}, ${lines} lines`;
926
1102
  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` });
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` });
1104
+ }
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(", ")}` });
928
1110
  }
929
- else if (lines > 50) {
930
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 10, maxPoints: 10, status: "pass", detail: `${lines} lines — comprehensive` });
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` });
931
1113
  }
932
- 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` });
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(", ")}` });
934
1118
  }
935
1119
  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` });
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` });
937
1121
  }
938
1122
  }
939
1123
  else {
940
- checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 10, status: "fail", detail: "Missing — 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" });
941
1162
  }
942
1163
  // README.md (8 pts)
943
1164
  const readmePath = join(p, "README.md");
944
1165
  if (existsSync(readmePath)) {
945
1166
  const content = readFileSync(readmePath, "utf-8");
946
1167
  const readmeLines = content.split("\n").length;
947
- if (readmeLines > 30) {
948
- 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` });
949
1170
  }
950
1171
  else {
951
- 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` });
952
1173
  }
953
1174
  }
954
1175
  else {
955
- 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" });
956
1177
  }
957
1178
  // CLAUDE.md / .cursorrules / AGENTS.md (6 pts)
958
1179
  const altPatterns = ["CLAUDE.md", ".cursorrules", ".cursor/rules", "AGENTS.md"];
959
1180
  const foundAlt = altPatterns.filter(pat => existsSync(join(p, pat)));
960
1181
  const realAlt = foundAlt.filter(pat => !isSymlink(join(p, pat)));
961
1182
  const symlinkAlt = foundAlt.filter(pat => isSymlink(join(p, pat)));
962
- if (realAlt.length >= 2) {
963
- 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(", ")}` });
964
1185
  }
965
1186
  else if (realAlt.length === 1 && symlinkAlt.length >= 1) {
966
- 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` });
967
1188
  }
968
- else if (foundAlt.length >= 2 && realAlt.length === 0) {
969
- 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` });
970
1191
  }
971
1192
  else if (foundAlt.length === 1) {
972
1193
  const pts = isSymlink(join(p, foundAlt[0])) ? 1 : 3;
973
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` });
974
1195
  }
975
1196
  else {
976
- 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" });
977
1198
  }
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");
1199
+ // SKILLS.md (3 pts) — .github/ or repo root, see resolveDocPath LOCK
1200
+ const skills = resolveDocPath(p, "SKILLS.md");
1201
+ if (skills) {
1202
+ const skillsContent = readFileSync(skills.path, "utf-8");
982
1203
  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` });
1204
+ if (skillsLines > RUBRIC.skillsFull && !isSymlink(skills.path)) {
1205
+ checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines (${skills.rel})` });
985
1206
  }
986
1207
  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` });
1208
+ 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
1209
  }
989
1210
  }
990
1211
  else {
991
- checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing — agents can't discover capabilities" });
1212
+ 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
1213
  }
993
1214
  // .env.example (3 pts) — validates actual content, not just existence
994
1215
  const envExamplePath = join(p, ".env.example");
995
1216
  if (existsSync(envExamplePath)) {
996
1217
  const envContent = readFileSync(envExamplePath, "utf-8");
997
1218
  const envVarLines = envContent.split("\n").filter(l => /^[A-Z_]+=/.test(l.trim())).length;
998
- if (envVarLines >= 3) {
999
- 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` });
1000
1221
  }
1001
1222
  else {
1002
- 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` });
1003
1224
  }
1004
1225
  }
1005
1226
  else {
1006
- 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" });
1007
1228
  }
1008
1229
  // --- Infrastructure (30 points max) ---
1009
1230
  // Git repo (5 pts)
1010
1231
  if (existsSync(join(p, ".git"))) {
1011
- 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" });
1012
1233
  }
1013
1234
  else {
1014
- 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" });
1015
1236
  }
1016
1237
  // .gitignore (3 pts) — validates essential patterns, not just existence
1017
1238
  const gitignoreScorePath = join(p, ".gitignore");
@@ -1019,28 +1240,35 @@ export function scoreProject(dir) {
1019
1240
  const giContent = readFileSync(gitignoreScorePath, "utf-8");
1020
1241
  const essentialPatterns = [".env", "node_modules", "dist", "vendor", ".DS_Store", "*.log"];
1021
1242
  const foundPatterns = essentialPatterns.filter(pat => giContent.includes(pat));
1022
- if (foundPatterns.length >= 3) {
1023
- 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` });
1024
1245
  }
1025
- else if (foundPatterns.length >= 1) {
1026
- 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` });
1027
1248
  }
1028
1249
  else {
1029
- 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)" });
1030
1251
  }
1031
1252
  }
1032
1253
  else {
1033
- checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "Missing" });
1034
- }
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" });
1254
+ checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 2, status: "fail", detail: "Missing" });
1255
+ }
1256
+ // Git hooks (5 pts) — see [ABSENCE-IS-NOT-A-VERDICT]: "installed but broken" is not "not installed"
1257
+ const repoHook = join(p, "hooks", "post-commit");
1258
+ const gitHook = join(p, ".git", "hooks", "post-commit");
1259
+ // existsSync() follows symlinks — a dangling link reads as absent. Check the link itself first.
1260
+ const dangling = [repoHook, gitHook].filter(h => isSymlink(h) && !existsSync(h));
1261
+ const live = [repoHook, gitHook].filter(h => existsSync(h));
1262
+ if (live.length > 0) {
1263
+ const where = live.map(h => h === gitHook ? ".git/hooks/post-commit" : "hooks/post-commit").join(" + ");
1264
+ checks.push({ name: "Git hooks", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: `post-commit hook configured (${where})` });
1265
+ }
1266
+ else if (dangling.length > 0) {
1267
+ const broken = dangling.map(h => `${h === gitHook ? ".git/hooks" : "hooks"}/post-commit → ${readlinkSafe(h)}`).join(", ");
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` });
1041
1269
  }
1042
1270
  else {
1043
- checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 5, status: "fail", detail: "No 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" });
1044
1272
  }
1045
1273
  // Docker / containerization (5 pts)
1046
1274
  // Context-aware: only award points if Docker is actually used for deployment.
@@ -1081,21 +1309,21 @@ export function scoreProject(dir) {
1081
1309
  existsSync(join(p, "fly.toml")) ||
1082
1310
  existsSync(join(p, "railway.json"));
1083
1311
  if (usesDockerForReal && hasDockerfile && hasCompose) {
1084
- 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)" });
1085
1313
  }
1086
1314
  else if (usesDockerForReal && (hasDockerfile || hasCompose)) {
1087
- 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" });
1088
1316
  }
1089
1317
  else if ((hasDockerfile || hasCompose) && !usesDockerForReal) {
1090
1318
  // Files exist but look like stubs/placeholders — minimal credit
1091
- 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" });
1092
1320
  }
1093
1321
  else if (hasAltDeploy) {
1094
1322
  // Project uses a different deploy platform — Docker is N/A, award full points
1095
- 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)" });
1096
1324
  }
1097
1325
  else {
1098
- 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" });
1099
1327
  }
1100
1328
  // CI config (5 pts) — validates workflows have real actions, not empty stubs
1101
1329
  const ciPaths = [".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci", ".travis.yml"];
@@ -1138,14 +1366,14 @@ export function scoreProject(dir) {
1138
1366
  const deployContent = readFileSync(deployFile, "utf-8");
1139
1367
  const deployLines = deployContent.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")).length;
1140
1368
  if (deployLines >= 3) {
1141
- 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)` });
1142
1370
  }
1143
1371
  else {
1144
- 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` });
1145
1373
  }
1146
1374
  }
1147
1375
  else {
1148
- 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" });
1149
1377
  }
1150
1378
  // PM2 / process manager (3 pts)
1151
1379
  if (existsSync(join(p, "ecosystem.config.js")) || existsSync(join(p, "ecosystem.config.cjs"))) {
@@ -1165,10 +1393,10 @@ export function scoreProject(dir) {
1165
1393
  if (testIsSymlink) {
1166
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` });
1167
1395
  }
1168
- else if (testFileCount >= 5) {
1396
+ else if (testFileCount >= RUBRIC.testsFull) {
1169
1397
  checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${foundTests[0]}/ — ${testFileCount} test files` });
1170
1398
  }
1171
- else if (testFileCount > 0) {
1399
+ else if (testFileCount > RUBRIC.testsPartial) {
1172
1400
  checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ — only ${testFileCount} test files` });
1173
1401
  }
1174
1402
  else {
@@ -1195,7 +1423,7 @@ export function scoreProject(dir) {
1195
1423
  const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
1196
1424
  const tsconfigIsSymlink = isSymlink(tsconfigPath);
1197
1425
  // Detect minimal/reference-only tsconfigs (just project references with no real config)
1198
- const isSubstantive = tsconfigContent.length > 50 && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1426
+ const isSubstantive = tsconfigContent.length > RUBRIC.tsconfigSubstantive && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
1199
1427
  if (tsconfigIsSymlink) {
1200
1428
  checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "tsconfig.json is a symlink — create root config" });
1201
1429
  }
@@ -1253,53 +1481,93 @@ export function scoreProject(dir) {
1253
1481
  if (existsSync(gitignorePath)) {
1254
1482
  const gitignore = readFileSync(gitignorePath, "utf-8");
1255
1483
  if (gitignore.includes(".env")) {
1256
- 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" });
1257
1485
  }
1258
1486
  else {
1259
- 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" });
1260
1488
  }
1261
1489
  }
1262
1490
  else {
1263
- 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" });
1264
1492
  }
1265
1493
  // 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") {
1269
- checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 6, status: "fail", detail: ".env is tracked by git!" });
1494
+ // [ABSENCE-IS-NOT-A-VERDICT]: "no .env" and "can't run git here" are different states.
1495
+ // The second is unverifiable and must NOT collect a 6/6 pass.
1496
+ const hasEnv = existsSync(join(p, ".env"));
1497
+ const hasGit = existsSync(join(p, ".git"));
1498
+ if (hasEnv && hasGit) {
1499
+ // [EXEC-FAILURE-IS-NOT-EMPTY]: a failed `git ls-files` must never read as "not tracked".
1500
+ const { ok, output: tracked } = execChecked("git ls-files .env", p);
1501
+ if (!ok) {
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" });
1503
+ }
1504
+ else if (tracked === ".env") {
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" });
1270
1506
  }
1271
1507
  else {
1272
- checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: ".env not tracked" });
1508
+ checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: ".env present and not tracked by git" });
1273
1509
  }
1274
1510
  }
1511
+ else if (!hasEnv) {
1512
+ checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: "No .env at repo root — nothing to leak" });
1513
+ }
1275
1514
  else {
1276
- checks.push({ name: "Secrets exposure", category: "Security", points: 6, maxPoints: 6, status: "pass", detail: "No .env or not a git repo" });
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" });
1277
1516
  }
1278
1517
  // Lockfile present (3 pts)
1279
1518
  const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock"];
1280
1519
  const foundLock = lockfiles.filter(l => existsSync(join(p, l)));
1281
1520
  if (foundLock.length > 0) {
1282
- 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(", ") });
1283
1522
  }
1284
1523
  else if (existsSync(pkgPath) || existsSync(join(p, "composer.json"))) {
1285
- 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" });
1286
1525
  }
1287
1526
  // node_modules in .gitignore (3 pts)
1288
1527
  if (existsSync(gitignorePath)) {
1289
1528
  const gitignore = readFileSync(gitignorePath, "utf-8");
1290
1529
  if (gitignore.includes("node_modules") || gitignore.includes("vendor")) {
1291
- 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" });
1292
1531
  }
1293
1532
  else if (!existsSync(join(p, "package.json")) && !existsSync(join(p, "composer.json"))) {
1294
- 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" });
1295
1534
  }
1296
1535
  else {
1297
- 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" });
1298
1537
  }
1299
1538
  }
1300
1539
  // --- Calculate totals ---
1540
+ // šŸ”’ LOCKED [SCORE-ARITHMETIC-INVARIANT] — 2026-08-13
1541
+ // ā›” NEVER compute the percentage against a summed maxScore without checking it equals 100 first.
1542
+ // WHY: several checks only push a result inside an `if` (e.g. "Deps gitignored" is skipped
1543
+ // entirely when a project has no .gitignore). A skipped check silently SHRANK the
1544
+ // denominator, so a project got a percentage out of 97 while every report — and every
1545
+ // cross-project comparison — presented it as if it were out of 100. Nothing was wrong on
1546
+ // screen; the number was just quietly measuring something else. A missing check is an
1547
+ // unknown, not a smaller exam.
1548
+ // FIX: the denominator is pinned to EXPECTED_MAX_SCORE. Any shortfall becomes a visible
1549
+ // "Scoring completeness" unknown row worth the missing points, and is logged to stderr
1550
+ // (never stdout — MCP protocol stream). Exact, free, unarguable, runs every time.
1551
+ const EXPECTED_MAX_SCORE = 100;
1301
1552
  const totalScore = checks.reduce((sum, c) => sum + c.points, 0);
1302
- const maxScore = checks.reduce((sum, c) => sum + c.maxPoints, 0);
1553
+ const emittedMax = checks.reduce((sum, c) => sum + c.maxPoints, 0);
1554
+ if (emittedMax !== EXPECTED_MAX_SCORE) {
1555
+ const gap = EXPECTED_MAX_SCORE - emittedMax;
1556
+ if (!canaryRunning) {
1557
+ 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`);
1558
+ }
1559
+ if (gap > 0) {
1560
+ checks.push({
1561
+ name: "Scoring completeness",
1562
+ category: "Meta",
1563
+ points: 0,
1564
+ maxPoints: gap,
1565
+ status: "unknown",
1566
+ 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`,
1567
+ });
1568
+ }
1569
+ }
1570
+ const maxScore = Math.max(EXPECTED_MAX_SCORE, emittedMax);
1303
1571
  const percentage = Math.round((totalScore / maxScore) * 100);
1304
1572
  let grade;
1305
1573
  if (percentage >= 90)
@@ -1314,6 +1582,21 @@ export function scoreProject(dir) {
1314
1582
  grade = "D";
1315
1583
  else
1316
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
+ }
1317
1600
  return {
1318
1601
  project: dir.name,
1319
1602
  path: dir.path,
@@ -1372,7 +1655,7 @@ export function formatScoreReport(scores) {
1372
1655
  lines.push("| Check | Category | Score | Status | Detail |");
1373
1656
  lines.push("|-------|----------|-------|--------|--------|");
1374
1657
  for (const c of s.checks) {
1375
- const icon = c.status === "pass" ? "āœ…" : c.status === "partial" ? "🟔" : "āŒ";
1658
+ const icon = statusIcon(c.status);
1376
1659
  lines.push(`| ${c.name} | ${c.category} | ${c.points}/${c.maxPoints} | ${icon} | ${c.detail} |`);
1377
1660
  }
1378
1661
  lines.push("");
@@ -1414,7 +1697,7 @@ export function generateProjectScoreMD(score) {
1414
1697
  lines.push("| Check | Category | Score | Max | Status | Detail |");
1415
1698
  lines.push("|---|---|---|---|---|---|");
1416
1699
  for (const c of score.checks) {
1417
- const icon = c.status === "pass" ? "āœ…" : c.status === "partial" ? "🟔" : "āŒ";
1700
+ const icon = statusIcon(c.status);
1418
1701
  lines.push(`| ${c.name} | ${c.category} | ${c.points} | ${c.maxPoints} | ${icon} | ${c.detail} |`);
1419
1702
  }
1420
1703
  // Failures and improvements
@@ -1429,6 +1712,16 @@ export function generateProjectScoreMD(score) {
1429
1712
  lines.push(`- 🟔 **${p.name}**: ${p.detail}`);
1430
1713
  }
1431
1714
  }
1715
+ // Unverifiable — kept OUT of "Improvements Needed" on purpose. These are checks that could not
1716
+ // reach a verdict; listing them as project failures is what [ABSENCE-IS-NOT-A-VERDICT] forbids.
1717
+ const unknowns = score.checks.filter(c => c.status === "unknown");
1718
+ if (unknowns.length > 0) {
1719
+ lines.push("\n## Could Not Be Verified\n");
1720
+ lines.push("_Gaps in the audit, not defects in the project — the scorer could not determine these._\n");
1721
+ for (const u of unknowns) {
1722
+ lines.push(`- ā” **${u.name}**: ${u.detail}`);
1723
+ }
1724
+ }
1432
1725
  lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
1433
1726
  return lines.join("\n");
1434
1727
  }
@@ -1449,13 +1742,8 @@ export function generateScoreHTML(scores) {
1449
1742
  return "#f97316";
1450
1743
  return "#ef4444";
1451
1744
  }
1452
- function statusIcon(status) {
1453
- if (status === "pass")
1454
- return "āœ…";
1455
- if (status === "partial")
1456
- return "🟔";
1457
- return "āŒ";
1458
- }
1745
+ // statusIcon is module-level — see [ABSENCE-IS-NOT-A-VERDICT]. A local copy here previously
1746
+ // rendered "unknown" as āŒ, which is the exact conflation this work removed.
1459
1747
  function categoryByScore(checks) {
1460
1748
  const m = new Map();
1461
1749
  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
@@ -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.1.3",
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": [