@iamoberlin/chorus 2.3.0 → 2.4.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/index.ts CHANGED
@@ -644,6 +644,49 @@ const plugin = {
644
644
  console.log("");
645
645
  });
646
646
 
647
+ // ── Behavioral Sink Health (Universe 25) ──────────────────
648
+ program
649
+ .command("health")
650
+ .description("Behavioral sink health check (Universe 25 anti-collapse)")
651
+ .action(async () => {
652
+ const { getSinkHealthSummary } = await import("./src/behavioral-sink.js");
653
+ const health = getSinkHealthSummary();
654
+ console.log("");
655
+ console.log(health.healthy ? "🟢 CHORUS Health: HEALTHY" : "🔴 CHORUS Health: DEGRADED");
656
+ console.log("═".repeat(50));
657
+ console.log("");
658
+
659
+ if (Object.keys(health.choirHealth).length === 0) {
660
+ console.log(" No quality data yet. Run some choir cycles first.");
661
+ } else {
662
+ console.log(" Choir Quality (avg over last 10 runs):");
663
+ for (const [id, h] of Object.entries(health.choirHealth) as any) {
664
+ const bar = "█".repeat(Math.round(h.avgQuality / 5)) + "░".repeat(20 - Math.round(h.avgQuality / 5));
665
+ const trendIcon = h.trend === "improving" ? "📈" : h.trend === "declining" ? "📉" : "➡️";
666
+ const boAlert = h.beautifulOneCount > 0 ? ` 🪞×${h.beautifulOneCount}` : "";
667
+ console.log(` ${id.padEnd(16)} [${bar}] ${h.avgQuality} ${trendIcon}${boAlert}`);
668
+ }
669
+ }
670
+
671
+ if (health.starvingPurposes.length > 0) {
672
+ console.log("");
673
+ console.log(" 🏚️ Starving Purposes:");
674
+ for (const p of health.starvingPurposes) {
675
+ console.log(` - ${p}`);
676
+ }
677
+ }
678
+
679
+ if (health.recentIncidents.length > 0) {
680
+ console.log("");
681
+ console.log(" 🪞 Recent Beautiful One Incidents:");
682
+ for (const i of health.recentIncidents) {
683
+ console.log(` ${i.timestamp.slice(0, 16)} ${i.choirId} (score: ${i.score})`);
684
+ }
685
+ }
686
+
687
+ console.log("");
688
+ });
689
+
647
690
  // Daemon commands
648
691
  const daemonCmd = program.command("daemon").description("Autonomous attention daemon");
649
692
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iamoberlin/chorus",
3
- "version": "2.3.0",
4
- "description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement with on-chain Prayer Chain (Solana)",
3
+ "version": "2.4.0",
4
+ "description": "CHORUS: Hierarchy Of Recursive Unified Self-improvement \u2014 with on-chain Prayer Chain (Solana)",
5
5
  "author": "Oberlin <iam@oberlin.ai>",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -0,0 +1,434 @@
1
+ /**
2
+ * CHORUS Behavioral Sink Prevention
3
+ *
4
+ * Inspired by John B. Calhoun's Universe 25 (1968-1972):
5
+ * A mouse utopia with unlimited resources collapsed not from scarcity,
6
+ * but from loss of purpose and structural role degradation.
7
+ *
8
+ * Three failure modes mapped to CHORUS:
9
+ *
10
+ * 1. BEAUTIFUL ONES — Mice that looked perfect but did nothing.
11
+ * → Choirs producing verbose but vacuous output. Busy ≠ productive.
12
+ *
13
+ * 2. BEHAVIORAL SINK — Pathological behaviors spreading contagiously.
14
+ * → Degraded illumination cascading through the choir hierarchy.
15
+ *
16
+ * 3. TERRITORIAL EXCLUSION — Dominant mice monopolizing resources,
17
+ * forcing others into withdrawal.
18
+ * → Purpose starvation: dominant purposes crowding out others.
19
+ *
20
+ * "Purpose prevents extinction."
21
+ */
22
+
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
24
+ import { join } from "path";
25
+ import { homedir } from "os";
26
+
27
+ const SINK_STATE_DIR = join(homedir(), ".chorus", "behavioral-sink");
28
+ const SINK_STATE_PATH = join(SINK_STATE_DIR, "state.json");
29
+
30
+ // ── Beautiful Ones Detection ─────────────────────────────────
31
+ // Detect when choir output is present but substanceless.
32
+
33
+ export interface QualitySignal {
34
+ score: number; // 0-100: 0 = vacuous, 100 = highly substantive
35
+ actionsTaken: number; // File writes, commands run, state changes
36
+ novelty: number; // 0-100: how different from last N outputs
37
+ flags: string[]; // Human-readable quality concerns
38
+ }
39
+
40
+ /**
41
+ * Analyze choir output quality. Returns a signal indicating whether
42
+ * the output represents real work or beautiful-one behavior.
43
+ */
44
+ export function assessOutputQuality(
45
+ choirId: string,
46
+ output: string,
47
+ previousOutputs: string[] = []
48
+ ): QualitySignal {
49
+ const flags: string[] = [];
50
+ let score = 50; // Start neutral
51
+
52
+ // ── Length check (too short = no work, too long = possibly padding)
53
+ if (output.length < 100) {
54
+ score -= 20;
55
+ flags.push("minimal_output");
56
+ } else if (output.length > 50) {
57
+ score += 10;
58
+ }
59
+
60
+ // ── Action density: Did it actually DO things?
61
+ const actionPatterns = [
62
+ /(?:updated|wrote|created|modified|edited|deleted|committed|pushed|deployed)\s/gi,
63
+ /(?:wrote to|saved|appended)\s+[^\s]+/gi,
64
+ /(?:ran|executed|running)\s+/gi,
65
+ /(?:changed|set|configured|enabled|disabled)\s/gi,
66
+ /(?:fixed|resolved|patched|upgraded)\s/gi,
67
+ ];
68
+ let actionsTaken = 0;
69
+ for (const pattern of actionPatterns) {
70
+ const matches = output.match(pattern);
71
+ if (matches) actionsTaken += matches.length;
72
+ }
73
+
74
+ if (actionsTaken === 0 && !["angels", "seraphim"].includes(choirId)) {
75
+ score -= 15;
76
+ flags.push("no_actions_detected");
77
+ } else if (actionsTaken >= 3) {
78
+ score += 20;
79
+ } else if (actionsTaken >= 1) {
80
+ score += 10;
81
+ }
82
+
83
+ // ── Filler detection: Generic phrases that pad without substance
84
+ const fillerPatterns = [
85
+ /everything (?:looks|seems|appears) (?:good|fine|stable|normal|healthy)/gi,
86
+ /no (?:significant |major |notable )?(?:changes|updates|issues|concerns|findings)/gi,
87
+ /(?:continuing|continue) to monitor/gi,
88
+ /(?:all|everything) (?:is )?(?:on track|aligned|in order)/gi,
89
+ /nothing (?:urgent|notable|significant) to report/gi,
90
+ /mission (?:remains |is )?(?:clear|aligned|unchanged)/gi,
91
+ /no (?:drift|deviation) detected/gi,
92
+ ];
93
+ let fillerCount = 0;
94
+ for (const pattern of fillerPatterns) {
95
+ const matches = output.match(pattern);
96
+ if (matches) fillerCount += matches.length;
97
+ }
98
+
99
+ if (fillerCount >= 3) {
100
+ score -= 25;
101
+ flags.push("high_filler_ratio");
102
+ } else if (fillerCount >= 1) {
103
+ score -= 5;
104
+ }
105
+
106
+ // ── Specificity: Does it contain concrete data (numbers, names, dates)?
107
+ const specificityPatterns = [
108
+ /\$[\d,]+(?:\.\d+)?/g, // Dollar amounts
109
+ /\d+(?:\.\d+)?%/g, // Percentages
110
+ /\d{4}-\d{2}-\d{2}/g, // Dates
111
+ /(?:PI|VIX|DXY|BTC|ETH)\s*(?:at|=|:)\s*[\d.,$]+/gi, // Market refs
112
+ ];
113
+ let specificityCount = 0;
114
+ for (const pattern of specificityPatterns) {
115
+ const matches = output.match(pattern);
116
+ if (matches) specificityCount += matches.length;
117
+ }
118
+
119
+ if (specificityCount >= 3) {
120
+ score += 15;
121
+ } else if (specificityCount === 0 && !["angels"].includes(choirId)) {
122
+ score -= 10;
123
+ flags.push("low_specificity");
124
+ }
125
+
126
+ // ── Novelty: Is this substantially different from recent outputs?
127
+ let novelty = 100;
128
+ if (previousOutputs.length > 0) {
129
+ const similarities = previousOutputs.map(prev => computeSimilarity(output, prev));
130
+ const maxSimilarity = Math.max(...similarities);
131
+
132
+ if (maxSimilarity > 0.85) {
133
+ novelty = 10;
134
+ score -= 20;
135
+ flags.push("near_duplicate_output");
136
+ } else if (maxSimilarity > 0.65) {
137
+ novelty = 40;
138
+ score -= 10;
139
+ flags.push("repetitive_output");
140
+ } else {
141
+ novelty = Math.round((1 - maxSimilarity) * 100);
142
+ score += 5;
143
+ }
144
+ }
145
+
146
+ // ── HEARTBEAT_OK is fine for Angels, vacuous for everything else
147
+ const isHeartbeat = output.trim().toUpperCase().includes("HEARTBEAT_OK");
148
+ if (isHeartbeat) {
149
+ if (choirId === "angels") {
150
+ return { score: 50, actionsTaken: 0, novelty: 0, flags: ["heartbeat_ack"] };
151
+ } else {
152
+ score = 5;
153
+ flags.push("beautiful_one_heartbeat");
154
+ }
155
+ }
156
+
157
+ // Clamp score
158
+ score = Math.max(0, Math.min(100, score));
159
+
160
+ return { score, actionsTaken, novelty, flags };
161
+ }
162
+
163
+ /**
164
+ * Simple bag-of-words Jaccard similarity.
165
+ * Fast enough for choir output comparison without external deps.
166
+ */
167
+ function computeSimilarity(a: string, b: string): number {
168
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 3));
169
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 3));
170
+
171
+ if (wordsA.size === 0 && wordsB.size === 0) return 1;
172
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
173
+
174
+ let intersection = 0;
175
+ for (const w of wordsA) {
176
+ if (wordsB.has(w)) intersection++;
177
+ }
178
+
179
+ const union = wordsA.size + wordsB.size - intersection;
180
+ return union > 0 ? intersection / union : 0;
181
+ }
182
+
183
+ // ── Behavioral Sink (Illumination Quality Gate) ──────────────
184
+ // Prevent degraded context from cascading downstream.
185
+
186
+ export interface IlluminationAssessment {
187
+ sourceChoir: string;
188
+ quality: QualitySignal;
189
+ usable: boolean; // Should downstream choirs use this context?
190
+ degraded: boolean; // Is this a degradation from normal quality?
191
+ recommendation: string; // What to do
192
+ }
193
+
194
+ /**
195
+ * Assess whether illumination from an upstream choir should be
196
+ * passed downstream, or whether it would propagate a behavioral sink.
197
+ */
198
+ export function assessIllumination(
199
+ sourceChoir: string,
200
+ output: string,
201
+ historicalQuality: number[] = []
202
+ ): IlluminationAssessment {
203
+ const quality = assessOutputQuality(sourceChoir, output);
204
+
205
+ // Is this a degradation from the choir's normal quality?
206
+ let degraded = false;
207
+ if (historicalQuality.length >= 3) {
208
+ const avgHistorical = historicalQuality.reduce((a, b) => a + b, 0) / historicalQuality.length;
209
+ degraded = quality.score < avgHistorical - 20;
210
+ }
211
+
212
+ // Below 25 = do not pass downstream (sink prevention)
213
+ const usable = quality.score >= 25;
214
+
215
+ let recommendation: string;
216
+ if (!usable) {
217
+ recommendation = `SINK PREVENTION: ${sourceChoir} output below quality threshold (${quality.score}/100). Context NOT passed to downstream choirs. Flags: ${quality.flags.join(", ")}`;
218
+ } else if (degraded) {
219
+ recommendation = `DEGRADATION WARNING: ${sourceChoir} quality dropped from avg ${Math.round(historicalQuality.reduce((a, b) => a + b, 0) / historicalQuality.length)} to ${quality.score}. Context passed with warning.`;
220
+ } else {
221
+ recommendation = "OK";
222
+ }
223
+
224
+ return { sourceChoir, quality, usable, degraded, recommendation };
225
+ }
226
+
227
+ // ── Territorial Exclusion Prevention (Purpose Fairness) ──────
228
+ // Ensure no purpose monopolizes execution at the expense of others.
229
+
230
+ export interface FairnessScore {
231
+ purposeId: string;
232
+ expectedInterval: number; // ms between runs (from frequency config)
233
+ actualInterval: number; // ms since last run
234
+ starvationRatio: number; // actual / expected — >2.0 = starving
235
+ starving: boolean;
236
+ priorityBoost: number; // Multiplier to apply to scheduling priority
237
+ }
238
+
239
+ /**
240
+ * Calculate starvation-aware priority for purpose scheduling.
241
+ * Replaces pure deadline-based sorting with fairness weighting.
242
+ *
243
+ * Universe 25 lesson: Dominant mice monopolized prime territory.
244
+ * Fix: overdue purposes get exponentially increasing priority.
245
+ */
246
+ export function calculateFairness(
247
+ purposeId: string,
248
+ lastRun: number | undefined,
249
+ frequencyPerDay: number,
250
+ deadline?: number
251
+ ): FairnessScore {
252
+ const expectedInterval = (24 * 60 * 60 * 1000) / Math.max(frequencyPerDay, 1);
253
+ const actualInterval = lastRun ? Date.now() - lastRun : expectedInterval * 10; // Never run = very overdue
254
+
255
+ const starvationRatio = actualInterval / expectedInterval;
256
+ const starving = starvationRatio >= 2.0;
257
+
258
+ // Priority boost: exponential with starvation ratio
259
+ // At 1x overdue: boost = 1 (normal)
260
+ // At 2x overdue: boost = 2
261
+ // At 4x overdue: boost = 4
262
+ // At 8x+ overdue: boost = 8 (capped)
263
+ let priorityBoost = 1;
264
+ if (starvationRatio > 1) {
265
+ priorityBoost = Math.min(starvationRatio, 8);
266
+ }
267
+
268
+ // Deadline urgency still matters but doesn't dominate
269
+ if (deadline) {
270
+ const daysToDeadline = (deadline - Date.now()) / (24 * 60 * 60 * 1000);
271
+ if (daysToDeadline <= 0) {
272
+ priorityBoost *= 2; // Overdue deadline doubles existing boost
273
+ } else if (daysToDeadline <= 3) {
274
+ priorityBoost *= 1.5;
275
+ }
276
+ }
277
+
278
+ return {
279
+ purposeId,
280
+ expectedInterval,
281
+ actualInterval,
282
+ starvationRatio,
283
+ starving,
284
+ priorityBoost,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Sort purposes by fairness-weighted priority.
290
+ * Replaces the naive deadline sort that caused starvation.
291
+ */
292
+ export function sortByFairness(purposes: Array<{
293
+ id: string;
294
+ lastRun?: number;
295
+ frequency: number;
296
+ deadline?: number;
297
+ }>): Array<{ id: string; fairness: FairnessScore }> {
298
+ const scored = purposes.map(p => ({
299
+ id: p.id,
300
+ fairness: calculateFairness(p.id, p.lastRun, p.frequency, p.deadline),
301
+ }));
302
+
303
+ // Sort by priorityBoost descending (most starved first)
304
+ scored.sort((a, b) => b.fairness.priorityBoost - a.fairness.priorityBoost);
305
+ return scored;
306
+ }
307
+
308
+ // ── State Persistence ────────────────────────────────────────
309
+ // Track quality history for degradation detection.
310
+
311
+ interface SinkState {
312
+ qualityHistory: Record<string, number[]>; // choirId → last N quality scores
313
+ recentOutputHashes: Record<string, string[]>; // choirId → last N output snippets for novelty
314
+ starvationAlerts: Record<string, number>; // purposeId → last alert timestamp
315
+ beautifulOnesDetected: Array<{
316
+ choirId: string;
317
+ timestamp: string;
318
+ score: number;
319
+ flags: string[];
320
+ }>;
321
+ }
322
+
323
+ const MAX_HISTORY = 10;
324
+
325
+ function loadSinkState(): SinkState {
326
+ try {
327
+ if (existsSync(SINK_STATE_PATH)) {
328
+ return JSON.parse(readFileSync(SINK_STATE_PATH, "utf-8"));
329
+ }
330
+ } catch {}
331
+ return {
332
+ qualityHistory: {},
333
+ recentOutputHashes: {},
334
+ starvationAlerts: {},
335
+ beautifulOnesDetected: [],
336
+ };
337
+ }
338
+
339
+ function saveSinkState(state: SinkState): void {
340
+ try {
341
+ if (!existsSync(SINK_STATE_DIR)) {
342
+ mkdirSync(SINK_STATE_DIR, { recursive: true });
343
+ }
344
+ writeFileSync(SINK_STATE_PATH, JSON.stringify(state, null, 2));
345
+ } catch {}
346
+ }
347
+
348
+ /**
349
+ * Record a choir execution's quality and return the assessment.
350
+ * Call this after every choir run.
351
+ */
352
+ export function recordAndAssess(
353
+ choirId: string,
354
+ output: string
355
+ ): { quality: QualitySignal; illumination: IlluminationAssessment } {
356
+ const state = loadSinkState();
357
+
358
+ // Get previous outputs for novelty detection
359
+ const previousOutputs = state.recentOutputHashes[choirId] || [];
360
+ const historicalQuality = state.qualityHistory[choirId] || [];
361
+
362
+ // Assess quality
363
+ const quality = assessOutputQuality(choirId, output, previousOutputs);
364
+ const illumination = assessIllumination(choirId, output, historicalQuality);
365
+
366
+ // Update history
367
+ if (!state.qualityHistory[choirId]) state.qualityHistory[choirId] = [];
368
+ state.qualityHistory[choirId].push(quality.score);
369
+ if (state.qualityHistory[choirId].length > MAX_HISTORY) {
370
+ state.qualityHistory[choirId] = state.qualityHistory[choirId].slice(-MAX_HISTORY);
371
+ }
372
+
373
+ // Store output snippet for novelty comparison (first 500 chars)
374
+ if (!state.recentOutputHashes[choirId]) state.recentOutputHashes[choirId] = [];
375
+ state.recentOutputHashes[choirId].push(output.slice(0, 500));
376
+ if (state.recentOutputHashes[choirId].length > MAX_HISTORY) {
377
+ state.recentOutputHashes[choirId] = state.recentOutputHashes[choirId].slice(-MAX_HISTORY);
378
+ }
379
+
380
+ // Track beautiful ones incidents
381
+ if (quality.score < 20 && choirId !== "angels") {
382
+ state.beautifulOnesDetected.push({
383
+ choirId,
384
+ timestamp: new Date().toISOString(),
385
+ score: quality.score,
386
+ flags: quality.flags,
387
+ });
388
+ // Keep last 50 incidents
389
+ if (state.beautifulOnesDetected.length > 50) {
390
+ state.beautifulOnesDetected = state.beautifulOnesDetected.slice(-50);
391
+ }
392
+ }
393
+
394
+ saveSinkState(state);
395
+
396
+ return { quality, illumination };
397
+ }
398
+
399
+ /**
400
+ * Get a summary of behavioral sink health across all choirs.
401
+ */
402
+ export function getSinkHealthSummary(): {
403
+ healthy: boolean;
404
+ choirHealth: Record<string, { avgQuality: number; trend: string; beautifulOneCount: number }>;
405
+ starvingPurposes: string[];
406
+ recentIncidents: Array<{ choirId: string; timestamp: string; score: number }>;
407
+ } {
408
+ const state = loadSinkState();
409
+ const choirHealth: Record<string, { avgQuality: number; trend: string; beautifulOneCount: number }> = {};
410
+
411
+ let healthy = true;
412
+
413
+ for (const [choirId, scores] of Object.entries(state.qualityHistory)) {
414
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
415
+ const recentAvg = scores.slice(-3).reduce((a, b) => a + b, 0) / Math.min(scores.length, 3);
416
+ const trend = recentAvg > avg + 10 ? "improving" : recentAvg < avg - 10 ? "declining" : "stable";
417
+
418
+ const beautifulOneCount = state.beautifulOnesDetected
419
+ .filter(b => b.choirId === choirId)
420
+ .length;
421
+
422
+ choirHealth[choirId] = { avgQuality: Math.round(avg), trend, beautifulOneCount };
423
+
424
+ if (avg < 30 || trend === "declining") healthy = false;
425
+ }
426
+
427
+ const starvingPurposes = Object.entries(state.starvationAlerts)
428
+ .filter(([_, ts]) => Date.now() - ts < 24 * 60 * 60 * 1000)
429
+ .map(([id]) => id);
430
+
431
+ const recentIncidents = state.beautifulOnesDetected.slice(-5);
432
+
433
+ return { healthy, choirHealth, starvingPurposes, recentIncidents };
434
+ }
package/src/delivery.ts CHANGED
@@ -47,6 +47,31 @@ export async function deliverChoirOutput(
47
47
  ): Promise<void> {
48
48
  if (!choir.delivers || !text || text === "HEARTBEAT_OK" || text === "NO_REPLY") return;
49
49
 
50
+ // ── Suppression Detection ──────────────────────────────────
51
+ // Choir agents sometimes decide NOT to send, but express that decision
52
+ // as output text (e.g., "NOT SENDING", "Standing by", "No alert needed").
53
+ // Without this gate, the deliberation itself gets delivered — the agent
54
+ // says "I won't message Brandon" and Brandon receives exactly that.
55
+ //
56
+ // This is calibration lesson #1 inverted: "Deciding ≠ Doing" becomes
57
+ // "Deciding NOT to do ≠ Not doing" when delivery is automatic.
58
+ const suppressionPatterns = [
59
+ /\bNOT\s+SEND(?:ING)?\b/i,
60
+ /\bSTAND(?:ING)?\s+BY\b/i,
61
+ /\bNO\s+ALERT\s+(?:NEEDED|REQUIRED|WARRANTED|NECESSARY)\b/i,
62
+ /\bSUPPRESS(?:ING|ED)?\s+(?:DELIVERY|OUTPUT|ALERT)\b/i,
63
+ /\bDO\s+NOT\s+(?:ALERT|NOTIFY|SEND|DELIVER)\b/i,
64
+ /\bNOT\s+(?:AN?\s+)?ALERT\b/i,
65
+ /\bNO\s+(?:ACTION|DELIVERY)\s+(?:NEEDED|REQUIRED)\b/i,
66
+ /\bSKIPPING\s+(?:DELIVERY|ALERT|NOTIFICATION)\b/i,
67
+ ];
68
+
69
+ const isSuppressed = suppressionPatterns.some(p => p.test(text));
70
+ if (isSuppressed) {
71
+ log?.info?.(`[chorus] 🔇 ${choir.name} output contains suppression signal — delivery blocked`);
72
+ return;
73
+ }
74
+
50
75
  const { target, channel } = resolveDeliveryTarget(api);
51
76
  if (!target) {
52
77
  log?.warn?.(`[chorus] ⚠ No delivery target found in OpenClaw config for ${choir.name}`);
@@ -8,6 +8,7 @@
8
8
  import type { OpenClawPluginService, PluginLogger } from "openclaw/plugin-sdk";
9
9
  import { loadPurposes, updatePurpose, type Purpose } from "./purposes.js";
10
10
  import { recordExecution, type ChoirExecution } from "./metrics.js";
11
+ import { sortByFairness, type FairnessScore } from "./behavioral-sink.js";
11
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
12
13
  import { join } from "path";
13
14
  import { homedir } from "os";
@@ -383,12 +384,18 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
383
384
  }
384
385
  }
385
386
 
387
+ // Re-read purpose from disk to avoid overwriting fields changed during the run
388
+ // (e.g., frequency modified externally while agent was executing)
389
+ const freshPurposes = await loadPurposes();
390
+ const freshPurpose = freshPurposes.find((p) => p.id === purpose.id);
391
+ const freshResearch = freshPurpose?.research ?? purpose.research;
392
+
386
393
  await updatePurpose(purpose.id, {
387
394
  research: {
388
- ...purpose.research,
389
- enabled: purpose.research?.enabled ?? true,
395
+ ...freshResearch,
396
+ enabled: freshResearch?.enabled ?? true,
390
397
  lastRun: Date.now(),
391
- runCount: (purpose.research?.runCount ?? 0) + 1,
398
+ runCount: (freshResearch?.runCount ?? 0) + 1,
392
399
  },
393
400
  });
394
401
  dailyRuns.count++;
@@ -399,6 +406,24 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
399
406
  execution.success = false;
400
407
  execution.error = String(err);
401
408
  log.error(`[purpose-research] ✗ "${purpose.name}" failed: ${err}`);
409
+
410
+ // Update lastRun even on failure to prevent retry storms
411
+ // (without this, failed runs leave lastRun stale and the scheduler retries immediately)
412
+ try {
413
+ const failPurposes = await loadPurposes();
414
+ const failPurpose = failPurposes.find((p) => p.id === purpose.id);
415
+ if (failPurpose) {
416
+ await updatePurpose(purpose.id, {
417
+ research: {
418
+ ...(failPurpose.research ?? purpose.research),
419
+ lastRun: Date.now(),
420
+ },
421
+ });
422
+ }
423
+ } catch (updateErr) {
424
+ log.error(`[purpose-research] Failed to update lastRun after error: ${updateErr}`);
425
+ }
426
+
402
427
  return false;
403
428
  } finally {
404
429
  runningPurposes.delete(purpose.id);
@@ -428,21 +453,37 @@ CRITICAL: If sending alerts to plain-text channels, use PLAIN TEXT ONLY (no mark
428
453
 
429
454
  if (duePurposes.length === 0) return;
430
455
 
431
- duePurposes.sort((a, b) => {
432
- const aDeadline = a.deadline
433
- ? typeof a.deadline === "string"
434
- ? Date.parse(a.deadline)
435
- : a.deadline
436
- : Infinity;
437
- const bDeadline = b.deadline
438
- ? typeof b.deadline === "string"
439
- ? Date.parse(b.deadline)
440
- : b.deadline
441
- : Infinity;
442
- return aDeadline - bDeadline;
443
- });
444
-
445
- const purpose = duePurposes[0];
456
+ // ── Universe 25 Fairness Sort ─────────────────────────────
457
+ // Replaces naive deadline sort that caused purpose starvation
458
+ // (calibration lesson #23). Overdue purposes get exponentially
459
+ // increasing priority — no purpose can be crowded out.
460
+ const fairnessSorted = sortByFairness(
461
+ duePurposes.map(p => ({
462
+ id: p.id,
463
+ lastRun: p.research?.lastRun,
464
+ frequency: calculateFrequency(p),
465
+ deadline: p.deadline
466
+ ? typeof p.deadline === "string"
467
+ ? Date.parse(p.deadline)
468
+ : p.deadline
469
+ : undefined,
470
+ }))
471
+ );
472
+
473
+ const selectedId = fairnessSorted[0]?.id;
474
+ const purpose = duePurposes.find(p => p.id === selectedId);
475
+ const fairness = fairnessSorted[0]?.fairness;
476
+
477
+ if (!purpose) return;
478
+
479
+ if (fairness?.starving) {
480
+ log.warn(
481
+ `[purpose-research] 🏚️ STARVATION RECOVERY: "${purpose.name}" — ` +
482
+ `${fairness.starvationRatio.toFixed(1)}x overdue (expected every ` +
483
+ `${(fairness.expectedInterval / 3600000).toFixed(1)}h, actual ` +
484
+ `${(fairness.actualInterval / 3600000).toFixed(1)}h). Boost: ${fairness.priorityBoost.toFixed(1)}x`
485
+ )
486
+ }
446
487
 
447
488
  if (dailyRuns.count < config.dailyRunCap) {
448
489
  await runResearch(purpose);
package/src/scheduler.ts CHANGED
@@ -15,6 +15,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
15
15
  import { join } from "path";
16
16
  import { homedir } from "os";
17
17
  import { spawn } from "child_process";
18
+ import { recordAndAssess } from "./behavioral-sink.js";
18
19
 
19
20
  // Type for the plugin API's runAgentTurn method
20
21
  interface AgentTurnResult {
@@ -56,7 +57,7 @@ const BASE_BACKOFF_MS = 60 * 1000; // 1 minute base
56
57
 
57
58
  function isRateLimitError(err: unknown): boolean {
58
59
  const msg = String(err).toLowerCase();
59
- return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests");
60
+ return msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests") || msg.includes("overloaded") || msg.includes("unavailable");
60
61
  }
61
62
 
62
63
  function computeBackoffMs(failures: number): number {
@@ -280,12 +281,30 @@ export function createChoirScheduler(
280
281
  execution.alerts = countAlerts(output);
281
282
  execution.improvements = extractImprovements(output, choir.id);
282
283
 
283
- // Store context for downstream choirs
284
- contextStore.set(choir.id, {
285
- choirId: choir.id,
286
- output: output.slice(0, 2000), // Truncate for context passing
287
- timestamp: new Date(),
288
- });
284
+ // ── Behavioral Sink Prevention ──────────────────────────
285
+ // Assess output quality and gate illumination to prevent
286
+ // degraded context from cascading downstream (Universe 25).
287
+ const sinkAssessment = recordAndAssess(choir.id, output);
288
+
289
+ if (sinkAssessment.quality.score < 20 && choir.id !== "angels") {
290
+ log.warn(`[chorus] 🪞 BEAUTIFUL ONE detected: ${choir.name} scored ${sinkAssessment.quality.score}/100 — flags: ${sinkAssessment.quality.flags.join(", ")}`);
291
+ }
292
+
293
+ // Store context for downstream choirs — only if quality passes sink gate
294
+ if (sinkAssessment.illumination.usable) {
295
+ const qualityTag = sinkAssessment.illumination.degraded
296
+ ? `\n[⚠️ CONTEXT QUALITY DEGRADED — score ${sinkAssessment.quality.score}/100]\n`
297
+ : "";
298
+ contextStore.set(choir.id, {
299
+ choirId: choir.id,
300
+ output: qualityTag + output.slice(0, 2000),
301
+ timestamp: new Date(),
302
+ });
303
+ } else {
304
+ log.warn(`[chorus] 🚫 SINK GATE: ${choir.name} output blocked from illumination chain (score ${sinkAssessment.quality.score}/100)`);
305
+ // Don't update context — downstream choirs keep stale but good context
306
+ // rather than receiving fresh but degraded context
307
+ }
289
308
 
290
309
  // Update run state
291
310
  runState.set(choir.id, {