@compr/opscontext-mcp 2.8.0 → 2.8.2

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/CHANGELOG.md CHANGED
@@ -4,6 +4,44 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
4
4
 
5
5
  > Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
6
6
 
7
+ ## [2.8.2] 2026-09-08: every source says what it is
8
+
9
+ ### Added
10
+
11
+ - **One-line summary per source in `list_sources` and `contextengine list-sources`**
12
+ (`src/source-summary.ts`): frontmatter `description` when the file has one, else H1 title
13
+ plus first prose line, else the module docstring or header comment for code. Read from the
14
+ first 4 KB of each file only. Why: with 800+ sources an agent opened two or three whole
15
+ documents to find the right one; the summary lets it pick once.
16
+
17
+ ## [2.8.1] 2026-09-07: a push is not done until its CI is read
18
+
19
+ ### Added
20
+
21
+ - **`end-session` check 3c, CI on HEAD** (`[PUSHED-MEANS-CI-READ]`, `src/ci-status.ts`): every
22
+ workflow run for the exact HEAD sha through `gh run list`; a failed run is a FAIL item (exit 1).
23
+ No gh, no remote, or no runs yet is "not checked", never a pass. Why: main's CI had been red
24
+ on every commit since 2026-09-04 and the Telegram alert fired each time; thirty commits and
25
+ five releases went by with nobody reading it.
26
+
27
+ ### Changed
28
+
29
+ - CI installs the activation server's own packages too: the first time the Test step actually
30
+ ran, `server/src/community-rules-server.test.ts` could not load (`ERR_MODULE_NOT_FOUND`).
31
+ - The Doc Freshness gate fails only when 20 or more source lines change without a doc change; a
32
+ one-line lint fix is not a documented change (its first run paged Telegram for a dash).
33
+ - CI runs on Node 20 and 22 with `fail-fast: false`; Node 18 is EOL and eslint 10 needs 20.19+,
34
+ and the 18 job's failure was cancelling the others before tests ran. `engines.node` is now
35
+ `>=20.19.0`, which is what is tested.
36
+
37
+ ### Fixed
38
+
39
+ - CI was red on every pull request for one `prefer-const` lint error in `src/firewall.ts`.
40
+ - The Doc Freshness workflow failed any push or PR made more than 8 hours after the last
41
+ SKILLS.md commit, whatever the change (two Dependabot bumps on 2026-09-07). It now measures
42
+ the change itself: source touched without a doc touched fails, anything else passes
43
+ (`[DOC-GATE-MEASURES-THE-DIFF]`).
44
+
7
45
  ## [2.8.0] 2026-09-06: health is measured, never estimated
8
46
 
9
47
  ### Added
@@ -0,0 +1,16 @@
1
+ export interface CiRun {
2
+ name: string;
3
+ status: string;
4
+ conclusion: string | null;
5
+ url: string;
6
+ }
7
+ export interface CiStatus {
8
+ sha: string;
9
+ state: "ok" | "failed" | "pending" | "no-runs" | "unavailable";
10
+ runs: CiRun[];
11
+ note?: string;
12
+ }
13
+ export type Runner = (cmd: string, args: string[], cwd: string) => string;
14
+ export declare function ciStatusForHead(cwd: string, run?: Runner): CiStatus;
15
+ export declare function formatCiStatus(s: CiStatus): string[];
16
+ //# sourceMappingURL=ci-status.d.ts.map
@@ -0,0 +1,55 @@
1
+ // [LOCKED] [PUSHED-MEANS-CI-READ] 2026-09-07
2
+ // [NEVER] let end-session pass while a workflow run for HEAD has failed, and [NEVER] count
3
+ // "no runs found" as green.
4
+ // WHY: main's CI had been red on every commit since 2026-09-04 (one lint error, then a Node 18
5
+ // job that eslint 10 cannot run on) and the Telegram alert fired each time. Thirty commits,
6
+ // five releases, nobody read it: the post-commit hook pushes, end-session ran after every
7
+ // push, and nothing in that loop looked at the result. A push is not done until its CI is.
8
+ // FIX: end-session check 3c lists every workflow run for the exact HEAD sha through `gh run
9
+ // list` (hardcoded argv, no shell) and counts a failure as a FAIL item. gh missing, no
10
+ // GitHub remote, or no runs yet is reported as "not checked", never as pass.
11
+ import { execFileSync } from "child_process";
12
+ const defaultRunner = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }).trim();
13
+ export function ciStatusForHead(cwd, run = defaultRunner) {
14
+ let sha = "";
15
+ try {
16
+ sha = run("git", ["rev-parse", "HEAD"], cwd);
17
+ }
18
+ catch {
19
+ return { sha, state: "unavailable", runs: [], note: "not a git repository" };
20
+ }
21
+ let raw = "";
22
+ try {
23
+ raw = run("gh", ["run", "list", "--limit", "40", "--json", "name,status,conclusion,url,headSha"], cwd);
24
+ }
25
+ catch {
26
+ return { sha, state: "unavailable", runs: [], note: "gh not available, not logged in, or no GitHub remote" };
27
+ }
28
+ let all = [];
29
+ try {
30
+ all = JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return { sha, state: "unavailable", runs: [], note: "gh returned no JSON" };
34
+ }
35
+ const runs = all.filter((r) => r.headSha === sha).map(({ name, status, conclusion, url }) => ({ name, status, conclusion, url }));
36
+ if (runs.length === 0)
37
+ return { sha, state: "no-runs", runs, note: "no workflow run for HEAD yet: pushed seconds ago, or CI not wired" };
38
+ const failed = runs.some((r) => r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure");
39
+ const pending = runs.some((r) => r.status !== "completed");
40
+ return { sha, state: failed ? "failed" : pending ? "pending" : "ok", runs };
41
+ }
42
+ export function formatCiStatus(s) {
43
+ const lines = [];
44
+ if (s.state === "unavailable" || s.state === "no-runs") {
45
+ lines.push(`- ⚠️ CI on HEAD${s.sha ? ` ${s.sha.slice(0, 7)}` : ""} not checked: ${s.note}`);
46
+ return lines;
47
+ }
48
+ for (const r of s.runs) {
49
+ const bad = r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure";
50
+ const icon = bad ? "❌ FAIL" : r.status !== "completed" ? "⏳" : r.conclusion === "success" ? "✅" : "▫️";
51
+ lines.push(`- ${icon} ${r.name}: ${r.conclusion ?? r.status}${bad ? ` ${r.url}` : ""}`);
52
+ }
53
+ return lines;
54
+ }
55
+ //# sourceMappingURL=ci-status.js.map
package/dist/cli.js CHANGED
@@ -641,6 +641,7 @@ async function runInit() {
641
641
  // ---------------------------------------------------------------------------
642
642
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir, findProjectRoot, looksLikePath } from "./config.js";
643
643
  import { ingestSources } from "./ingest.js";
644
+ import { summarizeSource } from "./source-summary.js";
644
645
  import { searchChunks } from "./search.js";
645
646
  import { SERVER_COMMANDS, suggestCommands } from "./cli-commands.js";
646
647
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
@@ -657,6 +658,7 @@ import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired
657
658
  import { safeAppend } from "./audit.js";
658
659
  import { listServers, formatServers } from "./server-registry.js";
659
660
  import { computeFleetHealth, formatFleetHealth } from "./fleet-health.js";
661
+ import { ciStatusForHead, formatCiStatus } from "./ci-status.js";
660
662
  import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
661
663
  import { fileURLToPath } from "url";
662
664
  // ---------------------------------------------------------------------------
@@ -742,6 +744,9 @@ async function cliListSources() {
742
744
  const count = chunks.filter((c) => c.source === s.name).length;
743
745
  const status = exists ? `✅ ${count} chunks` : "⚠ file not found";
744
746
  console.log(` ${s.name}: ${status}`);
747
+ const summary = exists ? summarizeSource(s) : "";
748
+ if (summary)
749
+ console.log(` ${summary}`);
745
750
  console.log(` ${s.path}`);
746
751
  }
747
752
  console.log("");
@@ -2238,6 +2243,17 @@ async function cliEndSession() {
2238
2243
  if (fleet.warnings.length > 0)
2239
2244
  failCount += fleet.warnings.length;
2240
2245
  checks.push("");
2246
+ // --- Check 3c: CI on HEAD ([LOCK] [PUSHED-MEANS-CI-READ]) ---
2247
+ checks.push("## 3c. CI on HEAD\n");
2248
+ const ci = ciStatusForHead(process.cwd());
2249
+ checks.push(...formatCiStatus(ci));
2250
+ if (ci.state === "failed") {
2251
+ failCount++;
2252
+ checks.push("- ❌ FAIL: a workflow run for HEAD failed; a push is not done until its CI is");
2253
+ }
2254
+ else if (ci.state === "ok")
2255
+ passCount++;
2256
+ checks.push("");
2241
2257
  checks.push("## 4. Sessions\n");
2242
2258
  const sessions = listSessions();
2243
2259
  if (sessions.length > 0) {
package/dist/firewall.js CHANGED
@@ -185,7 +185,7 @@ export class ProtocolFirewall {
185
185
  if (sessionUrgent && level === "footer")
186
186
  level = "header";
187
187
  // Prepend learning injection to response (always, if available)
188
- let text = injection ? injection + "\n\n" + responseText : responseText;
188
+ const text = injection ? injection + "\n\n" + responseText : responseText;
189
189
  // Build session urgency block (always prepended when overdue)
190
190
  const urgentBlock = sessionUrgent ? this.buildSessionUrgentBlock() : null;
191
191
  if (level === "silent" && !urgentBlock)
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { z } from "zod";
5
5
  import { loadSources, loadProjectDirs, loadConfig, resolveProjectDir } from "./config.js";
6
6
  import { ingestSources } from "./ingest.js";
7
+ import { summarizeSource } from "./source-summary.js";
7
8
  import { searchChunks } from "./search.js";
8
9
  import { initEmbeddings, embedChunks, embedKeyOf, vectorSearch, isEmbeddingsReady, } from "./embeddings.js";
9
10
  import { collectProjectOps, collectSystemOps } from "./collectors.js";
@@ -575,7 +576,7 @@ server.tool("search_context", "Search across all indexed project knowledge (copi
575
576
  // ---------------------------------------------------------------------------
576
577
  // Tool: list_sources
577
578
  // ---------------------------------------------------------------------------
578
- server.tool("list_sources", "List all knowledge sources indexed by ContextEngine, with their status (found/missing) and chunk counts.", {}, async () => {
579
+ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine, each with a one-line summary (from the file's own head: frontmatter description, title plus first sentence, or module docstring), status (found/missing) and chunk counts. Read the summary to pick the right source before calling read_source.", {}, async () => {
579
580
  const lines = sources.map((s) => {
580
581
  const exists = existsSync(s.path);
581
582
  const count = chunks.filter((c) => c.source === s.name).length;
@@ -583,7 +584,8 @@ server.tool("list_sources", "List all knowledge sources indexed by ContextEngine
583
584
  const status = exists
584
585
  ? `✅ ${count} chunks${embeddedCount > 0 ? ` (${embeddedCount} embedded)` : ""}`
585
586
  : "⚠ file not found";
586
- return `${s.name}: ${status}\n ${s.path}`;
587
+ const summary = exists ? summarizeSource(s) : "";
588
+ return `${s.name}: ${status}${summary ? `\n ${summary}` : ""}\n ${s.path}`;
587
589
  });
588
590
  const embStatus = isEmbeddingsReady()
589
591
  ? `✅ ${embeddedChunks.length} vectors`
@@ -0,0 +1,9 @@
1
+ import type { KnowledgeSource } from "./config.js";
2
+ export declare const HEAD_BYTES = 4096;
3
+ export declare const SUMMARY_MAX = 110;
4
+ /** Read at most `bytes` from the start of a file. Empty string on any error. */
5
+ export declare function readHead(path: string, bytes?: number): string;
6
+ /** Summary for a configured source; "" when the file is unreadable or says nothing. */
7
+ export declare function summarizeSource(source: KnowledgeSource): string;
8
+ export declare function summarizeText(text: string, type: "markdown" | "code"): string;
9
+ //# sourceMappingURL=source-summary.d.ts.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * One-line summary per knowledge source, for list_sources (MCP) and
3
+ * `contextengine list-sources` (CLI).
4
+ *
5
+ * Why: with 800+ sources, an agent reading a bare name plus a path opens two
6
+ * or three files to find the right one. Each open puts a whole document into
7
+ * its context (see CLAUDE.md, multi-agent cost). A summary line derived from
8
+ * the first few KB of the file lets it pick once.
9
+ *
10
+ * Reads only the head of the file (HEAD_BYTES), never the whole document, so
11
+ * the cost is bounded no matter how many sources are configured.
12
+ */
13
+ import { openSync, readSync, closeSync } from "fs";
14
+ export const HEAD_BYTES = 4096;
15
+ export const SUMMARY_MAX = 110;
16
+ /** Read at most `bytes` from the start of a file. Empty string on any error. */
17
+ export function readHead(path, bytes = HEAD_BYTES) {
18
+ let fd;
19
+ try {
20
+ fd = openSync(path, "r");
21
+ const buf = Buffer.alloc(bytes);
22
+ const n = readSync(fd, buf, 0, bytes, 0);
23
+ return buf.subarray(0, n).toString("utf-8");
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ finally {
29
+ if (fd !== undefined)
30
+ closeSync(fd);
31
+ }
32
+ }
33
+ /** Summary for a configured source; "" when the file is unreadable or says nothing. */
34
+ export function summarizeSource(source) {
35
+ return summarizeText(readHead(source.path), source.type);
36
+ }
37
+ export function summarizeText(text, type) {
38
+ if (!text)
39
+ return "";
40
+ return clip(type === "code" ? summarizeCode(text) : summarizeMarkdown(text));
41
+ }
42
+ function clip(s) {
43
+ const one = s.replace(/\s+/g, " ").trim();
44
+ if (one.length <= SUMMARY_MAX)
45
+ return one;
46
+ return one.slice(0, SUMMARY_MAX - 3).trimEnd() + "...";
47
+ }
48
+ /** Strip markdown decoration so the line reads as plain text. */
49
+ function plain(line) {
50
+ return line
51
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "") // images
52
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links -> text
53
+ .replace(/`([^`]*)`/g, "$1") // inline code
54
+ .replace(/\*\*|__/g, "") // bold
55
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "") // list bullet
56
+ .replace(/^\s*>\s?/, "") // blockquote
57
+ .trim();
58
+ }
59
+ /** Header metadata lines that say nothing about the content. */
60
+ const META_LINE = /^(updated|last updated|created|date|status|author|owner|version|scope|audience)\b\s*[:\-]/i;
61
+ function summarizeMarkdown(text) {
62
+ let body = text;
63
+ // YAML frontmatter: prefer its description, it IS a one-line summary.
64
+ if (body.startsWith("---\n") || body.startsWith("---\r\n")) {
65
+ const end = body.indexOf("\n---", 4);
66
+ if (end !== -1) {
67
+ const fm = body.slice(4, end);
68
+ const desc = fm.match(/^description:\s*(.+)$/m);
69
+ if (desc)
70
+ return desc[1].trim().replace(/^["']|["']$/g, "");
71
+ body = body.slice(end + 4);
72
+ }
73
+ }
74
+ let title = "";
75
+ let prose = "";
76
+ let inFence = false;
77
+ for (const raw of body.split("\n")) {
78
+ const line = raw.trim();
79
+ if (line.startsWith("```") || line.startsWith("~~~")) {
80
+ inFence = !inFence;
81
+ continue;
82
+ }
83
+ if (inFence || !line)
84
+ continue;
85
+ if (line.startsWith("<!--") || line.startsWith("|") || line.startsWith("![") || line.startsWith("[!"))
86
+ continue;
87
+ if (/^[-=*_]{3,}$/.test(line))
88
+ continue;
89
+ if (line.startsWith("#")) {
90
+ if (!title)
91
+ title = plain(line.replace(/^#+\s*/, ""));
92
+ continue;
93
+ }
94
+ const p = plain(line);
95
+ if (p.length < 12)
96
+ continue; // lone dates, "v2.1", etc.
97
+ if (META_LINE.test(p))
98
+ continue; // "Updated: 2026-03-13", "Status: draft"
99
+ prose = p;
100
+ break;
101
+ }
102
+ if (title && prose)
103
+ return `${title}: ${prose}`;
104
+ return title || prose;
105
+ }
106
+ const CODE_SKIP = /^(#!|\/\/\s*eslint|\/\*\s*eslint|@ts-|#\s*-\*-|#\s*coding[:=]|SPDX|Copyright|\[LOCK|\[LOCKED\]|\[NEVER\]|WHY:|FIX:|import |from |export |const |let |var |use strict|"use strict")/i;
107
+ function summarizeCode(text) {
108
+ const lines = text.split("\n");
109
+ let inBlock = false;
110
+ for (const raw of lines) {
111
+ let line = raw.trim();
112
+ if (!line)
113
+ continue;
114
+ if (!inBlock) {
115
+ const opensBlock = line.startsWith("/*") || line.startsWith('"""') || line.startsWith("'''");
116
+ const closesOnSameLine = (line.startsWith("/*") && /\*\/$/.test(line)) ||
117
+ (/^("""|''')/.test(line) && line.length > 3 && /("""|''')$/.test(line));
118
+ if (CODE_SKIP.test(line)) {
119
+ // Pragma, shebang or marker on the raw line: skip it. A one-line
120
+ // block comment ("/* eslint-disable */") must not leave us in a block.
121
+ if (opensBlock && !closesOnSameLine)
122
+ inBlock = true;
123
+ continue;
124
+ }
125
+ if (opensBlock) {
126
+ inBlock = !closesOnSameLine;
127
+ line = line.replace(/^(\/\*+|"""|''')\s*/, "").replace(/(\*\/|"""|''')\s*$/, "").trim();
128
+ }
129
+ else if (line.startsWith("//") || line.startsWith("#")) {
130
+ line = line.replace(/^(\/\/|#)+\s*/, "").trim();
131
+ }
132
+ else {
133
+ // Code before any comment: nothing worth saying about this file.
134
+ return "";
135
+ }
136
+ }
137
+ else {
138
+ if (/(\*\/|"""|''')$/.test(line))
139
+ inBlock = false;
140
+ line = line.replace(/^\*+\s*/, "").replace(/(\*\/|"""|''')\s*$/, "").trim();
141
+ }
142
+ if (!line || /^[-=*_#]{3,}$/.test(line) || CODE_SKIP.test(line))
143
+ continue;
144
+ if (line.length < 12)
145
+ continue;
146
+ return line;
147
+ }
148
+ return "";
149
+ }
150
+ //# sourceMappingURL=source-summary.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compr/opscontext-mcp",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
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",
@@ -59,7 +59,7 @@
59
59
  "email": "yannick@compr.ch"
60
60
  },
61
61
  "engines": {
62
- "node": ">=18.0.0"
62
+ "node": ">=20.19.0"
63
63
  },
64
64
  "files": [
65
65
  "dist/",