clementine-agent 1.0.76 → 1.0.78

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.
@@ -9,11 +9,14 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync,
9
9
  import path from 'node:path';
10
10
  import matter from 'gray-matter';
11
11
  import pino from 'pino';
12
- import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
13
- import { listAllGoals } from '../tools/shared.js';
12
+ import { HEARTBEAT_FILE, TASKS_FILE, INBOX_DIR, DAILY_NOTES_DIR, HEARTBEAT_INTERVAL_MINUTES, HEARTBEAT_ACTIVE_START, HEARTBEAT_ACTIVE_END, BASE_DIR, GOALS_DIR, AGENTS_DIR, HEARTBEAT_WORK_QUEUE_FILE, DISCORD_OWNER_ID, } from '../config.js';
13
+ import { findGoalPath, listAllGoals } from '../tools/shared.js';
14
14
  import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
15
+ import { decideDailyPlanPriority, decideDiscoveredWorkItem, decideGoalAdvancement, decisionShouldCreateGoalTrigger, decisionShouldQueueHeartbeatWork, } from '../agent/proactive-engine.js';
16
+ import { recentDecisions, recordDecision, recordDecisionOutcome, wasRecentlyDecided, } from '../agent/proactive-ledger.js';
15
17
  import { CronRunLog, logToDailyNote, todayISO } from './cron-scheduler.js';
16
18
  const logger = pino({ name: 'clementine.heartbeat' });
19
+ const PROACTIVE_DECISION_DEDUPE_MS = 24 * 60 * 60 * 1000;
17
20
  // ── HeartbeatScheduler ────────────────────────────────────────────────
18
21
  export class HeartbeatScheduler {
19
22
  stateFile;
@@ -307,6 +310,14 @@ export class HeartbeatScheduler {
307
310
  // ── Active hours check ────────────────────────────────────────────
308
311
  // Check active hours
309
312
  if (hour < HEARTBEAT_ACTIVE_START || hour >= HEARTBEAT_ACTIVE_END) {
313
+ // Critical proactive alerts are allowed outside active hours; normal
314
+ // heartbeat narration still stays quiet.
315
+ try {
316
+ await this.runInsightCheck();
317
+ }
318
+ catch (err) {
319
+ logger.debug({ err }, 'Outside-hours insight check failed (non-fatal)');
320
+ }
310
321
  logger.debug(`Heartbeat skipped: outside active hours (${hour}:00)`);
311
322
  return;
312
323
  }
@@ -319,26 +330,61 @@ export class HeartbeatScheduler {
319
330
  logger.info('First active-hours tick — generating daily plan');
320
331
  const plan = await dailyPlanner.plan();
321
332
  if (plan.priorities.length > 0) {
322
- const highUrgency = plan.priorities.filter(p => p.urgency >= 4);
323
- if (highUrgency.length > 0) {
324
- const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
325
- mkdirSync(goalTriggerDir, { recursive: true });
326
- for (const item of highUrgency) {
327
- if (item.type === 'goal') {
328
- const triggerPath = path.join(goalTriggerDir, `${item.id}.trigger.json`);
329
- if (!existsSync(triggerPath)) {
330
- writeFileSync(triggerPath, JSON.stringify({
331
- goalId: item.id,
332
- focus: item.action,
333
- maxTurns: 30,
334
- triggeredAt: new Date().toISOString(),
335
- source: 'daily-plan',
336
- }, null, 2));
337
- }
333
+ const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
334
+ mkdirSync(goalTriggerDir, { recursive: true });
335
+ let acted = 0;
336
+ let queued = 0;
337
+ let askUser = 0;
338
+ for (const item of plan.priorities) {
339
+ const decision = decideDailyPlanPriority({ priority: item, date: plan.date });
340
+ if (decision.action === 'ask_user')
341
+ askUser++;
342
+ if (item.type === 'goal' && decisionShouldCreateGoalTrigger(decision)) {
343
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
344
+ continue;
345
+ const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
346
+ if (!existsSync(triggerPath)) {
347
+ writeFileSync(triggerPath, JSON.stringify({
348
+ goalId: item.id,
349
+ focus: item.action,
350
+ maxTurns: 30,
351
+ triggeredAt: new Date().toISOString(),
352
+ source: 'daily-plan',
353
+ decision,
354
+ }, null, 2));
355
+ recordDecision(decision, {
356
+ signalType: 'daily-plan-priority',
357
+ description: item.action,
358
+ goalId: item.id,
359
+ metadata: { planDate: plan.date, type: item.type },
360
+ });
361
+ acted++;
338
362
  }
339
363
  }
364
+ else if (item.type === 'goal' && decisionShouldQueueHeartbeatWork(decision)) {
365
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
366
+ continue;
367
+ HeartbeatScheduler.enqueueWork({
368
+ description: item.action,
369
+ prompt: `Goal progress: ${item.action}\n\nThis is a medium-priority item from today's daily plan (goal: ${item.id}). ` +
370
+ `Use goal_work to make progress. If you need information from the owner to proceed, ` +
371
+ `note the blocker and move on.`,
372
+ source: `daily-plan:${item.id}`,
373
+ idempotencyKey: decision.idempotencyKey,
374
+ priority: 'normal',
375
+ maxTurns: 10,
376
+ tier: 1,
377
+ });
378
+ recordDecision(decision, {
379
+ signalType: 'daily-plan-priority',
380
+ description: item.action,
381
+ goalId: item.id,
382
+ metadata: { planDate: plan.date, type: item.type },
383
+ });
384
+ queued++;
385
+ }
340
386
  }
341
- logger.info({ priorities: plan.priorities.length, urgent: plan.priorities.filter(p => p.urgency >= 4).length }, 'Daily plan generated');
387
+ logger.info({ priorities: plan.priorities.length, acted, queued, askUser }, 'Daily plan generated and evaluated');
342
388
  }
343
389
  // Apply non-destructive cron changes suggested by the daily planner
344
390
  if (plan.suggestedCronChanges?.length > 0 && this.cronScheduler) {
@@ -390,29 +436,60 @@ export class HeartbeatScheduler {
390
436
  changesSummary += `\n\n## Today's Plan\n${todayPlan.summary}\nTop priorities: ${todayPlan.priorities.slice(0, 3).map(p => p.action).join('; ')}`;
391
437
  // ── Goal-driven work auto-queuing ─────────────────────────
392
438
  // Close the loop: daily planner priorities → work queue items
393
- // Only queue high-urgency goal items that are autonomously actionable
394
- const currentQueue = HeartbeatScheduler.loadWorkQueue();
395
- const pendingDescriptions = new Set(currentQueue.filter(i => i.status === 'pending' || i.status === 'running').map(i => i.description));
439
+ // The proactive ledger (wasRecentlyDecided) plus enqueueWork's
440
+ // in-flight dedup cover what the old description-based set was for.
396
441
  for (const priority of todayPlan.priorities) {
397
- // Only auto-queue high-urgency goal items (urgency >= 7)
398
- if (priority.type !== 'goal' || priority.urgency < 7)
442
+ const decision = decideDailyPlanPriority({ priority, date: todayPlan.date });
443
+ if (priority.type !== 'goal' || !decisionShouldQueueHeartbeatWork(decision))
399
444
  continue;
400
- // Skip if already queued
401
- if (pendingDescriptions.has(priority.action))
445
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
402
446
  continue;
403
- // Skip if the action requires human input (heuristic: contains question marks or decision words)
404
- if (/\?|decide|choose|approve|confirm|review with|ask\s/i.test(priority.action))
447
+ // If the goal belongs to a specialist agent, route to them via a
448
+ // goal-trigger file instead of running the work as Clementine.
449
+ // processGoalTriggers in cron-scheduler reads goal.owner and
450
+ // dispatches with the right profile + Discord channel.
451
+ const goalLookup = findGoalPath(priority.id);
452
+ const ownerSlug = goalLookup && goalLookup.owner !== 'clementine' ? goalLookup.owner : null;
453
+ if (ownerSlug) {
454
+ const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
455
+ mkdirSync(goalTriggerDir, { recursive: true });
456
+ const trigger = {
457
+ goalId: priority.id,
458
+ focus: priority.action,
459
+ maxTurns: 15,
460
+ triggeredAt: new Date().toISOString(),
461
+ source: 'daily-plan',
462
+ decision,
463
+ };
464
+ const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
465
+ writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
466
+ recordDecision(decision, {
467
+ signalType: 'daily-plan-priority',
468
+ description: priority.action,
469
+ goalId: priority.id,
470
+ owner: ownerSlug,
471
+ metadata: { planDate: todayPlan.date, type: priority.type, routedTo: ownerSlug },
472
+ });
473
+ logger.info({ goalId: priority.id, owner: ownerSlug, action: priority.action }, 'Routed daily-plan goal to owning agent');
405
474
  continue;
475
+ }
406
476
  HeartbeatScheduler.enqueueWork({
407
477
  description: priority.action,
408
478
  prompt: `Goal progress: ${priority.action}\n\nThis is a high-priority item from today's daily plan (goal: ${priority.id}). ` +
409
479
  `Use goal_work to make progress. If you need information from the owner to proceed, ` +
410
480
  `note the blocker and move on.`,
411
481
  source: `daily-plan:${priority.id}`,
482
+ idempotencyKey: decision.idempotencyKey,
412
483
  priority: 'high',
413
484
  maxTurns: 10,
414
485
  tier: 1,
415
486
  });
487
+ recordDecision(decision, {
488
+ signalType: 'daily-plan-priority',
489
+ description: priority.action,
490
+ goalId: priority.id,
491
+ metadata: { planDate: todayPlan.date, type: priority.type },
492
+ });
416
493
  logger.info({ goalId: priority.id, action: priority.action }, 'Auto-queued goal work from daily plan');
417
494
  }
418
495
  }
@@ -432,10 +509,12 @@ export class HeartbeatScheduler {
432
509
  this.completeItem(item.id, result || 'completed');
433
510
  completedWork.push({ description: item.description, result: result || 'completed' });
434
511
  logToDailyNote(`**Heartbeat work: ${item.description}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
512
+ this.recordWorkItemOutcome(item, 'advanced', result || 'completed');
435
513
  }
436
514
  catch (err) {
437
515
  this.failItem(item.id, String(err));
438
516
  logger.warn({ err, id: item.id }, 'Heartbeat work item failed');
517
+ this.recordWorkItemOutcome(item, 'failed', String(err));
439
518
  }
440
519
  }
441
520
  // ── Decide whether to invoke the LLM ──
@@ -907,7 +986,8 @@ export class HeartbeatScheduler {
907
986
  const activeGoals = allGoals.filter((g) => g && g.status === 'active');
908
987
  if (activeGoals.length === 0)
909
988
  return null;
910
- const now = Date.now();
989
+ const nowDate = new Date();
990
+ const now = nowDate.getTime();
911
991
  const DAY_MS = 86_400_000;
912
992
  const lines = activeGoals.map((g) => {
913
993
  const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
@@ -998,8 +1078,8 @@ export class HeartbeatScheduler {
998
1078
  const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
999
1079
  if (allGoals.length === 0)
1000
1080
  return;
1001
- const now = Date.now();
1002
- const DAY_MS = 86_400_000;
1081
+ const nowDate = new Date();
1082
+ const now = nowDate.getTime();
1003
1083
  // Load recent goal outcomes for disposition-based throttling.
1004
1084
  // The agent classifies each outcome (ADVANCED, BLOCKED_ON_USER, etc.)
1005
1085
  // and we use that to decide when/whether to retry.
@@ -1059,47 +1139,20 @@ export class HeartbeatScheduler {
1059
1139
  }
1060
1140
  }
1061
1141
  catch { /* non-fatal */ }
1062
- // Score ALL active goals stale goals get urgency bonus, but
1063
- // non-stale high-priority goals with pending work also qualify.
1142
+ // Score ALL active goals through the proactive decision engine. Stale
1143
+ // goals get urgency, but current high-priority goals with pending work
1144
+ // also qualify.
1064
1145
  const scoredGoals = [];
1065
1146
  for (const goal of allGoals) {
1066
1147
  try {
1067
- if (goal.status !== 'active')
1068
- continue;
1069
- // Skip goals in cooldown (failed recently or producing stale output)
1070
- if (goalCooldowns.has(goal.id))
1071
- continue;
1072
- const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
1073
- const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
1074
- const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
1075
- const isStale = daysSinceUpdate > staleThreshold;
1076
- const hasWork = (goal.nextActions?.length ?? 0) > 0;
1077
- // Skip non-stale goals that have no pending work
1078
- if (!isStale && !hasWork)
1079
- continue;
1080
- // Scoring: priority (high=15, medium=8, low=2) + staleness bonus + work availability
1081
- const priorityScore = goal.priority === 'high' ? 15 : goal.priority === 'medium' ? 8 : 2;
1082
- const stalenessScore = isStale ? Math.min(daysSinceUpdate - staleThreshold, 20) : 0;
1083
- const workScore = hasWork ? 5 : 0;
1084
- // Goals approaching target date get a deadline urgency boost
1085
- let deadlineScore = 0;
1086
- if (goal.targetDate) {
1087
- const daysUntilTarget = Math.floor((new Date(goal.targetDate).getTime() - now) / DAY_MS);
1088
- if (daysUntilTarget <= 0)
1089
- deadlineScore = 20; // overdue
1090
- else if (daysUntilTarget <= 3)
1091
- deadlineScore = 10; // imminent
1092
- else if (daysUntilTarget <= 7)
1093
- deadlineScore = 5; // approaching
1148
+ const advancement = decideGoalAdvancement({
1149
+ goal,
1150
+ now: nowDate,
1151
+ inCooldown: goalCooldowns.has(goal.id),
1152
+ });
1153
+ if (advancement && decisionShouldCreateGoalTrigger(advancement.decision)) {
1154
+ scoredGoals.push(advancement);
1094
1155
  }
1095
- const totalScore = priorityScore + stalenessScore + workScore + deadlineScore;
1096
- const reason = [
1097
- isStale ? `stale(${daysSinceUpdate}d)` : 'current',
1098
- `pri=${goal.priority}`,
1099
- hasWork ? 'has-work' : 'no-work',
1100
- deadlineScore > 0 ? `deadline-boost(${deadlineScore})` : '',
1101
- ].filter(Boolean).join(', ');
1102
- scoredGoals.push({ goal, score: totalScore, reason });
1103
1156
  }
1104
1157
  catch {
1105
1158
  continue;
@@ -1110,10 +1163,9 @@ export class HeartbeatScheduler {
1110
1163
  // Sort by score descending, take top 2
1111
1164
  scoredGoals.sort((a, b) => b.score - a.score);
1112
1165
  const toAdvance = scoredGoals.slice(0, 2);
1113
- for (const { goal, reason } of toAdvance) {
1114
- const focus = goal.nextActions?.length > 0
1115
- ? goal.nextActions[0]
1116
- : `Review and update progress on "${goal.title}"`;
1166
+ for (const { goal, reason, focus, decision, score } of toAdvance) {
1167
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
1168
+ continue;
1117
1169
  const trigger = {
1118
1170
  goalId: goal.id,
1119
1171
  focus,
@@ -1121,10 +1173,18 @@ export class HeartbeatScheduler {
1121
1173
  triggeredAt: new Date().toISOString(),
1122
1174
  source: 'heartbeat-advance',
1123
1175
  reason,
1176
+ decision,
1124
1177
  };
1125
- const triggerPath = path.join(goalTriggerDir, `${goal.id}.trigger.json`);
1178
+ const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
1126
1179
  writeFileSync(triggerPath, JSON.stringify(trigger, null, 2));
1127
- logger.info({ goalId: goal.id, title: goal.title, score: toAdvance[0].score, reason, focus }, 'Advancing goal via trigger');
1180
+ recordDecision(decision, {
1181
+ signalType: 'goal-advancement',
1182
+ description: focus,
1183
+ goalId: goal.id,
1184
+ owner: goal.owner,
1185
+ metadata: { score, reason, title: goal.title },
1186
+ });
1187
+ logger.info({ goalId: goal.id, title: goal.title, score, reason, focus, decision: decision.action }, 'Advancing goal via trigger');
1128
1188
  }
1129
1189
  // Note: task generation removed — the main goal trigger already includes
1130
1190
  // nextActions[0] as focus, so a separate task-gen trigger was redundant
@@ -1158,6 +1218,38 @@ export class HeartbeatScheduler {
1158
1218
  try {
1159
1219
  const content = readFileSync(filePath, 'utf-8');
1160
1220
  const title = file.replace(/\.md$/, '');
1221
+ const decision = decideDiscoveredWorkItem({
1222
+ type: 'inbox',
1223
+ id: title,
1224
+ description: `Triage inbox item: ${title}`,
1225
+ urgency: 2,
1226
+ source: 'inbox',
1227
+ });
1228
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
1229
+ continue;
1230
+ if (decision.action !== 'act_now') {
1231
+ HeartbeatScheduler.enqueueWork({
1232
+ description: `Triage inbox item: ${title}`,
1233
+ prompt: `Triage this inbox item later: ${title}`,
1234
+ source: `inbox:${title}`,
1235
+ idempotencyKey: decision.idempotencyKey,
1236
+ priority: 'normal',
1237
+ maxTurns: 5,
1238
+ tier: 1,
1239
+ });
1240
+ recordDecision(decision, {
1241
+ signalType: 'inbox-triage',
1242
+ description: `Triage inbox item: ${title}`,
1243
+ metadata: { file },
1244
+ });
1245
+ continue;
1246
+ }
1247
+ const decisionContext = {
1248
+ signalType: 'inbox-triage',
1249
+ description: `Triage inbox item: ${title}`,
1250
+ metadata: { file },
1251
+ };
1252
+ const decisionRecord = recordDecision(decision, decisionContext);
1161
1253
  // Move file before processing to prevent duplicate triage on next tick
1162
1254
  const destPath = path.join(processedDir, file);
1163
1255
  try {
@@ -1168,18 +1260,45 @@ export class HeartbeatScheduler {
1168
1260
  // If move fails, skip — will retry next tick
1169
1261
  continue;
1170
1262
  }
1263
+ // Load active team so Clementine can delegate when an item belongs
1264
+ // to a specialist. Read agent.md frontmatter for slug/name/scope.
1265
+ const teamLines = [];
1266
+ try {
1267
+ if (existsSync(AGENTS_DIR)) {
1268
+ const agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
1269
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_'))
1270
+ .map((d) => d.name);
1271
+ for (const slug of agentDirs) {
1272
+ const agentMd = path.join(AGENTS_DIR, slug, 'agent.md');
1273
+ if (!existsSync(agentMd))
1274
+ continue;
1275
+ try {
1276
+ const fm = matter(readFileSync(agentMd, 'utf-8')).data;
1277
+ const desc = (fm.description ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
1278
+ teamLines.push(`- \`${slug}\` (${fm.name ?? slug})${desc ? ` — ${desc}` : ''}`);
1279
+ }
1280
+ catch { /* skip malformed agent.md */ }
1281
+ }
1282
+ }
1283
+ }
1284
+ catch { /* non-fatal */ }
1285
+ const teamBlock = teamLines.length > 0
1286
+ ? `## Your Team (delegate when work clearly belongs to one of them)\n${teamLines.join('\n')}\n\n`
1287
+ : '';
1171
1288
  // Build a prompt for the agent to triage this inbox item
1172
1289
  const prompt = `Triage this inbox item and take appropriate action.\n\n` +
1173
1290
  `**Title:** ${title}\n` +
1174
1291
  `**Content:**\n${content.slice(0, 2000)}\n\n` +
1292
+ teamBlock +
1175
1293
  `## Instructions:\n` +
1176
- `1. Determine the intent: Is this a task, a reference/note, a reminder, or something else?\n` +
1294
+ `1. Determine the intent: Is this a task, a reference/note, a reminder, project update, or work for a teammate?\n` +
1177
1295
  `2. Take the appropriate action:\n` +
1178
1296
  ` - **Task**: Use \`task_add\` to create a task with the right priority and due date.\n` +
1179
1297
  ` - **Reference**: Use \`note_create\` or \`memory_write\` to file it in the vault.\n` +
1180
1298
  ` - **Reminder**: Add to today's daily note with \`memory_write(action="append_daily")\`.\n` +
1181
1299
  ` - **Project update**: Update the relevant project note.\n` +
1182
- `3. Respond with a one-line summary of what you did.`;
1300
+ ` - **Delegate to a teammate**: If the item is clearly work for a specialist on your team, use \`team_message\` to hand it off with enough context for them to act. Don't try to do their job yourself.\n` +
1301
+ `3. Respond with a one-line summary of what you did (including who you delegated to, if anyone).`;
1183
1302
  // Fire-and-forget — run as a lightweight cron job
1184
1303
  this.gateway
1185
1304
  .handleCronJob(`inbox:${title}`, prompt, 1, 5)
@@ -1188,6 +1307,15 @@ export class HeartbeatScheduler {
1188
1307
  logToDailyNote(`**Inbox processed: ${title}** — ${result.slice(0, 100).replace(/\n/g, ' ')}`);
1189
1308
  }
1190
1309
  logger.info({ file: title }, 'Inbox item processed');
1310
+ try {
1311
+ recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
1312
+ status: 'advanced',
1313
+ summary: (result ?? 'processed').slice(0, 200),
1314
+ });
1315
+ }
1316
+ catch (err) {
1317
+ logger.debug({ err, file: title }, 'Failed to record inbox outcome (non-fatal)');
1318
+ }
1191
1319
  })
1192
1320
  .catch((err) => {
1193
1321
  // Restore file to inbox on failure so it retries
@@ -1197,6 +1325,15 @@ export class HeartbeatScheduler {
1197
1325
  }
1198
1326
  catch { /* best-effort restore */ }
1199
1327
  logger.warn({ err, file: title }, 'Failed to process inbox item');
1328
+ try {
1329
+ recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
1330
+ status: 'failed',
1331
+ summary: String(err).slice(0, 200),
1332
+ });
1333
+ }
1334
+ catch (recordErr) {
1335
+ logger.debug({ err: recordErr, file: title }, 'Failed to record inbox outcome (non-fatal)');
1336
+ }
1200
1337
  });
1201
1338
  }
1202
1339
  catch (err) {
@@ -1349,14 +1486,40 @@ export class HeartbeatScheduler {
1349
1486
  HeartbeatScheduler.saveWorkQueue(queue);
1350
1487
  }
1351
1488
  }
1489
+ recordWorkItemOutcome(item, status, summary) {
1490
+ const key = item.idempotencyKey;
1491
+ if (!key)
1492
+ return;
1493
+ try {
1494
+ const original = recentDecisions({ idempotencyKey: key }, undefined)[0];
1495
+ if (!original)
1496
+ return;
1497
+ recordDecisionOutcome(original.id, original.decision, original.context, {
1498
+ status,
1499
+ summary: summary.slice(0, 200),
1500
+ });
1501
+ }
1502
+ catch (err) {
1503
+ logger.debug({ err, id: item.id }, 'Failed to record work-item outcome (non-fatal)');
1504
+ }
1505
+ }
1352
1506
  static enqueueWork(opts) {
1353
1507
  const queue = HeartbeatScheduler.loadWorkQueue();
1508
+ // Only dedup against in-flight items here. Cross-tick "we already acted on this
1509
+ // signal" lives in the proactive ledger (wasRecentlyDecided); blocking on
1510
+ // 'completed' here would prevent legitimate re-runs of multi-session work.
1511
+ const dedupKey = opts.idempotencyKey ?? opts.source;
1512
+ const existing = queue.find((item) => (item.idempotencyKey ?? item.source) === dedupKey &&
1513
+ (item.status === 'pending' || item.status === 'running'));
1514
+ if (existing)
1515
+ return existing.id;
1354
1516
  const id = randomBytes(4).toString('hex');
1355
1517
  const item = {
1356
1518
  id,
1357
1519
  description: opts.description,
1358
1520
  prompt: opts.prompt,
1359
1521
  source: opts.source,
1522
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
1360
1523
  priority: opts.priority ?? 'normal',
1361
1524
  queuedAt: new Date().toISOString(),
1362
1525
  maxTurns: opts.maxTurns ?? 3,
package/dist/index.js CHANGED
@@ -453,28 +453,36 @@ function startTimerChecker(dispatcher, gateway) {
453
453
  // ── Log rotation ─────────────────────────────────────────────────────
454
454
  const LOG_MAX_BYTES = 2 * 1024 * 1024; // 2 MB
455
455
  const LOG_MAX_BACKUPS = 7;
456
+ function rotateOne(logFile) {
457
+ if (!existsSync(logFile))
458
+ return;
459
+ const size = statSync(logFile).size;
460
+ if (size < LOG_MAX_BYTES)
461
+ return;
462
+ // Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
463
+ const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
464
+ if (existsSync(oldest))
465
+ unlinkSync(oldest);
466
+ for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
467
+ const src = `${logFile}.${i}`;
468
+ if (existsSync(src))
469
+ renameSync(src, `${logFile}.${i + 1}`);
470
+ }
471
+ renameSync(logFile, `${logFile}.1`);
472
+ writeFileSync(logFile, '');
473
+ }
456
474
  function rotateLogIfNeeded() {
457
- const logFile = path.join(config.BASE_DIR, 'logs', 'clementine.log');
458
- try {
459
- if (!existsSync(logFile))
460
- return;
461
- const size = statSync(logFile).size;
462
- if (size < LOG_MAX_BYTES)
463
- return;
464
- // Rotate: delete .log.7, shift .log.6→.log.7, ... .log→.log.1
465
- const oldest = `${logFile}.${LOG_MAX_BACKUPS}`;
466
- if (existsSync(oldest))
467
- unlinkSync(oldest);
468
- for (let i = LOG_MAX_BACKUPS - 1; i >= 1; i--) {
469
- const src = `${logFile}.${i}`;
470
- if (existsSync(src))
471
- renameSync(src, `${logFile}.${i + 1}`);
475
+ // cron.log is appended to by launchd-spawned `clementine cron run` invocations
476
+ // — each is a one-shot process that closes the FD after writing, so a
477
+ // rename-rotate at daemon startup is safe.
478
+ const logsDir = path.join(config.BASE_DIR, 'logs');
479
+ for (const name of ['clementine.log', 'cron.log']) {
480
+ try {
481
+ rotateOne(path.join(logsDir, name));
482
+ }
483
+ catch (err) {
484
+ logger.warn({ err, name }, 'Log rotation failed — continuing startup');
472
485
  }
473
- renameSync(logFile, `${logFile}.1`);
474
- writeFileSync(logFile, '');
475
- }
476
- catch (err) {
477
- logger.warn({ err }, 'Log rotation failed — continuing startup');
478
486
  }
479
487
  }
480
488
  // ── Async main ───────────────────────────────────────────────────────
@@ -652,6 +660,10 @@ async function asyncMain() {
652
660
  const heartbeat = new HeartbeatScheduler(gateway, dispatcher);
653
661
  const cronScheduler = new CronScheduler(gateway, dispatcher);
654
662
  heartbeat.setCronScheduler(cronScheduler);
663
+ // Per-agent heartbeats (Ross / Sasha / Nora / future hires). Cheap-path
664
+ // observation only in P2 — LLM ticks land in P3.
665
+ const { AgentHeartbeatManager } = await import('./gateway/agent-heartbeat-manager.js');
666
+ const agentHeartbeats = new AgentHeartbeatManager(gateway.getAgentManager());
655
667
  // ── Build channel tasks ──────────────────────────────────────────
656
668
  const channelTasks = [];
657
669
  const activeChannels = [];
@@ -748,6 +760,7 @@ async function asyncMain() {
748
760
  // Start heartbeat + cron + timers
749
761
  heartbeat.start();
750
762
  cronScheduler.start();
763
+ agentHeartbeats.start();
751
764
  const timerInterval = startTimerChecker(dispatcher, gateway);
752
765
  // Start brain ingest scheduler (polls registered REST sources on their cron)
753
766
  try {
@@ -938,6 +951,7 @@ async function asyncMain() {
938
951
  // Now safe to tear down remaining infrastructure
939
952
  heartbeat.stop();
940
953
  cronScheduler.stop();
954
+ agentHeartbeats.stop();
941
955
  // ── Self-restart (enhanced with health check + rollback) ────────
942
956
  if (restartRequested) {
943
957
  // Clear our PID file BEFORE spawning the child, so ensureSingleton()