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