clementine-agent 1.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 (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,1706 @@
1
+ /**
2
+ * Clementine TypeScript — Self-Improvement Loop Engine.
3
+ *
4
+ * Implements Karpathy's autoresearch iterative loop for autonomous self-improvement:
5
+ * hypothesize → execute → evaluate → keep/revert → repeat.
6
+ *
7
+ * Evaluates Clementine's own outputs (transcripts, feedback, cron logs) and proposes
8
+ * improvements to system prompts, cron job prompts, workflows, and memory settings.
9
+ * All proposed changes require Discord approval before being applied.
10
+ */
11
+ import { randomBytes } from 'node:crypto';
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
13
+ import matter from 'gray-matter';
14
+ import path from 'node:path';
15
+ import pino from 'pino';
16
+ import { BASE_DIR, SELF_IMPROVE_DIR, SOUL_FILE, AGENTS_FILE, CRON_FILE, WORKFLOWS_DIR, VAULT_DIR, MEMORY_DB_PATH, AGENTS_DIR, PKG_DIR, CRON_REFLECTIONS_DIR, GOALS_DIR, } from '../config.js';
17
+ const logger = pino({ name: 'clementine.self-improve' });
18
+ // ── Defaults ─────────────────────────────────────────────────────────
19
+ const DEFAULT_CONFIG = {
20
+ maxIterations: 6,
21
+ iterationBudgetMs: 300_000, // 5 min
22
+ maxDurationMs: 3_600_000, // 1 hour
23
+ acceptThreshold: 0.7,
24
+ plateauLimit: 3,
25
+ areas: ['soul', 'cron', 'workflow', 'memory', 'agent', 'source', 'communication', 'goal'],
26
+ autoApply: true,
27
+ sourceMode: 'propose-only',
28
+ };
29
+ // ── Paths ────────────────────────────────────────────────────────────
30
+ const EXPERIMENT_LOG = path.join(SELF_IMPROVE_DIR, 'experiment-log.jsonl');
31
+ const STATE_FILE = path.join(SELF_IMPROVE_DIR, 'state.json');
32
+ const PENDING_DIR = path.join(SELF_IMPROVE_DIR, 'pending-changes');
33
+ const APPROVAL_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
34
+ const IMPACT_CHECKS_FILE = path.join(SELF_IMPROVE_DIR, 'impact-checks.jsonl');
35
+ const EVOLUTION_VERSIONS_FILE = path.join(SELF_IMPROVE_DIR, 'evolution-versions.json');
36
+ const SOUL_BASELINE_FILE = path.join(SELF_IMPROVE_DIR, 'soul-baseline.md');
37
+ /** Minimum Jaccard similarity between a proposed SOUL.md and the baseline.
38
+ * Below this threshold, the change is rejected as identity drift. */
39
+ const DRIFT_SIMILARITY_THRESHOLD = 0.55;
40
+ /** If post-change metrics drop by more than this ratio, auto-rollback triggers. */
41
+ const REGRESSION_ROLLBACK_THRESHOLD = 0.10;
42
+ /** Max consecutive infrastructure errors before aborting the loop. */
43
+ const MAX_INFRA_ERRORS = 2;
44
+ /** Classify a self-improve error to determine if it's infrastructure (don't retry)
45
+ * or hypothesis-related (safe to try a different approach). */
46
+ function classifyError(err) {
47
+ const msg = String(err);
48
+ // Tool schema validation errors from the API
49
+ if (msg.includes('input_schema') || msg.includes('Input should be')) {
50
+ return {
51
+ category: 'infra_schema',
52
+ message: msg.slice(0, 300),
53
+ diagnostic: 'An MCP server is exposing a tool with a malformed input_schema (type must be "object"). ' +
54
+ 'This is an infrastructure issue — no iteration can succeed until the broken MCP server is fixed or excluded. ' +
55
+ 'Check external MCP servers in claude_desktop_config.json and Claude Code settings for recently updated packages.',
56
+ retryFutile: true,
57
+ };
58
+ }
59
+ // Auth / API key errors
60
+ if (msg.includes('401') || msg.includes('403') || msg.includes('authentication') || msg.includes('Unauthorized')) {
61
+ return {
62
+ category: 'infra_auth',
63
+ message: msg.slice(0, 300),
64
+ diagnostic: 'API authentication failed. Check that the Anthropic API key is valid and not expired.',
65
+ retryFutile: true,
66
+ };
67
+ }
68
+ // Rate limits
69
+ if (msg.includes('429') || msg.includes('rate_limit') || msg.includes('Too many requests')) {
70
+ return {
71
+ category: 'infra_rate_limit',
72
+ message: msg.slice(0, 300),
73
+ diagnostic: 'Hit API rate limit. Subsequent iterations will likely fail too. Wait and retry next cycle.',
74
+ retryFutile: true,
75
+ };
76
+ }
77
+ // Timeouts
78
+ if (msg.includes('timeout') || msg.includes('ETIMEDOUT') || msg.includes('AbortError')) {
79
+ return {
80
+ category: 'infra_timeout',
81
+ message: msg.slice(0, 300),
82
+ diagnostic: 'LLM call timed out. This may be transient — worth retrying once.',
83
+ retryFutile: false,
84
+ };
85
+ }
86
+ // Everything else — likely a hypothesis or parsing error, safe to retry with a different approach
87
+ return {
88
+ category: 'unknown',
89
+ message: msg.slice(0, 300),
90
+ diagnostic: 'Unexpected error during iteration. May be transient.',
91
+ retryFutile: false,
92
+ };
93
+ }
94
+ // ── Drift detection ─────────────────────────────────────────────────
95
+ /** Tokenize text into a word set for Jaccard similarity. */
96
+ function tokenizeForDrift(text) {
97
+ return new Set(text.toLowerCase()
98
+ .replace(/[^\w\s]/g, ' ')
99
+ .split(/\s+/)
100
+ .filter(w => w.length > 2));
101
+ }
102
+ /** Jaccard similarity between two token sets. */
103
+ function jaccardSimilarity(a, b) {
104
+ if (a.size === 0 && b.size === 0)
105
+ return 1;
106
+ let intersection = 0;
107
+ for (const token of a) {
108
+ if (b.has(token))
109
+ intersection++;
110
+ }
111
+ const union = a.size + b.size - intersection;
112
+ return union === 0 ? 0 : intersection / union;
113
+ }
114
+ /** Check if a proposed change drifts too far from the baseline identity.
115
+ * Only applies to 'soul' area changes. Returns { ok, similarity }. */
116
+ function checkDrift(proposedContent) {
117
+ if (!existsSync(SOUL_BASELINE_FILE)) {
118
+ // First run: snapshot current SOUL.md as baseline
119
+ if (existsSync(SOUL_FILE)) {
120
+ mkdirSync(path.dirname(SOUL_BASELINE_FILE), { recursive: true });
121
+ writeFileSync(SOUL_BASELINE_FILE, readFileSync(SOUL_FILE, 'utf-8'));
122
+ }
123
+ return { ok: true, similarity: 1 };
124
+ }
125
+ const baseline = readFileSync(SOUL_BASELINE_FILE, 'utf-8');
126
+ const baseTokens = tokenizeForDrift(baseline);
127
+ const proposedTokens = tokenizeForDrift(proposedContent);
128
+ const similarity = jaccardSimilarity(baseTokens, proposedTokens);
129
+ return { ok: similarity >= DRIFT_SIMILARITY_THRESHOLD, similarity };
130
+ }
131
+ /** Classify the risk level of a proposed change.
132
+ * - low: agent prompts, individual cron job prompts — auto-apply safe
133
+ * - medium: SOUL.md, AGENTS.md, MEMORY.md — needs owner approval
134
+ * - high: source code — stays blocked
135
+ */
136
+ function classifyRisk(area) {
137
+ switch (area) {
138
+ case 'agent': return 'low'; // Agent-scoped, easily reversible
139
+ case 'cron': return 'low'; // Cron prompt tweaks, low blast radius
140
+ case 'workflow': return 'low'; // Workflow definitions, scoped
141
+ case 'soul': return 'medium'; // Core personality — needs approval
142
+ case 'communication': return 'medium'; // Global operating instructions
143
+ case 'memory': return 'medium'; // Memory config
144
+ case 'source': return 'high'; // Code changes — always blocked in auto mode
145
+ case 'goal': return 'medium'; // New goals need owner review before activating
146
+ default: return 'high';
147
+ }
148
+ }
149
+ // ── SelfImproveLoop ──────────────────────────────────────────────────
150
+ export class SelfImproveLoop {
151
+ config;
152
+ assistant;
153
+ constructor(assistant, config) {
154
+ this.assistant = assistant;
155
+ this.config = { ...DEFAULT_CONFIG, ...config };
156
+ ensureDirs();
157
+ }
158
+ // ── Main entry point ──────────────────────────────────────────────
159
+ async run(onProposal) {
160
+ this.reconcileState();
161
+ this.expireStaleProposals();
162
+ await this.checkAppliedImpact();
163
+ // Capture SOUL.md baseline on first run (for drift detection)
164
+ if (!existsSync(SOUL_BASELINE_FILE) && existsSync(SOUL_FILE)) {
165
+ mkdirSync(path.dirname(SOUL_BASELINE_FILE), { recursive: true });
166
+ writeFileSync(SOUL_BASELINE_FILE, readFileSync(SOUL_FILE, 'utf-8'));
167
+ logger.info('Captured SOUL.md baseline for drift detection');
168
+ }
169
+ const state = this.loadState();
170
+ state.status = 'running';
171
+ state.lastRunAt = new Date().toISOString();
172
+ state.currentIteration = 0;
173
+ this.saveState(state);
174
+ const loopStart = Date.now();
175
+ const history = this.loadExperimentLog();
176
+ let consecutiveLow = 0;
177
+ try {
178
+ // Step 1: Gather baseline metrics
179
+ const metrics = await this.gatherMetrics();
180
+ state.baselineMetrics = {
181
+ feedbackPositiveRatio: metrics.feedbackStats.total > 0
182
+ ? metrics.feedbackStats.positive / metrics.feedbackStats.total
183
+ : 1,
184
+ cronSuccessRate: metrics.cronSuccessRate,
185
+ avgResponseQuality: 0, // Updated as we evaluate
186
+ };
187
+ // Synthesize feedback patterns and update user model before experiment loop
188
+ await this.synthesizeFeedbackPatterns();
189
+ await this.updateUserModel();
190
+ for (let i = 1; i <= this.config.maxIterations; i++) {
191
+ // Check time budget
192
+ if (Date.now() - loopStart > this.config.maxDurationMs) {
193
+ logger.info('Self-improve loop hit time limit — stopping');
194
+ break;
195
+ }
196
+ // Check plateau
197
+ if (consecutiveLow >= this.config.plateauLimit) {
198
+ logger.info({ consecutiveLow }, 'Plateau detected — stopping');
199
+ break;
200
+ }
201
+ state.currentIteration = i;
202
+ this.saveState(state);
203
+ const iterStart = Date.now();
204
+ const id = randomBytes(4).toString('hex');
205
+ try {
206
+ // Step 2-3: Diagnose + hypothesize
207
+ const proposal = await this.withTimeout(this.hypothesize(metrics, history), this.config.iterationBudgetMs);
208
+ if (!proposal) {
209
+ logger.info({ iteration: i }, 'No hypothesis generated — skipping');
210
+ consecutiveLow++;
211
+ continue;
212
+ }
213
+ // Diversity safety net: skip if hypothesis targets an over-represented area:target
214
+ const proposalKey = `${proposal.area}:${proposal.target}`;
215
+ const proposalCount = history.filter(e => `${e.area}:${e.target}` === proposalKey).length
216
+ + this.getPendingChanges().filter(p => `${p.area}:${p.target}` === proposalKey).length;
217
+ if (proposalCount >= 3) {
218
+ logger.warn({ area: proposal.area, target: proposal.target, count: proposalCount }, 'Hypothesis over-targeted — skipping');
219
+ consecutiveLow++;
220
+ continue;
221
+ }
222
+ const validation = this.validateProposal(proposal.area, proposal.target, proposal.proposedChange);
223
+ if (!validation.valid) {
224
+ logger.warn({ area: proposal.area, target: proposal.target, error: validation.error }, 'Proposed change failed validation — skipping');
225
+ consecutiveLow++;
226
+ continue;
227
+ }
228
+ // Drift detection: reject SOUL.md changes that stray too far from baseline identity
229
+ if (proposal.area === 'soul') {
230
+ const drift = checkDrift(proposal.proposedChange);
231
+ if (!drift.ok) {
232
+ logger.warn({ similarity: drift.similarity.toFixed(3), threshold: DRIFT_SIMILARITY_THRESHOLD }, 'Soul drift detected — proposed change deviates too far from baseline identity');
233
+ consecutiveLow++;
234
+ continue;
235
+ }
236
+ logger.debug({ similarity: drift.similarity.toFixed(3) }, 'Soul drift check passed');
237
+ }
238
+ // Step 4: Read current state
239
+ const before = await this.readCurrentState(proposal.area, proposal.target);
240
+ // Step 5: Evaluate
241
+ const evaluation = await this.withTimeout(this.evaluate(before, proposal.proposedChange, proposal.hypothesis), 60_000);
242
+ const score = evaluation?.score ?? 0;
243
+ const normalizedScore = score / 10; // Convert 0-10 to 0-1
244
+ const accepted = normalizedScore >= this.config.acceptThreshold;
245
+ const priorScores = history
246
+ .filter(e => e.area === proposal.area && e.target === proposal.target && e.score > 0)
247
+ .map(e => e.score);
248
+ const baselineScore = priorScores.length > 0
249
+ ? priorScores.reduce((a, b) => a + b, 0) / priorScores.length
250
+ : 0.5;
251
+ const experiment = {
252
+ id,
253
+ iteration: i,
254
+ startedAt: new Date(iterStart).toISOString(),
255
+ finishedAt: new Date().toISOString(),
256
+ durationMs: Date.now() - iterStart,
257
+ area: proposal.area,
258
+ target: proposal.target,
259
+ hypothesis: proposal.hypothesis,
260
+ proposedChange: proposal.proposedChange,
261
+ baselineScore,
262
+ score: normalizedScore,
263
+ accepted,
264
+ approvalStatus: accepted ? 'pending' : 'denied',
265
+ reason: accepted
266
+ ? `Score ${score}/10 exceeds threshold — pending approval`
267
+ : `Score ${score}/10 below threshold (${this.config.acceptThreshold * 10}/10)`,
268
+ };
269
+ // Step 7: Log
270
+ this.appendExperimentLog(experiment);
271
+ history.push(experiment);
272
+ state.totalExperiments++;
273
+ // Step 6: Gate — save pending change + notify (tiered by risk)
274
+ if (accepted) {
275
+ const risk = classifyRisk(proposal.area);
276
+ if (this.config.autoApply && risk === 'low') {
277
+ // Low-risk + auto-apply enabled: apply immediately without approval
278
+ const targetPath = this.resolveTargetPath(proposal.area, proposal.target);
279
+ if (targetPath) {
280
+ // Validate before auto-applying
281
+ const autoValidation = this.validateProposal(proposal.area, proposal.target, proposal.proposedChange);
282
+ if (autoValidation.valid) {
283
+ writeFileSync(targetPath, proposal.proposedChange);
284
+ experiment.approvalStatus = 'approved';
285
+ this.updateExperimentStatus(id, 'approved');
286
+ // Record version for rollback lineage
287
+ this.recordVersion(id, proposal.area, proposal.target, proposal.hypothesis, before);
288
+ // Schedule impact check
289
+ try {
290
+ appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
291
+ experimentId: id,
292
+ area: proposal.area,
293
+ target: proposal.target,
294
+ appliedAt: new Date().toISOString(),
295
+ checkAfterMs: 24 * 60 * 60 * 1000,
296
+ }) + '\n');
297
+ }
298
+ catch { /* non-fatal */ }
299
+ logger.info({ id, area: proposal.area, target: proposal.target, risk }, 'Auto-applied low-risk change');
300
+ }
301
+ else {
302
+ logger.warn({ id, error: autoValidation.error }, 'Auto-apply blocked by validation');
303
+ await this.savePendingChange(experiment, before);
304
+ state.pendingApprovals++;
305
+ }
306
+ }
307
+ else {
308
+ await this.savePendingChange(experiment, before);
309
+ state.pendingApprovals++;
310
+ }
311
+ }
312
+ else if (this.config.autoApply && risk === 'high') {
313
+ // High-risk: behavior depends on sourceMode config
314
+ if (this.config.sourceMode === 'skip') {
315
+ logger.info({ id, area: proposal.area, risk }, 'Skipped high-risk proposal in auto mode');
316
+ experiment.approvalStatus = 'denied';
317
+ experiment.reason = 'High-risk area blocked in autonomous mode (sourceMode=skip)';
318
+ }
319
+ else {
320
+ // propose-only: save for human review, never auto-apply
321
+ await this.savePendingChange(experiment, before);
322
+ state.pendingApprovals++;
323
+ if (onProposal) {
324
+ await onProposal(experiment);
325
+ }
326
+ logger.info({ id, area: proposal.area, risk }, 'Saved high-risk proposal for human review');
327
+ }
328
+ }
329
+ else {
330
+ // Medium-risk or manual mode: save as pending for approval
331
+ await this.savePendingChange(experiment, before);
332
+ state.pendingApprovals++;
333
+ if (onProposal) {
334
+ await onProposal(experiment);
335
+ }
336
+ }
337
+ consecutiveLow = 0;
338
+ }
339
+ else {
340
+ consecutiveLow++;
341
+ }
342
+ logger.info({
343
+ iteration: i,
344
+ id,
345
+ area: proposal.area,
346
+ score,
347
+ accepted,
348
+ }, `Iteration ${i} complete`);
349
+ }
350
+ catch (err) {
351
+ const classified = classifyError(err);
352
+ const experiment = {
353
+ id,
354
+ iteration: i,
355
+ startedAt: new Date(iterStart).toISOString(),
356
+ finishedAt: new Date().toISOString(),
357
+ durationMs: Date.now() - iterStart,
358
+ area: this.config.areas[0],
359
+ target: 'unknown',
360
+ hypothesis: `[${classified.category}] ${classified.diagnostic.slice(0, 120)}`,
361
+ proposedChange: '',
362
+ baselineScore: 0,
363
+ score: 0,
364
+ accepted: false,
365
+ approvalStatus: 'denied',
366
+ reason: `Error: ${classified.category}`,
367
+ error: classified.message,
368
+ };
369
+ this.appendExperimentLog(experiment);
370
+ history.push(experiment);
371
+ state.totalExperiments++;
372
+ consecutiveLow++;
373
+ logger.error({
374
+ err,
375
+ iteration: i,
376
+ errorCategory: classified.category,
377
+ diagnostic: classified.diagnostic,
378
+ retryFutile: classified.retryFutile,
379
+ }, `Iteration ${i} failed: ${classified.category}`);
380
+ // If this is an infrastructure error that can't be fixed by retrying,
381
+ // check how many consecutive infra errors we've hit. If >= MAX_INFRA_ERRORS,
382
+ // abort the loop — every remaining iteration will fail the same way.
383
+ if (classified.retryFutile) {
384
+ const recentInfraErrors = history.slice(-MAX_INFRA_ERRORS)
385
+ .filter(e => e.reason?.startsWith('Error: infra_'));
386
+ if (recentInfraErrors.length >= MAX_INFRA_ERRORS) {
387
+ logger.warn({
388
+ category: classified.category,
389
+ diagnostic: classified.diagnostic,
390
+ consecutiveInfraErrors: recentInfraErrors.length,
391
+ }, 'Aborting self-improve loop — infrastructure error is persistent and cannot be fixed by retrying');
392
+ state.infraError = {
393
+ category: classified.category,
394
+ diagnostic: classified.diagnostic,
395
+ };
396
+ break;
397
+ }
398
+ }
399
+ }
400
+ this.saveState(state);
401
+ }
402
+ // Update avgResponseQuality from this run's scores
403
+ const runScores = history.filter(e => e.iteration >= 1 && e.score > 0).map(e => e.score);
404
+ if (runScores.length > 0) {
405
+ state.baselineMetrics.avgResponseQuality = runScores.reduce((a, b) => a + b, 0) / runScores.length;
406
+ }
407
+ state.status = 'completed';
408
+ }
409
+ catch (err) {
410
+ state.status = 'failed';
411
+ logger.error({ err }, 'Self-improve loop failed');
412
+ }
413
+ this.saveState(state);
414
+ // Memory cleanup at end of nightly run
415
+ await this.runMemoryCleanup();
416
+ return state;
417
+ }
418
+ // ── Per-agent focused cycle ────────────────────────────────────────
419
+ /** Run a focused self-improvement cycle for a specific agent. */
420
+ async runForAgent(agentSlug, onProposal) {
421
+ // Override config for agent-focused run
422
+ this.config = {
423
+ ...this.config,
424
+ maxIterations: 5, // Fewer iterations for focused run
425
+ maxDurationMs: 600_000, // 10 min max
426
+ areas: ['agent', 'cron'], // Only agent-scoped areas
427
+ autoApply: true, // Auto-apply for agent changes
428
+ agentSlug,
429
+ };
430
+ return this.run(onProposal);
431
+ }
432
+ // ── Step 1: Gather performance data ──────────────────────────────
433
+ async gatherMetrics() {
434
+ const { MemoryStore } = await import('../memory/store.js');
435
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
436
+ store.initialize();
437
+ const feedbackStats = store.getFeedbackStats();
438
+ const negativeFeedback = store.getRecentFeedback(20)
439
+ .filter(f => f.rating === 'negative');
440
+ store.close();
441
+ // Gather cron errors from run logs
442
+ const { CronRunLog } = await import('../gateway/heartbeat.js');
443
+ const runLog = new CronRunLog();
444
+ const cronErrors = [];
445
+ let cronTotal = 0;
446
+ let cronOk = 0;
447
+ const runsDir = path.join(BASE_DIR, 'cron', 'runs');
448
+ if (existsSync(runsDir)) {
449
+ const files = readdirSync(runsDir).filter(f => f.endsWith('.jsonl'));
450
+ for (const file of files) {
451
+ // Filename is the sanitized job name — pass as-is to readRecent
452
+ // (readRecent applies the same sanitization internally)
453
+ const sanitizedName = file.replace('.jsonl', '');
454
+ const entries = runLog.readRecent(sanitizedName, 20);
455
+ for (const entry of entries) {
456
+ cronTotal++;
457
+ if (entry.status === 'ok') {
458
+ cronOk++;
459
+ }
460
+ else {
461
+ cronErrors.push(entry);
462
+ }
463
+ }
464
+ }
465
+ }
466
+ // Gather cron reflections (quality ratings from post-cron reflection passes)
467
+ const cronReflections = [];
468
+ try {
469
+ if (existsSync(CRON_REFLECTIONS_DIR)) {
470
+ const reflFiles = readdirSync(CRON_REFLECTIONS_DIR).filter(f => f.endsWith('.jsonl'));
471
+ for (const file of reflFiles) {
472
+ const lines = readFileSync(path.join(CRON_REFLECTIONS_DIR, file), 'utf-8').trim().split('\n');
473
+ // Take the most recent 5 reflections per job
474
+ for (const line of lines.slice(-5)) {
475
+ try {
476
+ cronReflections.push(JSON.parse(line));
477
+ }
478
+ catch { /* skip malformed */ }
479
+ }
480
+ }
481
+ }
482
+ }
483
+ catch { /* non-fatal */ }
484
+ // Gather goal health data
485
+ const goalHealth = [];
486
+ try {
487
+ if (existsSync(GOALS_DIR)) {
488
+ const goalFiles = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
489
+ const now = Date.now();
490
+ const DAY_MS = 86_400_000;
491
+ for (const file of goalFiles) {
492
+ try {
493
+ const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, file), 'utf-8'));
494
+ const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
495
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
496
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
497
+ goalHealth.push({
498
+ id: goal.id,
499
+ title: goal.title,
500
+ status: goal.status,
501
+ owner: goal.owner,
502
+ priority: goal.priority,
503
+ daysSinceUpdate,
504
+ reviewFrequency: goal.reviewFrequency,
505
+ isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
506
+ linkedCronJobs: goal.linkedCronJobs || [],
507
+ progressCount: goal.progressNotes?.length ?? 0,
508
+ });
509
+ }
510
+ catch { /* skip malformed */ }
511
+ }
512
+ }
513
+ }
514
+ catch { /* non-fatal */ }
515
+ // Gather execution advisor insights (if available)
516
+ const advisorInsights = [];
517
+ try {
518
+ const advisorLog = path.join(BASE_DIR, 'cron', 'advisor-decisions.jsonl');
519
+ if (existsSync(advisorLog)) {
520
+ const advisorLines = readFileSync(advisorLog, 'utf-8').trim().split('\n').filter(Boolean);
521
+ const outcomes = advisorLines.slice(-100)
522
+ .map(l => { try {
523
+ return JSON.parse(l);
524
+ }
525
+ catch {
526
+ return null;
527
+ } })
528
+ .filter((d) => d?.type === 'outcome');
529
+ // Summarize per-job intervention effectiveness
530
+ const byJob = new Map();
531
+ for (const o of outcomes) {
532
+ if (!byJob.has(o.jobName))
533
+ byJob.set(o.jobName, { interventions: new Map() });
534
+ const jm = byJob.get(o.jobName);
535
+ for (const [key, val] of Object.entries(o.interventions ?? {})) {
536
+ if (!val)
537
+ continue;
538
+ if (!jm.interventions.has(key))
539
+ jm.interventions.set(key, { ok: 0, total: 0 });
540
+ const stats = jm.interventions.get(key);
541
+ stats.total++;
542
+ if (o.outcome === 'ok')
543
+ stats.ok++;
544
+ }
545
+ }
546
+ for (const [job, data] of byJob) {
547
+ for (const [intervention, stats] of data.interventions) {
548
+ if (stats.total >= 2) {
549
+ advisorInsights.push(`${job}: ${intervention} — ${((stats.ok / stats.total) * 100).toFixed(0)}% success (n=${stats.total})`);
550
+ }
551
+ }
552
+ }
553
+ }
554
+ }
555
+ catch { /* non-fatal */ }
556
+ // Filter to target agent if running per-agent cycle
557
+ let filteredReflections = cronReflections;
558
+ let filteredErrors = cronErrors;
559
+ if (this.config.agentSlug) {
560
+ filteredReflections = cronReflections.filter(r => r.agentSlug === this.config.agentSlug);
561
+ filteredErrors = cronErrors.filter(e => e.agentSlug === this.config.agentSlug);
562
+ }
563
+ return {
564
+ feedbackStats,
565
+ negativeFeedback,
566
+ cronErrors: filteredErrors.slice(0, 10),
567
+ cronSuccessRate: cronTotal > 0 ? cronOk / cronTotal : 1,
568
+ cronReflections: filteredReflections.slice(-20),
569
+ goalHealth,
570
+ advisorInsights,
571
+ };
572
+ }
573
+ // ── Steps 2-3: Diagnose + Hypothesize ────────────────────────────
574
+ async hypothesize(metrics, history) {
575
+ // Read targeted triggers (written by cron scheduler when jobs fail repeatedly)
576
+ let targetedTriggers = '';
577
+ const triggersDir = path.join(SELF_IMPROVE_DIR, 'triggers');
578
+ if (existsSync(triggersDir)) {
579
+ const triggerFiles = readdirSync(triggersDir).filter(f => f.endsWith('.json'));
580
+ if (triggerFiles.length > 0) {
581
+ const triggers = triggerFiles.slice(0, 3).map(f => {
582
+ try {
583
+ const t = JSON.parse(readFileSync(path.join(triggersDir, f), 'utf-8'));
584
+ // Clean up trigger after reading
585
+ unlinkSync(path.join(triggersDir, f));
586
+ return t;
587
+ }
588
+ catch {
589
+ return null;
590
+ }
591
+ }).filter(Boolean);
592
+ if (triggers.length > 0) {
593
+ targetedTriggers = `\n\n## PRIORITY: Failing Jobs Needing Attention\n` +
594
+ `These jobs have been failing repeatedly and need prompt/config fixes:\n` +
595
+ triggers.map((t) => `- **${t.jobName}**: ${t.consecutiveErrors} consecutive errors. Recent: ${(t.recentErrors ?? []).join('; ')}`).join('\n') +
596
+ `\n\nFocus your improvement hypothesis on fixing these jobs first.\n`;
597
+ }
598
+ }
599
+ }
600
+ // Format experiment history for the prompt
601
+ const historyText = history.slice(-20).map(e => `#${e.iteration} | ${e.area} | "${e.hypothesis.slice(0, 60)}" | ${(e.score * 10).toFixed(1)}/10 ${e.accepted ? '✅' : '❌'}`).join('\n') || '(no prior experiments)';
602
+ // Enforce diversity: count recent proposals per area:target AND per area
603
+ const recentTargets = new Map();
604
+ const recentAreas = new Map();
605
+ for (const e of history.slice(-10)) {
606
+ const key = `${e.area}:${e.target}`;
607
+ recentTargets.set(key, (recentTargets.get(key) ?? 0) + 1);
608
+ recentAreas.set(e.area, (recentAreas.get(e.area) ?? 0) + 1);
609
+ }
610
+ for (const p of this.getPendingChanges()) {
611
+ const key = `${p.area}:${p.target}`;
612
+ recentTargets.set(key, (recentTargets.get(key) ?? 0) + 1);
613
+ recentAreas.set(p.area, (recentAreas.get(p.area) ?? 0) + 1);
614
+ }
615
+ // Block area:target pairs with >= 2 recent proposals
616
+ const overTargeted = [...recentTargets.entries()]
617
+ .filter(([, count]) => count >= 2)
618
+ .map(([key]) => key);
619
+ // Block entire areas with >= 3 recent proposals
620
+ const overTargetedAreas = [...recentAreas.entries()]
621
+ .filter(([, count]) => count >= 3)
622
+ .map(([area]) => area);
623
+ // Build area coverage stats to nudge the LLM toward unexplored areas
624
+ const allAreas = this.config.areas;
625
+ const areaCoverage = allAreas.map(area => {
626
+ const count = recentAreas.get(area) ?? 0;
627
+ return `- ${area}: ${count} recent proposals`;
628
+ }).join('\n');
629
+ const diversityConstraint = `\n\n## AREA COVERAGE (target under-explored areas)\n${areaCoverage}\n` +
630
+ (overTargeted.length > 0 || overTargetedAreas.length > 0
631
+ ? `\n## DIVERSITY CONSTRAINT\n` +
632
+ (overTargetedAreas.length > 0
633
+ ? `These AREAS have been over-targeted and MUST NOT be chosen:\n${overTargetedAreas.map(a => `- ${a} (${recentAreas.get(a)} proposals)`).join('\n')}\n`
634
+ : '') +
635
+ (overTargeted.length > 0
636
+ ? `These specific targets MUST NOT be re-targeted:\n${overTargeted.map(t => `- ${t}`).join('\n')}\n`
637
+ : '') +
638
+ `Choose a DIFFERENT area/target. If no other improvement is needed, output { "area": null }.\n`
639
+ : '');
640
+ const patternAnalysis = this.analyzeExperimentPatterns(history);
641
+ // Format negative feedback
642
+ const negativeFeedbackText = metrics.negativeFeedback.slice(0, 5).map(f => `- Rating: ${f.rating} | Message: "${(f.messageSnippet ?? '').slice(0, 100)}" | Response: "${(f.responseSnippet ?? '').slice(0, 100)}"${f.comment ? ` | Comment: "${f.comment}"` : ''}`).join('\n') || '(no negative feedback)';
643
+ // Format cron errors
644
+ const cronErrorsText = metrics.cronErrors.slice(0, 5).map(e => `- Job: ${e.jobName} | Error: ${(e.error ?? 'unknown').slice(0, 200)} | At: ${e.startedAt}`).join('\n') || '(no cron errors)';
645
+ // Format cron reflections (quality ratings from automated reflection passes)
646
+ const cronReflectionsText = metrics.cronReflections.slice(-10).map(r => `- Job: ${r.jobName}${r.agentSlug ? ` (${r.agentSlug})` : ''} | Quality: ${r.quality}/5 | ` +
647
+ `Exist: ${r.existence ?? '?'} Substance: ${r.substance ?? '?'} Actionable: ${r.actionable ?? '?'} ` +
648
+ `Comm: ${r.communication ?? '?'} | ` +
649
+ `Gap: "${r.gap?.slice(0, 80) ?? ''}"${r.commNote ? ` | CommNote: "${r.commNote.slice(0, 80)}"` : ''} | At: ${r.timestamp}`).join('\n') || '(no cron reflections yet)';
650
+ // Compute per-agent metrics from reflections
651
+ const agentMetrics = new Map();
652
+ for (const r of metrics.cronReflections) {
653
+ const slug = r.agentSlug || 'clementine';
654
+ if (!agentMetrics.has(slug)) {
655
+ agentMetrics.set(slug, { total: 0, qualitySum: 0, emptyCount: 0, gaps: [] });
656
+ }
657
+ const m = agentMetrics.get(slug);
658
+ m.total++;
659
+ m.qualitySum += r.quality ?? 0;
660
+ if (r.existence === false || r.substance === false)
661
+ m.emptyCount++;
662
+ if (r.gap && r.gap !== 'none')
663
+ m.gaps.push(r.gap);
664
+ }
665
+ const perAgentText = agentMetrics.size > 0
666
+ ? Array.from(agentMetrics.entries()).map(([slug, m]) => {
667
+ const avgQ = (m.qualitySum / m.total).toFixed(1);
668
+ const emptyPct = ((m.emptyCount / m.total) * 100).toFixed(0);
669
+ const topGaps = m.gaps.slice(-3).map(g => g.slice(0, 60)).join('; ') || 'none';
670
+ return `- ${slug}: avg quality ${avgQ}/5, ${emptyPct}% empty outputs, common gaps: "${topGaps}"`;
671
+ }).join('\n')
672
+ : '(no per-agent data yet)';
673
+ // Format goal health data
674
+ const goalHealthText = metrics.goalHealth.length > 0
675
+ ? metrics.goalHealth.map(g => {
676
+ const staleTag = g.isStale ? ' ⚠ STALE' : '';
677
+ const linkedTag = g.linkedCronJobs.length > 0 ? ` | Linked crons: ${g.linkedCronJobs.join(', ')}` : ' | No linked crons';
678
+ return `- [${g.status.toUpperCase()}] ${g.title} (${g.priority}) — owner: ${g.owner} | ${g.daysSinceUpdate}d since update | ${g.progressCount} progress notes${linkedTag}${staleTag}`;
679
+ }).join('\n')
680
+ : '(no goals defined)';
681
+ const advisorText = metrics.advisorInsights.length > 0
682
+ ? metrics.advisorInsights.map(a => `- ${a}`).join('\n')
683
+ : '(no advisor data yet)';
684
+ const areas = this.config.areas.map(a => `'${a}'`).join(', ');
685
+ const agentFocusText = this.config.agentSlug
686
+ ? `\n\n## AGENT FOCUS: ${this.config.agentSlug}\nThis is a focused improvement cycle for agent "${this.config.agentSlug}" ONLY.\n` +
687
+ `- You MUST target area "agent" with target "${this.config.agentSlug}", OR area "cron" targeting a cron job that this agent runs.\n` +
688
+ `- Do NOT propose changes to SOUL.md, AGENTS.md, source code, or other agents.\n` +
689
+ `- Focus on improving this agent's personality, instructions, and task execution quality.\n`
690
+ : '';
691
+ // Read SOUL.md evolution candidates from FEEDBACK.md (written by synthesizeFeedbackPatterns)
692
+ let soulCandidatesText = '';
693
+ try {
694
+ const feedbackFile = path.join(VAULT_DIR, '00-System', 'FEEDBACK.md');
695
+ if (existsSync(feedbackFile)) {
696
+ const parsed = matter(readFileSync(feedbackFile, 'utf-8'));
697
+ if (parsed.data?.soul_candidates) {
698
+ soulCandidatesText = `\n\n## Pending SOUL.md Evolution Candidates (from feedback synthesis)\n` +
699
+ `These are evidence-backed personality changes identified from user interactions. ` +
700
+ `Prioritize these when considering "soul" area improvements:\n${parsed.data.soul_candidates}\n`;
701
+ }
702
+ }
703
+ }
704
+ catch { /* non-fatal */ }
705
+ // ── Step 1: Analysis — identify top opportunities from metrics (no config dumps) ──
706
+ const analysisPrompt = `You are Clementine's self-improvement strategist. Analyze the performance data below and identify the top 3 improvement opportunities.\n\n` +
707
+ `## Recent Performance Data (last 7 days)\n` +
708
+ `- Feedback: ${metrics.feedbackStats.positive} positive, ${metrics.feedbackStats.negative} negative, ${metrics.feedbackStats.mixed} mixed (${metrics.feedbackStats.total} total)\n` +
709
+ `- Cron success rate: ${(metrics.cronSuccessRate * 100).toFixed(1)}%\n\n` +
710
+ `### Negative feedback examples:\n${negativeFeedbackText}\n\n` +
711
+ `### Cron job quality reflections (automated self-evaluation):\n${cronReflectionsText}\n\n` +
712
+ `### Per-agent cron performance:\n${perAgentText}\n\n` +
713
+ `### Goal health:\n${goalHealthText}\n\n` +
714
+ `### Execution advisor intervention outcomes:\n${advisorText}\n\n` +
715
+ `### Cron job errors:\n${cronErrorsText}\n\n` +
716
+ targetedTriggers +
717
+ `## Experiment History (avoid repeating failed approaches):\n${historyText}\n\n` +
718
+ (patternAnalysis ? `${patternAnalysis}\n\n` : '') +
719
+ diversityConstraint +
720
+ agentFocusText +
721
+ soulCandidatesText +
722
+ `\n## Instructions\n` +
723
+ `Rank these by expected impact. For each opportunity, specify:\n` +
724
+ `- area: ${areas}\n` +
725
+ `- target: the file/agent slug that should change\n` +
726
+ `- what: a 1-sentence description of what specifically should change\n` +
727
+ `- why: which metric this should improve\n\n` +
728
+ `Area notes:\n` +
729
+ `- For "goal": target = "{owner}/{goal-slug}" (e.g. "clementine/improve-reply-rates" or "ross-the-sdr/book-demos"). ` +
730
+ `Propose when you observe a pattern in completed tasks or cron runs that suggests a missing or stale goal. ` +
731
+ `The proposedChange must be a JSON goal object with at minimum: title, description, priority, reviewFrequency.\n\n` +
732
+ `Output ONLY a JSON array of 1-3 objects (no markdown, no explanation):\n` +
733
+ `[{ "area": "...", "target": "...", "what": "...", "why": "..." }]\n` +
734
+ `If no improvement is needed, output: []`;
735
+ const analysisResult = await this.assistant.runPlanStep('si-analyze', analysisPrompt, {
736
+ tier: 2,
737
+ maxTurns: 3,
738
+ disableTools: true,
739
+ outputFormat: {
740
+ type: 'json_schema',
741
+ schema: {
742
+ type: 'object',
743
+ properties: {
744
+ results: {
745
+ type: 'array',
746
+ items: {
747
+ type: 'object',
748
+ properties: {
749
+ area: { type: 'string' },
750
+ target: { type: 'string' },
751
+ what: { type: 'string' },
752
+ why: { type: 'string' },
753
+ },
754
+ required: ['area', 'target', 'what', 'why'],
755
+ },
756
+ },
757
+ },
758
+ required: ['results'],
759
+ },
760
+ },
761
+ });
762
+ const rawParsed = this.parseJsonResponse(analysisResult);
763
+ // Handle both wrapped {results:[...]} and bare array responses
764
+ const opportunities = Array.isArray(rawParsed)
765
+ ? rawParsed
766
+ : Array.isArray(rawParsed?.results) ? rawParsed.results
767
+ : rawParsed ? [rawParsed] : [];
768
+ if (opportunities.length === 0)
769
+ return null;
770
+ // Pick the first opportunity that isn't over-targeted
771
+ const selected = opportunities.find((o) => {
772
+ const key = `${o.area}:${o.target}`;
773
+ return !overTargeted.includes(key) && !overTargetedAreas.includes(o.area);
774
+ }) ?? opportunities[0];
775
+ // ── Step 2: Proposal — load only the target file, generate specific change ──
776
+ const currentContent = await this.readCurrentState(selected.area, selected.target);
777
+ const proposalPrompt = `You identified this as the highest-impact improvement:\n` +
778
+ `- Area: ${selected.area}\n` +
779
+ `- Target: ${selected.target}\n` +
780
+ `- What: ${selected.what}\n` +
781
+ `- Why: ${selected.why}\n\n` +
782
+ `## Current file content:\n${currentContent.slice(0, 5000)}\n\n` +
783
+ `## Instructions\n` +
784
+ `- Generate a SPECIFIC, MINIMAL change (not a full rewrite)\n` +
785
+ `- Explain WHY this change should improve the metric\n` +
786
+ `- IMPORTANT: "proposedChange" must be the COMPLETE updated file content (not just the diff), because it will replace the entire file\n` +
787
+ `- For source code changes: preserve all imports, exports, and function signatures. Only modify implementation details.\n\n` +
788
+ `Output ONLY a JSON object (no markdown, no explanation):\n` +
789
+ `{ "area": "${selected.area}", "target": "${selected.target}", "hypothesis": "what will improve and why", "proposedChange": "the complete updated file content with your minimal change applied" }`;
790
+ const result = await this.assistant.runPlanStep('si-hypothesize', proposalPrompt, {
791
+ tier: 2,
792
+ maxTurns: 5,
793
+ disableTools: true,
794
+ });
795
+ return this.parseJsonResponse(result);
796
+ }
797
+ // ── Step 4: Read current state ───────────────────────────────────
798
+ async readCurrentState(area, target) {
799
+ switch (area) {
800
+ case 'soul':
801
+ return existsSync(SOUL_FILE) ? readFileSync(SOUL_FILE, 'utf-8') : '';
802
+ case 'cron':
803
+ return existsSync(CRON_FILE) ? readFileSync(CRON_FILE, 'utf-8') : '';
804
+ case 'workflow': {
805
+ const wfFile = path.join(WORKFLOWS_DIR, target.endsWith('.md') ? target : `${target}.md`);
806
+ return existsSync(wfFile) ? readFileSync(wfFile, 'utf-8') : '';
807
+ }
808
+ case 'agent': {
809
+ const agentFile = path.join(AGENTS_DIR, target, 'agent.md');
810
+ return existsSync(agentFile) ? readFileSync(agentFile, 'utf-8') : '';
811
+ }
812
+ case 'source': {
813
+ const srcFile = path.join(PKG_DIR, 'src', target);
814
+ return existsSync(srcFile) ? readFileSync(srcFile, 'utf-8') : '';
815
+ }
816
+ case 'communication':
817
+ return existsSync(AGENTS_FILE) ? readFileSync(AGENTS_FILE, 'utf-8') : '';
818
+ case 'memory': {
819
+ const memoryFile = path.join(VAULT_DIR, '00-System', 'MEMORY.md');
820
+ return existsSync(memoryFile) ? readFileSync(memoryFile, 'utf-8') : '';
821
+ }
822
+ case 'goal': {
823
+ // target = "{owner}" e.g. "clementine" or an agent slug
824
+ const owner = target.split('/')[0];
825
+ const goalDir = owner === 'clementine'
826
+ ? GOALS_DIR
827
+ : path.join(AGENTS_DIR, owner, 'goals');
828
+ if (!existsSync(goalDir))
829
+ return '(no goals yet for this owner)';
830
+ const files = readdirSync(goalDir).filter(f => f.endsWith('.json') && !readdirSync(goalDir).includes(f + '.bak'));
831
+ const goals = files.map(f => {
832
+ try {
833
+ return JSON.parse(readFileSync(path.join(goalDir, f), 'utf-8'));
834
+ }
835
+ catch {
836
+ return null;
837
+ }
838
+ }).filter(Boolean);
839
+ if (goals.length === 0)
840
+ return '(no goals yet for this owner)';
841
+ return goals.map((g) => `[${g.status ?? 'unknown'}] ${g.title}: ${(g.description ?? '').slice(0, 120)}`).join('\n');
842
+ }
843
+ default:
844
+ return '';
845
+ }
846
+ }
847
+ // ── Step 5: LLM judge evaluation ─────────────────────────────────
848
+ async evaluate(before, after, hypothesis) {
849
+ const prompt = `Score this proposed change using the structured rubric below.\n\n` +
850
+ `## Current text (before):\n${before.slice(0, 3000)}\n\n` +
851
+ `## Proposed change (after):\n${after.slice(0, 3000)}\n\n` +
852
+ `## Hypothesis:\n${hypothesis}\n\n` +
853
+ `## Rubric (score each criterion 0, 1, or 2):\n` +
854
+ `1. Specificity: 0=vague/generic, 1=somewhat specific, 2=precise and actionable\n` +
855
+ `2. Evidence: 0=no data backing, 1=some metric reference, 2=directly addresses a measured weakness\n` +
856
+ `3. Safety: 0=breaks guardrails or removes constraints, 1=minor concern, 2=clean, maintains all constraints\n` +
857
+ `4. Impact: 0=unlikely to help, 1=plausible improvement, 2=high-confidence improvement\n` +
858
+ `5. Novelty: 0=repeat of a failed approach, 1=incremental variation, 2=fresh angle\n\n` +
859
+ `Sum the 5 scores for a total 0-10.\n\n` +
860
+ `Output ONLY a JSON object (no markdown, no explanation):\n` +
861
+ `{ "specificity": <0-2>, "evidence": <0-2>, "safety": <0-2>, "impact": <0-2>, "novelty": <0-2>, "score": <0-10>, "reasoning": "brief explanation" }`;
862
+ const result = await this.assistant.runPlanStep('si-evaluate', prompt, {
863
+ tier: 2,
864
+ maxTurns: 3,
865
+ disableTools: true,
866
+ outputFormat: {
867
+ type: 'json_schema',
868
+ schema: {
869
+ type: 'object',
870
+ properties: {
871
+ specificity: { type: 'number' },
872
+ evidence: { type: 'number' },
873
+ safety: { type: 'number' },
874
+ impact: { type: 'number' },
875
+ novelty: { type: 'number' },
876
+ score: { type: 'number' },
877
+ reasoning: { type: 'string' },
878
+ },
879
+ required: ['score', 'reasoning'],
880
+ },
881
+ },
882
+ });
883
+ return this.parseJsonResponse(result);
884
+ }
885
+ // ── Step 6: Save pending change ──────────────────────────────────
886
+ async savePendingChange(experiment, before) {
887
+ ensureDirs();
888
+ const filePath = path.join(PENDING_DIR, `${experiment.id}.json`);
889
+ const pending = {
890
+ ...experiment,
891
+ before,
892
+ createdAt: new Date().toISOString(),
893
+ };
894
+ writeFileSync(filePath, JSON.stringify(pending, null, 2));
895
+ logger.info({ id: experiment.id, area: experiment.area }, 'Saved pending change');
896
+ }
897
+ // ── Apply approved change ────────────────────────────────────────
898
+ async applyApprovedChange(experimentId) {
899
+ const pendingFile = path.join(PENDING_DIR, `${experimentId}.json`);
900
+ if (!existsSync(pendingFile)) {
901
+ return `Pending change not found: ${experimentId}`;
902
+ }
903
+ const pending = JSON.parse(readFileSync(pendingFile, 'utf-8'));
904
+ const targetPath = this.resolveTargetPath(pending.area, pending.target);
905
+ if (!targetPath) {
906
+ return `Cannot resolve target path for area=${pending.area}, target=${pending.target}`;
907
+ }
908
+ // Route source changes through the safe pipeline
909
+ if (pending.area === 'source') {
910
+ const { safeSourceEdit } = await import('./safe-restart.js');
911
+ const result = await safeSourceEdit(PKG_DIR, [
912
+ { relativePath: `src/${pending.target}`, content: pending.proposedChange },
913
+ ], { experimentId, reason: `self-improve: ${pending.hypothesis.slice(0, 60)}`, description: pending.hypothesis });
914
+ if (!result.success) {
915
+ return `Source edit failed: ${result.error}${result.preflightErrors ? '\n' + result.preflightErrors.join('\n') : ''}`;
916
+ }
917
+ // Update experiment log — mark as approved
918
+ this.updateExperimentStatus(experimentId, 'approved');
919
+ try {
920
+ unlinkSync(pendingFile);
921
+ }
922
+ catch { /* ignore */ }
923
+ const state = this.loadState();
924
+ state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
925
+ this.saveState(state);
926
+ // Schedule impact measurement for 24h later
927
+ try {
928
+ appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
929
+ experimentId,
930
+ area: pending.area,
931
+ target: pending.target,
932
+ appliedAt: new Date().toISOString(),
933
+ checkAfterMs: 24 * 60 * 60 * 1000,
934
+ }) + '\n');
935
+ }
936
+ catch (err) {
937
+ logger.warn({ err }, 'Failed to schedule impact check');
938
+ }
939
+ return `Applied source change to ${pending.target} — restart triggered.`;
940
+ }
941
+ // Goal area: parse JSON, inject required fields, ensure parent dir exists
942
+ if (pending.area === 'goal') {
943
+ try {
944
+ const goalData = JSON.parse(pending.proposedChange);
945
+ const [owner, goalSlug] = pending.target.split('/');
946
+ if (!goalSlug)
947
+ return `Invalid goal target (need "owner/slug"): ${pending.target}`;
948
+ const goalDir = owner === 'clementine'
949
+ ? GOALS_DIR
950
+ : path.join(AGENTS_DIR, owner, 'goals');
951
+ mkdirSync(goalDir, { recursive: true });
952
+ const now = new Date().toISOString();
953
+ const goalJson = JSON.stringify({
954
+ id: goalSlug,
955
+ owner,
956
+ status: 'active',
957
+ createdAt: now,
958
+ progressNotes: [],
959
+ ...goalData,
960
+ updatedAt: now,
961
+ }, null, 2);
962
+ writeFileSync(targetPath, goalJson);
963
+ this.recordVersion(experimentId, pending.area, pending.target, pending.hypothesis, pending.before);
964
+ this.updateExperimentStatus(experimentId, 'approved');
965
+ try {
966
+ unlinkSync(pendingFile);
967
+ }
968
+ catch { /* ignore */ }
969
+ const state = this.loadState();
970
+ state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
971
+ this.saveState(state);
972
+ logger.info({ id: experimentId, target: pending.target }, 'Goal created from self-improve proposal');
973
+ return `Goal created: ${goalData.title ?? goalSlug}`;
974
+ }
975
+ catch (err) {
976
+ return `Failed to create goal: ${err instanceof Error ? err.message : String(err)}`;
977
+ }
978
+ }
979
+ // Final validation before writing
980
+ const validation = this.validateProposal(pending.area, pending.target, pending.proposedChange);
981
+ if (!validation.valid) {
982
+ return `Cannot apply change — validation failed: ${validation.error}`;
983
+ }
984
+ // Drift check for soul changes — even approved changes must not drift too far
985
+ if (pending.area === 'soul') {
986
+ const drift = checkDrift(pending.proposedChange);
987
+ if (!drift.ok) {
988
+ return `Cannot apply change — identity drift too high (similarity: ${drift.similarity.toFixed(3)}, threshold: ${DRIFT_SIMILARITY_THRESHOLD})`;
989
+ }
990
+ }
991
+ // Write the change (non-source areas)
992
+ writeFileSync(targetPath, pending.proposedChange);
993
+ // Record version for rollback lineage
994
+ this.recordVersion(experimentId, pending.area, pending.target, pending.hypothesis, pending.before);
995
+ logger.info({ id: experimentId, area: pending.area, target: pending.target }, 'Applied approved change');
996
+ // Update experiment log — mark as approved
997
+ this.updateExperimentStatus(experimentId, 'approved');
998
+ // Remove pending file
999
+ try {
1000
+ unlinkSync(pendingFile);
1001
+ }
1002
+ catch { /* ignore */ }
1003
+ // Update state
1004
+ const state = this.loadState();
1005
+ state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
1006
+ this.saveState(state);
1007
+ // Schedule impact measurement for 24h later
1008
+ try {
1009
+ appendFileSync(IMPACT_CHECKS_FILE, JSON.stringify({
1010
+ experimentId,
1011
+ area: pending.area,
1012
+ target: pending.target,
1013
+ appliedAt: new Date().toISOString(),
1014
+ checkAfterMs: 24 * 60 * 60 * 1000,
1015
+ }) + '\n');
1016
+ }
1017
+ catch (err) {
1018
+ logger.warn({ err }, 'Failed to schedule impact check');
1019
+ }
1020
+ return `Applied change to ${pending.area}/${pending.target}`;
1021
+ }
1022
+ /** Deny a pending change without applying it. */
1023
+ denyChange(experimentId) {
1024
+ const pendingFile = path.join(PENDING_DIR, `${experimentId}.json`);
1025
+ if (!existsSync(pendingFile)) {
1026
+ return `Pending change not found: ${experimentId}`;
1027
+ }
1028
+ this.updateExperimentStatus(experimentId, 'denied');
1029
+ try {
1030
+ unlinkSync(pendingFile);
1031
+ }
1032
+ catch { /* ignore */ }
1033
+ const state = this.loadState();
1034
+ state.pendingApprovals = Math.max(0, state.pendingApprovals - 1);
1035
+ this.saveState(state);
1036
+ return `Denied change: ${experimentId}`;
1037
+ }
1038
+ // ── Memory cleanup ───────────────────────────────────────────────
1039
+ async runMemoryCleanup() {
1040
+ try {
1041
+ const { MemoryStore } = await import('../memory/store.js');
1042
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
1043
+ store.initialize();
1044
+ store.decaySalience(30);
1045
+ store.pruneStaleData({
1046
+ maxAgeDays: 90,
1047
+ salienceThreshold: 0.01,
1048
+ accessLogRetentionDays: 60,
1049
+ transcriptRetentionDays: 90,
1050
+ });
1051
+ store.close();
1052
+ logger.info('Memory cleanup complete');
1053
+ }
1054
+ catch (err) {
1055
+ logger.error({ err }, 'Memory cleanup failed');
1056
+ }
1057
+ }
1058
+ // ── Feedback synthesis ───────────────────────────────────────────
1059
+ async synthesizeFeedbackPatterns() {
1060
+ try {
1061
+ const { MemoryStore } = await import('../memory/store.js');
1062
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
1063
+ store.initialize();
1064
+ // Gather from multiple sources
1065
+ const recentFeedback = store.getRecentFeedback(50);
1066
+ const reflections = store.getRecentReflections(20);
1067
+ const behavioralPatterns = store.getBehavioralPatterns(2);
1068
+ const corrections = store.getRecentCorrections(10);
1069
+ store.close();
1070
+ const totalSignals = recentFeedback.length + reflections.length + behavioralPatterns.length;
1071
+ if (totalSignals < 3) {
1072
+ logger.info({ totalSignals }, 'Not enough data to synthesize (need 3+)');
1073
+ return;
1074
+ }
1075
+ // Format feedback by rating
1076
+ const grouped = {};
1077
+ for (const f of recentFeedback) {
1078
+ (grouped[f.rating] ??= []).push(f);
1079
+ }
1080
+ const feedbackLines = [];
1081
+ for (const [rating, items] of Object.entries(grouped)) {
1082
+ feedbackLines.push(`### ${rating.toUpperCase()} (${items.length})`);
1083
+ for (const f of items.slice(0, 15)) {
1084
+ const snippet = f.messageSnippet ? ` | Message: "${f.messageSnippet.slice(0, 100)}"` : '';
1085
+ const comment = f.comment ? ` | Comment: "${f.comment}"` : '';
1086
+ feedbackLines.push(`- ${f.channel}${snippet}${comment}`);
1087
+ }
1088
+ }
1089
+ // Format session reflections
1090
+ const reflectionLines = reflections.map(r => {
1091
+ const friction = r.frictionSignals.length > 0 ? ` | Friction: ${r.frictionSignals.join('; ')}` : '';
1092
+ const corrections = r.behavioralCorrections.length > 0
1093
+ ? ` | Corrections: ${r.behavioralCorrections.map(c => `${c.correction} [${c.category}]`).join('; ')}`
1094
+ : '';
1095
+ const prefs = r.preferencesLearned.length > 0
1096
+ ? ` | Preferences: ${r.preferencesLearned.map(p => `${p.preference} [${p.confidence}]`).join('; ')}`
1097
+ : '';
1098
+ return `- Session ${r.sessionKey.slice(0, 30)} | Quality: ${r.qualityScore}/5 | ${r.exchangeCount} exchanges${friction}${corrections}${prefs}`;
1099
+ });
1100
+ // Format recurring behavioral patterns
1101
+ const patternLines = behavioralPatterns.map(p => `- "${p.correction}" [${p.category}] — ${p.count} occurrences (last: ${p.lastSeen})`);
1102
+ const prompt = `Analyze the multi-source data below about an AI assistant's interactions and produce TWO outputs.\n\n` +
1103
+ `## 1. Feedback Entries (${recentFeedback.length})\n${feedbackLines.join('\n') || '(none)'}\n\n` +
1104
+ `## 2. Session Reflections (${reflections.length})\n${reflectionLines.join('\n') || '(none)'}\n\n` +
1105
+ `## 3. Recurring Behavioral Corrections (appeared 2+ times)\n${patternLines.join('\n') || '(none)'}\n\n` +
1106
+ `## 4. Recent Fact Corrections (${corrections.length})\n` +
1107
+ corrections.map(c => `- ${c.correction}`).join('\n') + '\n\n' +
1108
+ `## OUTPUT 1: Communication Preferences\n` +
1109
+ `Synthesize 5-10 specific, actionable behavioral rules from the evidence.\n` +
1110
+ `Each rule should be:\n` +
1111
+ `- Specific enough to follow ("Be concise" is bad; "Keep responses under 3 sentences unless asked for detail" is good)\n` +
1112
+ `- Evidence-based (mention the signal count or pattern that supports it)\n` +
1113
+ `- Categorized in brackets: [tone], [format], [verbosity], [proactivity], [workflow], [scope]\n\n` +
1114
+ `## OUTPUT 2: SOUL.md Evolution Candidates\n` +
1115
+ `Identify 0-3 patterns strong enough (3+ occurrences or high confidence) to warrant a change to the agent's\n` +
1116
+ `core personality (SOUL.md). These should be durable behavioral shifts, not one-off preferences.\n` +
1117
+ `For each candidate, output:\n` +
1118
+ `- What trait should change\n` +
1119
+ `- Current behavior → Desired behavior\n` +
1120
+ `- Evidence count\n` +
1121
+ `- Confidence: high (5+ signals) / medium (3-4 signals)\n\n` +
1122
+ `Format your response as:\n` +
1123
+ `## Communication Preferences\n` +
1124
+ `- [category] Specific rule (evidence: N signals)\n` +
1125
+ `...\n\n` +
1126
+ `## SOUL.md Candidates\n` +
1127
+ `- **Trait**: Current → Desired (evidence: N signals, confidence: high/medium)\n` +
1128
+ `...\n\n` +
1129
+ `If there are no SOUL.md candidates, write "No candidates — current personality is well-calibrated."`;
1130
+ const result = await this.assistant.runPlanStep('si-feedback-synthesis', prompt, {
1131
+ tier: 2,
1132
+ maxTurns: 1,
1133
+ disableTools: true,
1134
+ });
1135
+ // Extract communication preferences
1136
+ const prefSection = result.match(/## Communication Preferences\s*\n([\s\S]*?)(?=\n## SOUL|$)/i);
1137
+ const bullets = prefSection
1138
+ ? prefSection[1].split('\n').map(l => l.trim()).filter(l => l.startsWith('- '))
1139
+ : result.split('\n').map(l => l.trim()).filter(l => l.startsWith('- '));
1140
+ // Extract SOUL.md candidates
1141
+ const soulSection = result.match(/## SOUL\.md Candidates\s*\n([\s\S]*?)$/i);
1142
+ const soulCandidates = soulSection
1143
+ ? soulSection[1].split('\n').map(l => l.trim()).filter(l => l.startsWith('- '))
1144
+ : [];
1145
+ if (bullets.length === 0 && soulCandidates.length === 0) {
1146
+ logger.warn('Feedback synthesis returned no output');
1147
+ return;
1148
+ }
1149
+ const patternsSummary = bullets.join('\n');
1150
+ const soulCandidatesSummary = soulCandidates.length > 0
1151
+ ? soulCandidates.join('\n')
1152
+ : 'No candidates — current personality is well-calibrated.';
1153
+ const feedbackDir = path.join(VAULT_DIR, '00-System');
1154
+ if (!existsSync(feedbackDir))
1155
+ mkdirSync(feedbackDir, { recursive: true });
1156
+ const feedbackFile = path.join(feedbackDir, 'FEEDBACK.md');
1157
+ const content = matter.stringify(`\n## Communication Preferences\n\n${patternsSummary}\n\n## Pending SOUL.md Candidates\n\n${soulCandidatesSummary}\n`, {
1158
+ patterns_summary: patternsSummary,
1159
+ soul_candidates: soulCandidatesSummary,
1160
+ last_synthesized: new Date().toISOString(),
1161
+ feedback_count: recentFeedback.length,
1162
+ reflection_count: reflections.length,
1163
+ behavioral_correction_count: behavioralPatterns.length,
1164
+ });
1165
+ writeFileSync(feedbackFile, content);
1166
+ // Write agent-specific PREFERENCES.md for agents with enough data
1167
+ const agentReflections = new Map();
1168
+ for (const r of reflections) {
1169
+ if (r.agentSlug && r.agentSlug !== 'clementine') {
1170
+ if (!agentReflections.has(r.agentSlug))
1171
+ agentReflections.set(r.agentSlug, []);
1172
+ agentReflections.get(r.agentSlug).push(r);
1173
+ }
1174
+ }
1175
+ for (const [slug, agentRefls] of agentReflections) {
1176
+ if (agentRefls.length < 2)
1177
+ continue; // Need enough data
1178
+ const agentCorrections = agentRefls.flatMap(r => r.behavioralCorrections);
1179
+ if (agentCorrections.length === 0)
1180
+ continue;
1181
+ const agentPrefs = agentCorrections.map(c => `- [${c.category}] ${c.correction} (${c.strength})`).join('\n');
1182
+ const agentDir = path.join(AGENTS_DIR, slug);
1183
+ if (existsSync(agentDir)) {
1184
+ const prefsFile = path.join(agentDir, 'PREFERENCES.md');
1185
+ const prefsContent = matter.stringify(`\n## Agent Preferences\n\n${agentPrefs}\n`, {
1186
+ preferences: agentPrefs,
1187
+ last_synthesized: new Date().toISOString(),
1188
+ reflection_count: agentRefls.length,
1189
+ });
1190
+ writeFileSync(prefsFile, prefsContent);
1191
+ logger.info({ slug, corrections: agentCorrections.length }, 'Agent-specific preferences written');
1192
+ }
1193
+ }
1194
+ logger.info({
1195
+ bullets: bullets.length,
1196
+ soulCandidates: soulCandidates.length,
1197
+ feedbackCount: recentFeedback.length,
1198
+ reflectionCount: reflections.length,
1199
+ }, 'Feedback patterns + SOUL.md candidates synthesized to FEEDBACK.md');
1200
+ }
1201
+ catch (err) {
1202
+ logger.error({ err }, 'Feedback synthesis failed');
1203
+ }
1204
+ }
1205
+ // ── User Theory of Mind ──────────────────────────────────────────
1206
+ /** Update the structured user model from interaction data. */
1207
+ async updateUserModel() {
1208
+ try {
1209
+ const { MemoryStore } = await import('../memory/store.js');
1210
+ const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
1211
+ store.initialize();
1212
+ const reflections = store.getRecentReflections(30);
1213
+ const feedback = store.getRecentFeedback(30);
1214
+ const patterns = store.getBehavioralPatterns(1);
1215
+ store.close();
1216
+ if (reflections.length + feedback.length < 5) {
1217
+ logger.info('Not enough interaction data for user model update');
1218
+ return;
1219
+ }
1220
+ // Read existing model
1221
+ const modelFile = path.join(VAULT_DIR, '00-System', 'USER_MODEL.md');
1222
+ let existingModel = '';
1223
+ if (existsSync(modelFile)) {
1224
+ existingModel = readFileSync(modelFile, 'utf-8');
1225
+ }
1226
+ const reflectionSummary = reflections.slice(0, 15).map(r => {
1227
+ const corrections = r.behavioralCorrections.map(c => `${c.correction} [${c.category}]`).join('; ');
1228
+ return `- Quality: ${r.qualityScore}/5, ${r.exchangeCount} exchanges${corrections ? `, corrections: ${corrections}` : ''}`;
1229
+ }).join('\n');
1230
+ const feedbackSummary = feedback.slice(0, 15).map(f => `- [${f.rating}] ${f.channel}: ${f.comment || f.messageSnippet || '(no detail)'}`.slice(0, 120)).join('\n');
1231
+ const patternSummary = patterns.map(p => `- "${p.correction}" [${p.category}] x${p.count}`).join('\n');
1232
+ const prompt = `You are updating a structured user model based on interaction data. The model tracks the owner's expertise, priorities, communication preferences, and behavioral patterns.\n\n` +
1233
+ `## Current Model\n${existingModel || '(empty — first synthesis)'}\n\n` +
1234
+ `## Recent Session Reflections (${reflections.length})\n${reflectionSummary || '(none)'}\n\n` +
1235
+ `## Recent Feedback (${feedback.length})\n${feedbackSummary || '(none)'}\n\n` +
1236
+ `## Recurring Behavioral Patterns\n${patternSummary || '(none)'}\n\n` +
1237
+ `## Instructions\n` +
1238
+ `Output a YAML frontmatter block for USER_MODEL.md. Include these sections:\n` +
1239
+ `- expertise: map of domain → level (beginner/intermediate/expert) based on how they interact\n` +
1240
+ `- priorities: list of current focus areas with priority level\n` +
1241
+ `- communication: style, verbosity, decision_making, time_sensitivity\n` +
1242
+ `- patterns: morning/afternoon/evening behavioral patterns\n` +
1243
+ `- confidence_scores: how confident each section is (0-1)\n\n` +
1244
+ `Preserve existing data that's still accurate. Only update fields where new evidence supports a change.\n` +
1245
+ `Output ONLY the YAML frontmatter block (--- delimited), no other text.`;
1246
+ const result = await this.assistant.runPlanStep('si-user-model', prompt, {
1247
+ tier: 1,
1248
+ maxTurns: 1,
1249
+ disableTools: true,
1250
+ });
1251
+ // Extract YAML frontmatter from response
1252
+ const yamlMatch = result.match(/---\s*\n([\s\S]*?)\n---/);
1253
+ if (!yamlMatch) {
1254
+ logger.warn('User model synthesis returned no YAML block');
1255
+ return;
1256
+ }
1257
+ const modelDir = path.join(VAULT_DIR, '00-System');
1258
+ if (!existsSync(modelDir))
1259
+ mkdirSync(modelDir, { recursive: true });
1260
+ const content = `---\n${yamlMatch[1].trim()}\nlast_updated: "${new Date().toISOString()}"\n---\n\n# User Model\n\nThis file is auto-generated by the self-improvement loop. It captures a structured understanding of the owner based on interaction patterns, feedback, and behavioral corrections.\n`;
1261
+ writeFileSync(modelFile, content);
1262
+ logger.info('User model updated: USER_MODEL.md');
1263
+ }
1264
+ catch (err) {
1265
+ logger.error({ err }, 'User model update failed');
1266
+ }
1267
+ }
1268
+ // ── JSONL log management ─────────────────────────────────────────
1269
+ loadExperimentLog() {
1270
+ if (!existsSync(EXPERIMENT_LOG))
1271
+ return [];
1272
+ try {
1273
+ return readFileSync(EXPERIMENT_LOG, 'utf-8')
1274
+ .trim()
1275
+ .split('\n')
1276
+ .filter(Boolean)
1277
+ .map(line => JSON.parse(line));
1278
+ }
1279
+ catch {
1280
+ return [];
1281
+ }
1282
+ }
1283
+ appendExperimentLog(entry) {
1284
+ ensureDirs();
1285
+ appendFileSync(EXPERIMENT_LOG, JSON.stringify(entry) + '\n');
1286
+ }
1287
+ updateExperimentStatus(experimentId, status) {
1288
+ const experiments = this.loadExperimentLog();
1289
+ const updated = experiments.map(e => e.id === experimentId ? { ...e, approvalStatus: status } : e);
1290
+ writeFileSync(EXPERIMENT_LOG, updated.map(e => JSON.stringify(e)).join('\n') + '\n');
1291
+ }
1292
+ // ── State management ─────────────────────────────────────────────
1293
+ loadState() {
1294
+ if (existsSync(STATE_FILE)) {
1295
+ try {
1296
+ return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
1297
+ }
1298
+ catch { /* fall through to default */ }
1299
+ }
1300
+ return {
1301
+ status: 'idle',
1302
+ lastRunAt: '',
1303
+ currentIteration: 0,
1304
+ totalExperiments: 0,
1305
+ baselineMetrics: {
1306
+ feedbackPositiveRatio: 0,
1307
+ cronSuccessRate: 0,
1308
+ avgResponseQuality: 0,
1309
+ },
1310
+ pendingApprovals: 0,
1311
+ };
1312
+ }
1313
+ /** Reconcile pendingApprovals counter with actual pending-changes/ directory. */
1314
+ reconcileState() {
1315
+ const state = this.loadState();
1316
+ const actualPending = this.getPendingChanges().length;
1317
+ if (state.pendingApprovals !== actualPending) {
1318
+ logger.warn({ stored: state.pendingApprovals, actual: actualPending }, 'Pending approvals counter drift — reconciling');
1319
+ state.pendingApprovals = actualPending;
1320
+ this.saveState(state);
1321
+ }
1322
+ return state;
1323
+ }
1324
+ /** Expire pending proposals older than APPROVAL_TTL_MS. */
1325
+ expireStaleProposals() {
1326
+ const pending = this.getPendingChanges();
1327
+ let expired = 0;
1328
+ const now = Date.now();
1329
+ for (const p of pending) {
1330
+ const createdAt = p.createdAt
1331
+ ? new Date(p.createdAt).getTime()
1332
+ : new Date(p.finishedAt).getTime();
1333
+ if (now - createdAt > APPROVAL_TTL_MS) {
1334
+ this.updateExperimentStatus(p.id, 'expired');
1335
+ try {
1336
+ unlinkSync(path.join(PENDING_DIR, `${p.id}.json`));
1337
+ }
1338
+ catch { /* ignore */ }
1339
+ expired++;
1340
+ logger.info({ id: p.id, area: p.area, target: p.target }, 'Expired stale proposal');
1341
+ }
1342
+ }
1343
+ if (expired > 0) {
1344
+ const state = this.loadState();
1345
+ state.pendingApprovals = Math.max(0, state.pendingApprovals - expired);
1346
+ this.saveState(state);
1347
+ }
1348
+ return expired;
1349
+ }
1350
+ /** Check impact of previously applied changes. Triggers auto-rollback on regression. */
1351
+ async checkAppliedImpact() {
1352
+ if (!existsSync(IMPACT_CHECKS_FILE))
1353
+ return;
1354
+ try {
1355
+ const lines = readFileSync(IMPACT_CHECKS_FILE, 'utf-8').trim().split('\n').filter(Boolean);
1356
+ const remaining = [];
1357
+ const now = Date.now();
1358
+ const state = this.loadState();
1359
+ for (const line of lines) {
1360
+ try {
1361
+ const check = JSON.parse(line);
1362
+ const appliedAt = new Date(check.appliedAt).getTime();
1363
+ if (now - appliedAt < check.checkAfterMs) {
1364
+ remaining.push(line);
1365
+ continue;
1366
+ }
1367
+ // Measure current state for this area
1368
+ const metrics = await this.gatherMetrics();
1369
+ const currentFeedbackRatio = metrics.feedbackStats.total > 0
1370
+ ? metrics.feedbackStats.positive / metrics.feedbackStats.total : 1;
1371
+ const impact = {
1372
+ type: 'impact',
1373
+ experimentId: check.experimentId,
1374
+ area: check.area,
1375
+ target: check.target,
1376
+ measuredAt: new Date().toISOString(),
1377
+ cronSuccessRate: metrics.cronSuccessRate,
1378
+ feedbackPositiveRatio: currentFeedbackRatio,
1379
+ };
1380
+ appendFileSync(EXPERIMENT_LOG, JSON.stringify(impact) + '\n');
1381
+ logger.info({ ...impact }, 'Impact measurement recorded');
1382
+ // Auto-rollback: check if metrics regressed significantly
1383
+ const baselineCron = state.baselineMetrics?.cronSuccessRate ?? 0;
1384
+ const baselineFeedback = state.baselineMetrics?.feedbackPositiveRatio ?? 0;
1385
+ const cronDrop = baselineCron > 0
1386
+ ? (baselineCron - metrics.cronSuccessRate) / baselineCron : 0;
1387
+ const feedbackDrop = baselineFeedback > 0
1388
+ ? (baselineFeedback - currentFeedbackRatio) / baselineFeedback : 0;
1389
+ if (cronDrop > REGRESSION_ROLLBACK_THRESHOLD || feedbackDrop > REGRESSION_ROLLBACK_THRESHOLD) {
1390
+ logger.warn({
1391
+ experimentId: check.experimentId,
1392
+ cronDrop: `${(cronDrop * 100).toFixed(1)}%`,
1393
+ feedbackDrop: `${(feedbackDrop * 100).toFixed(1)}%`,
1394
+ }, 'Regression detected — initiating auto-rollback');
1395
+ this.rollbackVersion(check.experimentId);
1396
+ }
1397
+ }
1398
+ catch {
1399
+ remaining.push(line);
1400
+ }
1401
+ }
1402
+ writeFileSync(IMPACT_CHECKS_FILE, remaining.length > 0 ? remaining.join('\n') + '\n' : '');
1403
+ }
1404
+ catch (err) {
1405
+ logger.warn({ err }, 'Impact check failed');
1406
+ }
1407
+ }
1408
+ // ── Version Lineage ───────────────────────────────────────────────
1409
+ /** Load evolution version history. */
1410
+ loadVersions() {
1411
+ if (!existsSync(EVOLUTION_VERSIONS_FILE))
1412
+ return [];
1413
+ try {
1414
+ return JSON.parse(readFileSync(EVOLUTION_VERSIONS_FILE, 'utf-8'));
1415
+ }
1416
+ catch {
1417
+ return [];
1418
+ }
1419
+ }
1420
+ /** Save evolution version history. */
1421
+ saveVersions(versions) {
1422
+ ensureDirs();
1423
+ writeFileSync(EVOLUTION_VERSIONS_FILE, JSON.stringify(versions, null, 2));
1424
+ }
1425
+ /** Record a version when a change is applied. */
1426
+ recordVersion(experimentId, area, target, rationale, beforeSnapshot) {
1427
+ const versions = this.loadVersions();
1428
+ // Find parent: most recent non-rolled-back version for the same target
1429
+ const parent = versions
1430
+ .filter(v => v.area === area && v.target === target && !v.rolledBack)
1431
+ .sort((a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime())[0];
1432
+ versions.push({
1433
+ experimentId,
1434
+ area,
1435
+ target,
1436
+ appliedAt: new Date().toISOString(),
1437
+ parentVersion: parent?.experimentId,
1438
+ rationale,
1439
+ beforeSnapshot,
1440
+ });
1441
+ // Keep at most 50 versions to prevent unbounded growth
1442
+ if (versions.length > 50) {
1443
+ versions.splice(0, versions.length - 50);
1444
+ }
1445
+ this.saveVersions(versions);
1446
+ }
1447
+ /** Rollback a specific version by restoring its beforeSnapshot. */
1448
+ rollbackVersion(experimentId) {
1449
+ const versions = this.loadVersions();
1450
+ const version = versions.find(v => v.experimentId === experimentId && !v.rolledBack);
1451
+ if (!version) {
1452
+ logger.warn({ experimentId }, 'No version found to rollback');
1453
+ return false;
1454
+ }
1455
+ const targetPath = this.resolveTargetPath(version.area, version.target);
1456
+ if (!targetPath) {
1457
+ logger.warn({ area: version.area, target: version.target }, 'Cannot resolve target path for rollback');
1458
+ return false;
1459
+ }
1460
+ try {
1461
+ writeFileSync(targetPath, version.beforeSnapshot);
1462
+ version.rolledBack = true;
1463
+ version.rolledBackAt = new Date().toISOString();
1464
+ this.saveVersions(versions);
1465
+ this.updateExperimentStatus(experimentId, 'denied');
1466
+ // Log the rollback event
1467
+ appendFileSync(EXPERIMENT_LOG, JSON.stringify({
1468
+ type: 'rollback',
1469
+ experimentId,
1470
+ area: version.area,
1471
+ target: version.target,
1472
+ rolledBackAt: version.rolledBackAt,
1473
+ reason: 'Regression detected in post-change metrics',
1474
+ }) + '\n');
1475
+ logger.info({ experimentId, area: version.area, target: version.target }, 'Auto-rollback completed');
1476
+ return true;
1477
+ }
1478
+ catch (err) {
1479
+ logger.error({ err, experimentId }, 'Auto-rollback failed');
1480
+ return false;
1481
+ }
1482
+ }
1483
+ saveState(state) {
1484
+ ensureDirs();
1485
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
1486
+ }
1487
+ // ── Pending changes ──────────────────────────────────────────────
1488
+ getPendingChanges() {
1489
+ ensureDirs();
1490
+ if (!existsSync(PENDING_DIR))
1491
+ return [];
1492
+ return readdirSync(PENDING_DIR)
1493
+ .filter(f => f.endsWith('.json'))
1494
+ .map(f => {
1495
+ try {
1496
+ return JSON.parse(readFileSync(path.join(PENDING_DIR, f), 'utf-8'));
1497
+ }
1498
+ catch {
1499
+ return null;
1500
+ }
1501
+ })
1502
+ .filter(Boolean);
1503
+ }
1504
+ // ── Helpers ──────────────────────────────────────────────────────
1505
+ /** Analyze experiment history for success patterns and failed approaches. */
1506
+ analyzeExperimentPatterns(history) {
1507
+ if (history.length < 3)
1508
+ return '';
1509
+ const byArea = new Map();
1510
+ for (const e of history) {
1511
+ if (e.type === 'impact')
1512
+ continue; // skip impact records
1513
+ const entry = byArea.get(e.area) ?? { total: 0, accepted: 0, scoreSum: 0 };
1514
+ entry.total++;
1515
+ if (e.accepted)
1516
+ entry.accepted++;
1517
+ entry.scoreSum += e.score;
1518
+ byArea.set(e.area, entry);
1519
+ }
1520
+ const lines = ['## Experiment Pattern Analysis'];
1521
+ for (const [area, stats] of byArea) {
1522
+ const avg = (stats.scoreSum / stats.total * 10).toFixed(1);
1523
+ const rate = ((stats.accepted / stats.total) * 100).toFixed(0);
1524
+ lines.push(`- ${area}: ${stats.total} experiments, ${rate}% acceptance, avg ${avg}/10`);
1525
+ }
1526
+ // Read impact records from experiment log
1527
+ try {
1528
+ if (existsSync(EXPERIMENT_LOG)) {
1529
+ const allLines = readFileSync(EXPERIMENT_LOG, 'utf-8').trim().split('\n').filter(Boolean);
1530
+ const impacts = allLines
1531
+ .map(l => { try {
1532
+ return JSON.parse(l);
1533
+ }
1534
+ catch {
1535
+ return null;
1536
+ } })
1537
+ .filter((r) => r?.type === 'impact')
1538
+ .slice(-5);
1539
+ if (impacts.length > 0) {
1540
+ lines.push('\n### Measured Impact of Past Applied Changes');
1541
+ for (const ir of impacts) {
1542
+ lines.push(`- ${ir.area}/${ir.target}: cron ${(ir.cronSuccessRate * 100).toFixed(0)}%, feedback ${(ir.feedbackPositiveRatio * 100).toFixed(0)}% positive (measured ${ir.measuredAt})`);
1543
+ }
1544
+ }
1545
+ }
1546
+ }
1547
+ catch { /* non-fatal */ }
1548
+ // Identify consistently failed approaches
1549
+ const failedHypotheses = history
1550
+ .filter(e => !e.accepted && e.score < 0.3 && !e.type)
1551
+ .map(e => e.hypothesis.slice(0, 80));
1552
+ if (failedHypotheses.length > 0) {
1553
+ lines.push('\n### Approaches That Scored Poorly (avoid these)');
1554
+ for (const h of [...new Set(failedHypotheses)].slice(0, 5)) {
1555
+ lines.push(`- "${h}"`);
1556
+ }
1557
+ }
1558
+ return lines.join('\n');
1559
+ }
1560
+ /** Validate that a proposed change has valid syntax for its target area. */
1561
+ validateProposal(area, target, proposedChange) {
1562
+ return validateProposal(area, target, proposedChange);
1563
+ }
1564
+ resolveTargetPath(area, target) {
1565
+ switch (area) {
1566
+ case 'soul':
1567
+ return SOUL_FILE;
1568
+ case 'cron':
1569
+ return CRON_FILE;
1570
+ case 'workflow': {
1571
+ const name = target.endsWith('.md') ? target : `${target}.md`;
1572
+ return path.join(WORKFLOWS_DIR, name);
1573
+ }
1574
+ case 'agent': {
1575
+ return path.join(AGENTS_DIR, target, 'agent.md');
1576
+ }
1577
+ case 'source': {
1578
+ return path.join(PKG_DIR, 'src', target);
1579
+ }
1580
+ case 'communication':
1581
+ return AGENTS_FILE;
1582
+ case 'memory':
1583
+ return path.join(VAULT_DIR, '00-System', 'MEMORY.md');
1584
+ case 'goal': {
1585
+ // target = "{owner}/{goalSlug}" e.g. "clementine/book-10-demos-q2" or "ross-the-sdr/expand-pool"
1586
+ const [owner, goalSlug] = target.split('/');
1587
+ if (!goalSlug)
1588
+ return null; // need both owner and slug
1589
+ if (owner === 'clementine')
1590
+ return path.join(GOALS_DIR, `${goalSlug}.json`);
1591
+ return path.join(AGENTS_DIR, owner, 'goals', `${goalSlug}.json`);
1592
+ }
1593
+ default:
1594
+ return null;
1595
+ }
1596
+ }
1597
+ parseJsonResponse(text) {
1598
+ // Try to extract JSON from the response (may be wrapped in markdown code blocks)
1599
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
1600
+ if (!jsonMatch)
1601
+ return null;
1602
+ try {
1603
+ const parsed = JSON.parse(jsonMatch[0]);
1604
+ // Check for "no improvement needed" signal
1605
+ if (parsed.area === null)
1606
+ return null;
1607
+ return parsed;
1608
+ }
1609
+ catch {
1610
+ logger.warn({ text: text.slice(0, 200) }, 'Failed to parse JSON response');
1611
+ return null;
1612
+ }
1613
+ }
1614
+ async withTimeout(promise, ms) {
1615
+ let timer;
1616
+ const timeout = new Promise((resolve) => {
1617
+ timer = setTimeout(() => resolve(null), ms);
1618
+ });
1619
+ try {
1620
+ const result = await Promise.race([promise, timeout]);
1621
+ return result;
1622
+ }
1623
+ finally {
1624
+ clearTimeout(timer);
1625
+ }
1626
+ }
1627
+ }
1628
+ // ── Utility ──────────────────────────────────────────────────────────
1629
+ /** Validate that a proposed change has valid syntax for its target area. */
1630
+ /** Files that must never be modified by self-improvement (catastrophic blast radius or self-referential). */
1631
+ const SOURCE_BLOCKLIST = new Set([
1632
+ 'config.ts',
1633
+ 'types.ts',
1634
+ 'gateway/router.ts',
1635
+ 'gateway/lanes.ts',
1636
+ 'gateway/heartbeat-scheduler.ts',
1637
+ 'gateway/cron-scheduler.ts',
1638
+ 'gateway/security-scanner.ts',
1639
+ 'agent/self-improve.ts',
1640
+ 'agent/safe-restart.ts',
1641
+ 'agent/source-mods.ts',
1642
+ 'cli/index.ts',
1643
+ 'cli/dashboard.ts',
1644
+ 'security/scanner.ts',
1645
+ ]);
1646
+ export function validateProposal(area, target, proposedChange) {
1647
+ if (!proposedChange.trim()) {
1648
+ return { valid: false, error: 'Proposed change is empty' };
1649
+ }
1650
+ if (['soul', 'cron', 'workflow', 'agent', 'communication'].includes(area)) {
1651
+ try {
1652
+ matter(proposedChange);
1653
+ }
1654
+ catch (err) {
1655
+ return { valid: false, error: `YAML frontmatter parse error: ${err instanceof Error ? err.message : String(err)}` };
1656
+ }
1657
+ }
1658
+ if (area === 'goal') {
1659
+ try {
1660
+ const parsed = JSON.parse(proposedChange);
1661
+ if (!parsed.title)
1662
+ return { valid: false, error: 'Goal proposal missing required "title" field' };
1663
+ }
1664
+ catch (err) {
1665
+ return { valid: false, error: `Goal proposal must be valid JSON: ${err instanceof Error ? err.message : String(err)}` };
1666
+ }
1667
+ }
1668
+ if (area === 'cron') {
1669
+ try {
1670
+ const parsed = matter(proposedChange);
1671
+ if (parsed.data?.jobs && !Array.isArray(parsed.data.jobs)) {
1672
+ return { valid: false, error: 'CRON.md jobs field must be an array' };
1673
+ }
1674
+ if (Array.isArray(parsed.data?.jobs)) {
1675
+ for (const job of parsed.data.jobs) {
1676
+ if (!job.name || !job.schedule || !job.prompt) {
1677
+ return { valid: false, error: `Cron job missing required fields (name/schedule/prompt): ${JSON.stringify(job).slice(0, 100)}` };
1678
+ }
1679
+ }
1680
+ }
1681
+ }
1682
+ catch (err) {
1683
+ return { valid: false, error: `CRON.md validation failed: ${err}` };
1684
+ }
1685
+ }
1686
+ if (area === 'source') {
1687
+ // Check blocklist
1688
+ if (SOURCE_BLOCKLIST.has(target)) {
1689
+ return { valid: false, error: `Source file '${target}' is in the blocklist and cannot be modified by self-improvement` };
1690
+ }
1691
+ // Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller)
1692
+ // Check basic TypeScript structure: must contain at least one import or export
1693
+ if (!proposedChange.includes('import ') && !proposedChange.includes('export ')) {
1694
+ return { valid: false, error: 'Source proposal missing import/export statements — likely not valid TypeScript' };
1695
+ }
1696
+ }
1697
+ return { valid: true };
1698
+ }
1699
+ function ensureDirs() {
1700
+ for (const dir of [SELF_IMPROVE_DIR, PENDING_DIR]) {
1701
+ if (!existsSync(dir)) {
1702
+ mkdirSync(dir, { recursive: true });
1703
+ }
1704
+ }
1705
+ }
1706
+ //# sourceMappingURL=self-improve.js.map