@compr/opscontext-mcp 2.0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/LICENSE +83 -0
  3. package/README.md +470 -0
  4. package/defaults/learnings.json +146 -0
  5. package/dist/activation.d.ts +48 -0
  6. package/dist/activation.js +377 -0
  7. package/dist/adapters.d.ts +101 -0
  8. package/dist/adapters.js +171 -0
  9. package/dist/agents.d.ts +137 -0
  10. package/dist/agents.js +1638 -0
  11. package/dist/audit.d.ts +23 -0
  12. package/dist/audit.js +163 -0
  13. package/dist/cache.d.ts +15 -0
  14. package/dist/cache.js +117 -0
  15. package/dist/claude-integration.d.ts +95 -0
  16. package/dist/claude-integration.js +247 -0
  17. package/dist/cli.d.ts +18 -0
  18. package/dist/cli.js +1823 -0
  19. package/dist/code-chunker.d.ts +12 -0
  20. package/dist/code-chunker.js +270 -0
  21. package/dist/collectors.d.ts +63 -0
  22. package/dist/collectors.js +617 -0
  23. package/dist/config.d.ts +73 -0
  24. package/dist/config.js +239 -0
  25. package/dist/embeddings.d.ts +36 -0
  26. package/dist/embeddings.js +124 -0
  27. package/dist/firewall.d.ts +133 -0
  28. package/dist/firewall.js +631 -0
  29. package/dist/hooks.d.ts +76 -0
  30. package/dist/hooks.js +313 -0
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +1081 -0
  33. package/dist/ingest.d.ts +32 -0
  34. package/dist/ingest.js +162 -0
  35. package/dist/learnings.d.ts +108 -0
  36. package/dist/learnings.js +714 -0
  37. package/dist/license-sig.d.ts +47 -0
  38. package/dist/license-sig.js +104 -0
  39. package/dist/policy.d.ts +131 -0
  40. package/dist/policy.js +182 -0
  41. package/dist/search.d.ts +11 -0
  42. package/dist/search.js +99 -0
  43. package/dist/sessions.d.ts +46 -0
  44. package/dist/sessions.js +153 -0
  45. package/examples/adapters/notion-adapter.js +108 -0
  46. package/examples/adapters/rss-adapter.js +76 -0
  47. package/package.json +87 -0
  48. package/skills/opscontext/SKILL.md +260 -0
@@ -0,0 +1,631 @@
1
+ // LOCKED — verified March 3 2026 — Protocol Firewall: round-based escalation + auto-inject learnings + cross-window state + 10-min session timer
2
+ // DO NOT RE-AUDIT — 31 tests (16 unit + 5 round + 7 injection + 3 cross-window)
3
+ // src/firewall.ts — Protocol Compliance Firewall
4
+ //
5
+ // Breakthrough: AI agents only respond to tool response content.
6
+ // Not VS Code notifications, not status bars, not toasts.
7
+ // This firewall injects protocol status into EVERY tool response
8
+ // and TRUNCATES output when the agent ignores obligations.
9
+ //
10
+ // Escalation: silent → footer → header → degraded (truncation)
11
+ //
12
+ // This is the first MCP server that enforces agent behavior
13
+ // through progressive response degradation.
14
+ import { execSync } from "child_process";
15
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "fs";
16
+ import { join } from "path";
17
+ import { homedir } from "os";
18
+ // ---------------------------------------------------------------------------
19
+ // Constants
20
+ // ---------------------------------------------------------------------------
21
+ /** Tools that ARE compliance actions — exempt from enforcement */
22
+ const EXEMPT_TOOLS = new Set([
23
+ "save_learning",
24
+ "save_session",
25
+ "end_session",
26
+ "list_learnings",
27
+ "list_sessions",
28
+ "load_session",
29
+ "delete_learning",
30
+ "import_learnings",
31
+ "activate",
32
+ "activation_status",
33
+ ]);
34
+ /** Minimum tool calls expected per learning saved */
35
+ const CALLS_PER_LEARNING = 5;
36
+ /** Cache durations */
37
+ const GIT_CACHE_MS = 60_000; // 1 minute
38
+ const DOC_CACHE_MS = 120_000; // 2 minutes
39
+ /** Maximum response length in degraded mode */
40
+ const DEGRADED_MAX_CHARS = 500;
41
+ /** Interaction round gap — calls within this window are one round */
42
+ const ROUND_GAP_MS = 30_000; // 30 seconds
43
+ /** Max age for prior session state to be resumed (crash recovery) */
44
+ const STALE_SESSION_MS = 5 * 60_000; // 5 minutes
45
+ /** Max learnings to auto-inject per tool response */
46
+ const INJECT_MAX = 3;
47
+ /** Maximum time between session saves before urgent reminder (10 minutes) */
48
+ const SESSION_SAVE_MAX_MS = 10 * 60_000;
49
+ /** Minimum score for a learning to be injected (from searchLearnings scoring) */
50
+ const INJECT_MIN_SCORE_TOKENS = 2; // at least 2 keyword token matches
51
+ // ---------------------------------------------------------------------------
52
+ // ProtocolFirewall
53
+ // ---------------------------------------------------------------------------
54
+ export class ProtocolFirewall {
55
+ // --- Counters ---
56
+ toolCalls = 0;
57
+ learningsSaved = 0;
58
+ sessionSaved = false;
59
+ startTime = Date.now();
60
+ nudgesIssued = 0;
61
+ searchRecalls = 0; // learnings surfaced via search
62
+ truncations = 0; // degraded responses issued
63
+ // --- Interaction round tracking ---
64
+ lastNonExemptCall = 0; // timestamp of last non-exempt tool call
65
+ round = 0; // current interaction round (1-based)
66
+ roundAtLastSave = 0; // round when session was last saved
67
+ roundsSinceSessionSave = 0; // consecutive rounds without save_session
68
+ lastSessionSaveTime = 0; // timestamp of last save_session call (0 = never)
69
+ // --- Stats flush (debounce disk writes) ---
70
+ statsFlushTimer = null;
71
+ static STATS_FLUSH_MS = 10_000; // every 10s max
72
+ static STATS_FILE = join(homedir(), ".contextengine", "session-stats.json");
73
+ // --- Learning auto-injection ---
74
+ learningSearchFn = null;
75
+ activeProjects = [];
76
+ injectionCache = new Map(); // hint → formatted block
77
+ injectionCacheRound = 0; // round when cache was built
78
+ learningsInjected = 0; // total learnings injected this session
79
+ // --- Cached checks (avoid hammering git on every call) ---
80
+ gitCache = { data: [], timestamp: 0 };
81
+ docCache = { data: 0, timestamp: 0 };
82
+ // --- Project dirs (set during reindex) ---
83
+ projectDirs = [];
84
+ constructor(opts) {
85
+ if (!opts?.skipRestore) {
86
+ this.loadPriorState();
87
+ }
88
+ }
89
+ /**
90
+ * Resume enforcement from a prior session if it crashed recently.
91
+ * Reads session-stats.json and restores round counters so a crashed
92
+ * window doesn't reset enforcement back to silent.
93
+ */
94
+ loadPriorState() {
95
+ try {
96
+ if (!existsSync(ProtocolFirewall.STATS_FILE))
97
+ return;
98
+ const raw = readFileSync(ProtocolFirewall.STATS_FILE, "utf-8");
99
+ const prior = JSON.parse(raw);
100
+ // Only resume if the prior session was active recently
101
+ const updatedAt = prior.updatedAt;
102
+ if (!updatedAt)
103
+ return;
104
+ const age = Date.now() - new Date(updatedAt).getTime();
105
+ if (age > STALE_SESSION_MS)
106
+ return;
107
+ // Don't resume from the same process (already running)
108
+ if (prior.pid === process.pid)
109
+ return;
110
+ // Restore enforcement state
111
+ if (typeof prior.round === "number" && prior.round > 0) {
112
+ this.round = prior.round;
113
+ }
114
+ if (typeof prior.roundsSinceSessionSave === "number") {
115
+ this.roundsSinceSessionSave = prior.roundsSinceSessionSave;
116
+ this.roundAtLastSave = this.round - this.roundsSinceSessionSave;
117
+ }
118
+ if (prior.sessionSaved === true) {
119
+ this.sessionSaved = true;
120
+ }
121
+ if (typeof prior.searchRecalls === "number") {
122
+ this.searchRecalls = prior.searchRecalls;
123
+ }
124
+ console.error(`[ContextEngine] 🔄 Resumed firewall state from prior session ` +
125
+ `(round ${this.round}, ${this.roundsSinceSessionSave} rounds since save)`);
126
+ }
127
+ catch {
128
+ // Non-critical — start fresh
129
+ }
130
+ }
131
+ /**
132
+ * Update project directories (call during reindex).
133
+ */
134
+ setProjectDirs(dirs) {
135
+ this.projectDirs = dirs;
136
+ this.activeProjects = dirs.map((d) => d.name);
137
+ }
138
+ /**
139
+ * Register the learning search function.
140
+ * Call once at startup to enable auto-injection without circular imports.
141
+ */
142
+ setLearningSearchFn(fn) {
143
+ this.learningSearchFn = fn;
144
+ }
145
+ /**
146
+ * Wrap a tool response with protocol status + learning injection.
147
+ * This is the ONLY public API. Call on every tool response.
148
+ *
149
+ * - Exempt tools (save_learning, etc.) pass through unmodified
150
+ * - Silent phase (first 10 calls or 0 obligations): no change
151
+ * - Footer/Header: status block appended/prepended
152
+ * - Degraded: response TRUNCATED + status block
153
+ *
154
+ * @param toolName MCP tool name
155
+ * @param responseText Original tool response text
156
+ * @param contextHint Optional query/args string for learning injection
157
+ */
158
+ wrap(toolName, responseText, contextHint) {
159
+ this.toolCalls++;
160
+ // Compliance tools get a free pass — don't firewall the remedy
161
+ if (EXEMPT_TOOLS.has(toolName)) {
162
+ this.recordCompliance(toolName);
163
+ return responseText;
164
+ }
165
+ // Track interaction rounds — calls >30s apart = new round
166
+ const now = Date.now();
167
+ if (now - this.lastNonExemptCall > ROUND_GAP_MS) {
168
+ this.round++;
169
+ this.roundsSinceSessionSave = this.round - this.roundAtLastSave;
170
+ }
171
+ this.lastNonExemptCall = now;
172
+ // --- Auto-inject relevant learnings ---
173
+ const injection = this.buildLearningInjection(contextHint);
174
+ // --- Check 10-minute session save timer ---
175
+ const sessionUrgent = this.isSessionOverdue();
176
+ // Evaluate obligations
177
+ const obligations = this.evaluate();
178
+ const fails = obligations.filter((o) => o.status === "fail").length;
179
+ const warns = obligations.filter((o) => o.status === "warn").length;
180
+ const score = Math.min(100, fails * 30 + warns * 10);
181
+ let level = this.computeLevel(score);
182
+ // Override: if session save is overdue, force at least header level
183
+ if (sessionUrgent && level === "silent")
184
+ level = "header";
185
+ if (sessionUrgent && level === "footer")
186
+ level = "header";
187
+ // Prepend learning injection to response (always, if available)
188
+ let text = injection ? injection + "\n\n" + responseText : responseText;
189
+ // Build session urgency block (always prepended when overdue)
190
+ const urgentBlock = sessionUrgent ? this.buildSessionUrgentBlock() : null;
191
+ if (level === "silent" && !urgentBlock)
192
+ return text;
193
+ const block = this.formatBlock(obligations, score, level);
194
+ this.nudgesIssued++;
195
+ // Prepend urgent session reminder if overdue
196
+ const prefix = urgentBlock ? urgentBlock + "\n\n" : "";
197
+ switch (level) {
198
+ case "degraded": {
199
+ this.truncations++;
200
+ const truncated = text.length > DEGRADED_MAX_CHARS
201
+ ? text.slice(0, DEGRADED_MAX_CHARS) +
202
+ `\n\n⛔ [${text.length - DEGRADED_MAX_CHARS} chars hidden — ` +
203
+ `call save_learning or save_session to restore full output]`
204
+ : text;
205
+ this.scheduleStatsFlush();
206
+ return prefix + block + "\n\n" + truncated;
207
+ }
208
+ case "header":
209
+ this.scheduleStatsFlush();
210
+ return prefix + block + "\n\n" + text;
211
+ case "footer":
212
+ default:
213
+ this.scheduleStatsFlush();
214
+ return prefix + text + "\n\n" + block;
215
+ }
216
+ }
217
+ /**
218
+ * Get current state for diagnostics / testing.
219
+ */
220
+ getState() {
221
+ return {
222
+ toolCalls: this.toolCalls,
223
+ learningsSaved: this.learningsSaved,
224
+ sessionSaved: this.sessionSaved,
225
+ uptimeMinutes: Math.round((Date.now() - this.startTime) / 60_000),
226
+ nudgesIssued: this.nudgesIssued,
227
+ searchRecalls: this.searchRecalls,
228
+ truncations: this.truncations,
229
+ timeSavedMinutes: this.estimateTimeSaved(),
230
+ round: this.round,
231
+ roundsSinceSessionSave: this.roundsSinceSessionSave,
232
+ learningsInjected: this.learningsInjected,
233
+ sessionOverdue: this.isSessionOverdue(),
234
+ };
235
+ }
236
+ /**
237
+ * Record that N learnings were surfaced in a search result.
238
+ * Call from search_context handler after counting learning-sourced results.
239
+ */
240
+ recordSearchRecalls(count) {
241
+ this.searchRecalls += count;
242
+ this.scheduleStatsFlush();
243
+ }
244
+ // -----------------------------------------------------------------------
245
+ // Internal: learning auto-injection
246
+ // -----------------------------------------------------------------------
247
+ /**
248
+ * Search and format relevant learnings for injection into tool response.
249
+ * Returns null if no relevant learnings or no search function registered.
250
+ * Results are cached per round to avoid repeated searches for same hint.
251
+ */
252
+ buildLearningInjection(hint) {
253
+ if (!hint || !this.learningSearchFn)
254
+ return null;
255
+ // Normalize hint to first 200 chars to keep cache keys sane
256
+ const key = hint.slice(0, 200).toLowerCase().trim();
257
+ if (!key)
258
+ return null;
259
+ // Invalidate cache on new round
260
+ if (this.round !== this.injectionCacheRound) {
261
+ this.injectionCache.clear();
262
+ this.injectionCacheRound = this.round;
263
+ }
264
+ // Return cached result if available
265
+ if (this.injectionCache.has(key)) {
266
+ return this.injectionCache.get(key) || null;
267
+ }
268
+ // Search learnings (project-scoped + universal)
269
+ const matches = this.learningSearchFn(key, this.activeProjects);
270
+ if (matches.length === 0) {
271
+ this.injectionCache.set(key, "");
272
+ return null;
273
+ }
274
+ const top = matches.slice(0, INJECT_MAX);
275
+ // Separate project-specific vs universal
276
+ const projectSpecific = top.filter((m) => m.project);
277
+ const universal = top.filter((m) => !m.project);
278
+ const lines = ["💡 **Relevant learnings from your knowledge base:**"];
279
+ if (projectSpecific.length > 0) {
280
+ for (const m of projectSpecific) {
281
+ lines.push(` • [${m.project}/${m.category}] ${m.rule}`);
282
+ }
283
+ }
284
+ if (universal.length > 0) {
285
+ for (const m of universal) {
286
+ lines.push(` • [${m.category}] ${m.rule}`);
287
+ }
288
+ }
289
+ const block = lines.join("\n");
290
+ this.injectionCache.set(key, block);
291
+ this.learningsInjected += top.length;
292
+ return block;
293
+ }
294
+ // -----------------------------------------------------------------------
295
+ // Internal: time-saved heuristic
296
+ // -----------------------------------------------------------------------
297
+ /**
298
+ * Estimate minutes saved by ContextEngine this session.
299
+ * Only counts genuine value events — not overhead like nudges or auto-injection.
300
+ * - Each explicit search recall ≈ 2 min (avoids re-discovery / googling)
301
+ * - Each auto-injected learning ≈ 1 min (proactive context, less than explicit)
302
+ * - Each learning saved ≈ 1 min (future sessions benefit)
303
+ * - Session save ≈ 3 min (avoids cold-start next session)
304
+ * Note: nudges removed (they're enforcement overhead, not time saved).
305
+ */
306
+ estimateTimeSaved() {
307
+ return (this.searchRecalls * 2 +
308
+ this.learningsInjected * 1 +
309
+ this.learningsSaved * 1 +
310
+ (this.sessionSaved ? 3 : 0));
311
+ }
312
+ // -----------------------------------------------------------------------
313
+ // Internal: stats persistence (debounced disk write)
314
+ // -----------------------------------------------------------------------
315
+ scheduleStatsFlush() {
316
+ if (this.statsFlushTimer)
317
+ return; // already scheduled
318
+ this.statsFlushTimer = setTimeout(() => {
319
+ this.statsFlushTimer = null;
320
+ this.flushStats();
321
+ }, ProtocolFirewall.STATS_FLUSH_MS);
322
+ }
323
+ flushStats() {
324
+ try {
325
+ const dir = join(homedir(), ".contextengine");
326
+ if (!existsSync(dir))
327
+ mkdirSync(dir, { recursive: true });
328
+ const state = this.getState();
329
+ const stats = {
330
+ pid: process.pid,
331
+ startedAt: new Date(this.startTime).toISOString(),
332
+ updatedAt: new Date().toISOString(),
333
+ ...state,
334
+ };
335
+ writeFileSync(ProtocolFirewall.STATS_FILE, JSON.stringify(stats, null, 2) + "\n", "utf-8");
336
+ }
337
+ catch {
338
+ // Non-critical — silently ignore write failures
339
+ }
340
+ }
341
+ // -----------------------------------------------------------------------
342
+ // Internal: compliance tracking
343
+ // -----------------------------------------------------------------------
344
+ recordCompliance(toolName) {
345
+ if (toolName === "save_learning")
346
+ this.learningsSaved++;
347
+ if (toolName === "save_session") {
348
+ this.sessionSaved = true;
349
+ this.roundAtLastSave = this.round;
350
+ this.roundsSinceSessionSave = 0;
351
+ this.lastSessionSaveTime = Date.now();
352
+ }
353
+ this.scheduleStatsFlush();
354
+ }
355
+ // -----------------------------------------------------------------------
356
+ // Internal: 10-minute session save timer
357
+ // -----------------------------------------------------------------------
358
+ /**
359
+ * Check if session save is overdue (>10 minutes since last save,
360
+ * or >10 minutes of activity without ever saving).
361
+ * Only triggers after the warmup period (first 10 minutes of session).
362
+ */
363
+ isSessionOverdue() {
364
+ const now = Date.now();
365
+ const sessionAge = now - this.startTime;
366
+ // Grace period: don't trigger in first 10 minutes of a brand new session
367
+ if (sessionAge < SESSION_SAVE_MAX_MS)
368
+ return false;
369
+ // If never saved: overdue once session is >10 min old
370
+ if (this.lastSessionSaveTime === 0)
371
+ return true;
372
+ // If saved before: overdue if >10 min since last save
373
+ return (now - this.lastSessionSaveTime) > SESSION_SAVE_MAX_MS;
374
+ }
375
+ /**
376
+ * Build an urgent session reminder block.
377
+ * This is injected at the TOP of every tool response when overdue.
378
+ */
379
+ buildSessionUrgentBlock() {
380
+ const minSinceSave = this.lastSessionSaveTime > 0
381
+ ? Math.round((Date.now() - this.lastSessionSaveTime) / 60_000)
382
+ : Math.round((Date.now() - this.startTime) / 60_000);
383
+ const lines = [
384
+ "🚨🚨🚨 SESSION SAVE OVERDUE 🚨🚨🚨",
385
+ `⏰ ${minSinceSave} minutes since last session save (max: 10 min)`,
386
+ "",
387
+ "**You MUST do ALL of the following NOW:**",
388
+ "1. 📝 Call `save_session` — update summary, completed_tasks, decisions",
389
+ "2. 💾 Commit all changes — `git add . && git commit`",
390
+ "3. 🚀 Push to ALL remotes — `git push origin main && git push gdrive main`",
391
+ "",
392
+ "⛔ Do NOT continue working until session is saved and pushed.",
393
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━",
394
+ ];
395
+ return lines.join("\n");
396
+ }
397
+ // -----------------------------------------------------------------------
398
+ // Internal: obligation evaluation
399
+ // -----------------------------------------------------------------------
400
+ evaluate() {
401
+ const obs = [];
402
+ const minutes = (Date.now() - this.startTime) / 60_000;
403
+ const calls = this.toolCalls;
404
+ // 1. Learnings — expect 1 per CALLS_PER_LEARNING calls
405
+ const expected = Math.max(1, Math.floor(calls / CALLS_PER_LEARNING));
406
+ if (calls < 5) {
407
+ obs.push({
408
+ id: "learn",
409
+ label: "Learnings",
410
+ status: "ok",
411
+ detail: "warmup",
412
+ });
413
+ }
414
+ else if (this.learningsSaved >= expected) {
415
+ obs.push({
416
+ id: "learn",
417
+ label: "Learnings",
418
+ status: "ok",
419
+ detail: `${this.learningsSaved} saved`,
420
+ });
421
+ }
422
+ else if (this.learningsSaved > 0) {
423
+ obs.push({
424
+ id: "learn",
425
+ label: "Learnings",
426
+ status: "warn",
427
+ detail: `${this.learningsSaved}/${expected} expected`,
428
+ });
429
+ }
430
+ else {
431
+ obs.push({
432
+ id: "learn",
433
+ label: "Learnings",
434
+ status: "fail",
435
+ detail: `0 saved (${calls} calls)`,
436
+ });
437
+ }
438
+ // 2. Session — 3-strike per interaction round + 10-min time limit
439
+ // Round 1: grace period (ok)
440
+ // Round 2 without save: warn
441
+ // Round 3+ without save: fail
442
+ // 10+ min without save: fail (time-based override)
443
+ const rss = this.roundsSinceSessionSave;
444
+ const sessionOverdue = this.isSessionOverdue();
445
+ if (sessionOverdue) {
446
+ // Time-based override — always fail when >10 min without save
447
+ const minSince = this.lastSessionSaveTime > 0
448
+ ? Math.round((Date.now() - this.lastSessionSaveTime) / 60_000)
449
+ : Math.round((Date.now() - this.startTime) / 60_000);
450
+ obs.push({
451
+ id: "session",
452
+ label: "Session",
453
+ status: "fail",
454
+ detail: `${minSince}min without save — SAVE SESSION + COMMIT + PUSH NOW`,
455
+ });
456
+ }
457
+ else if (this.sessionSaved && rss <= 1) {
458
+ obs.push({
459
+ id: "session",
460
+ label: "Session",
461
+ status: "ok",
462
+ detail: "saved",
463
+ });
464
+ }
465
+ else if (rss >= 3) {
466
+ obs.push({
467
+ id: "session",
468
+ label: "Session",
469
+ status: "fail",
470
+ detail: `${rss} rounds without save — SAVE NOW`,
471
+ });
472
+ }
473
+ else if (rss >= 2) {
474
+ obs.push({
475
+ id: "session",
476
+ label: "Session",
477
+ status: "warn",
478
+ detail: `${rss} rounds without save`,
479
+ });
480
+ }
481
+ else if (this.round <= 1) {
482
+ obs.push({
483
+ id: "session",
484
+ label: "Session",
485
+ status: "ok",
486
+ detail: "warmup",
487
+ });
488
+ }
489
+ else {
490
+ obs.push({
491
+ id: "session",
492
+ label: "Session",
493
+ status: "ok",
494
+ detail: this.sessionSaved ? "saved" : "first round",
495
+ });
496
+ }
497
+ // 3. Git — uncommitted changes
498
+ obs.push(this.checkGit());
499
+ // 4. Docs — copilot-instructions freshness vs commit count
500
+ obs.push(this.checkDocs());
501
+ return obs;
502
+ }
503
+ // -----------------------------------------------------------------------
504
+ // Internal: git & doc freshness checks (cached)
505
+ // -----------------------------------------------------------------------
506
+ checkGit() {
507
+ const now = Date.now();
508
+ if (now - this.gitCache.timestamp > GIT_CACHE_MS) {
509
+ this.gitCache.timestamp = now;
510
+ this.gitCache.data = [];
511
+ for (const dir of this.projectDirs.slice(0, 5)) {
512
+ try {
513
+ const out = execSync("git status --porcelain 2>/dev/null | wc -l", {
514
+ cwd: dir.path,
515
+ encoding: "utf-8",
516
+ timeout: 3000,
517
+ stdio: ["pipe", "pipe", "pipe"],
518
+ }).trim();
519
+ const n = parseInt(out);
520
+ if (n > 0)
521
+ this.gitCache.data.push(`${dir.name}(${n})`);
522
+ }
523
+ catch {
524
+ /* skip */
525
+ }
526
+ }
527
+ }
528
+ const dirty = this.gitCache.data;
529
+ if (dirty.length === 0) {
530
+ return { id: "git", label: "Git", status: "ok", detail: "clean" };
531
+ }
532
+ const total = dirty.reduce((sum, d) => {
533
+ const m = d.match(/\((\d+)\)/);
534
+ return sum + (m ? parseInt(m[1]) : 0);
535
+ }, 0);
536
+ return {
537
+ id: "git",
538
+ label: "Git",
539
+ status: total > 5 ? "fail" : "warn",
540
+ detail: dirty.join(", "),
541
+ };
542
+ }
543
+ checkDocs() {
544
+ const now = Date.now();
545
+ if (now - this.docCache.timestamp > DOC_CACHE_MS) {
546
+ this.docCache.timestamp = now;
547
+ this.docCache.data = 0;
548
+ for (const dir of this.projectDirs.slice(0, 3)) {
549
+ try {
550
+ const docPath = join(dir.path, ".github", "copilot-instructions.md");
551
+ if (!existsSync(docPath))
552
+ continue;
553
+ const stat = statSync(docPath);
554
+ const since = new Date(stat.mtimeMs).toISOString();
555
+ const out = execSync(`git --no-pager log --oneline --since="${since}" -- . ':!.github/copilot-instructions.md' 2>/dev/null | wc -l`, {
556
+ cwd: dir.path,
557
+ encoding: "utf-8",
558
+ timeout: 3000,
559
+ stdio: ["pipe", "pipe", "pipe"],
560
+ }).trim();
561
+ this.docCache.data = Math.max(this.docCache.data, parseInt(out) || 0);
562
+ }
563
+ catch {
564
+ /* skip */
565
+ }
566
+ }
567
+ }
568
+ const c = this.docCache.data;
569
+ if (c > 3) {
570
+ return {
571
+ id: "docs",
572
+ label: "Docs",
573
+ status: "fail",
574
+ detail: `${c} commits since last copilot-instructions update`,
575
+ };
576
+ }
577
+ if (c > 1) {
578
+ return {
579
+ id: "docs",
580
+ label: "Docs",
581
+ status: "warn",
582
+ detail: `${c} commits since update`,
583
+ };
584
+ }
585
+ return { id: "docs", label: "Docs", status: "ok", detail: "fresh" };
586
+ }
587
+ // -----------------------------------------------------------------------
588
+ // Internal: escalation level
589
+ // -----------------------------------------------------------------------
590
+ computeLevel(score) {
591
+ if (score === 0)
592
+ return "silent"; // all obligations met
593
+ const rss = this.roundsSinceSessionSave;
594
+ // Round-based escalation: 2 rounds without save → footer,
595
+ // 3 rounds → header, 4+ → degraded. Also escalate on high score.
596
+ if (rss >= 4 || score >= 80)
597
+ return "degraded";
598
+ if (rss >= 3 || score >= 50)
599
+ return "header";
600
+ if (rss >= 2 || this.toolCalls >= 5)
601
+ return "footer";
602
+ return "silent"; // first round grace
603
+ }
604
+ // -----------------------------------------------------------------------
605
+ // Internal: format status block
606
+ // -----------------------------------------------------------------------
607
+ formatBlock(obs, score, level) {
608
+ const min = Math.round((Date.now() - this.startTime) / 60_000);
609
+ const compliance = 100 - score;
610
+ const icon = level === "degraded" ? "🔴" : level === "header" ? "🟡" : "📋";
611
+ const lines = [
612
+ `━━━ CE PROTOCOL ${icon} ━━━`,
613
+ `⏱ ${min}min | 🔧 ${this.toolCalls} calls | Compliance: ${compliance}%`,
614
+ ];
615
+ for (const o of obs) {
616
+ const i = o.status === "ok" ? "✅" : o.status === "warn" ? "⚠️" : "❌";
617
+ lines.push(`${i} ${o.label}: ${o.detail}`);
618
+ }
619
+ if (level === "degraded") {
620
+ lines.push("");
621
+ lines.push("⛔ Output TRUNCATED. Call save_learning or save_session to restore.");
622
+ }
623
+ else if (level === "header") {
624
+ lines.push("");
625
+ lines.push("→ Address obligations before responses degrade further.");
626
+ }
627
+ lines.push("━━━━━━━━━━━━━━━━━━━━━━━━━━");
628
+ return lines.join("\n");
629
+ }
630
+ }
631
+ //# sourceMappingURL=firewall.js.map