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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Owns the lifecycle of all per-agent heartbeat schedulers.
3
+ *
4
+ * Boots at daemon start, scans the AgentManager's active list, spawns one
5
+ * AgentHeartbeatScheduler per agent. An outer 60s interval iterates the
6
+ * registry and fires `tick()` on any agent whose nextCheckAt is due.
7
+ *
8
+ * Reconciliation runs each outer tick: agents added to AGENTS_DIR start
9
+ * heartbeats automatically; agents removed (or paused/terminated) drop
10
+ * out. Per-agent failures are caught so one buggy agent can't crash the
11
+ * daemon or stall others.
12
+ */
13
+ import pino from 'pino';
14
+ import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
15
+ const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
16
+ const OUTER_TICK_MS = 60_000;
17
+ export class AgentHeartbeatManager {
18
+ agentManager;
19
+ schedulers = new Map();
20
+ timer = null;
21
+ running = false;
22
+ ticking = false;
23
+ constructor(agentManager) {
24
+ this.agentManager = agentManager;
25
+ }
26
+ start() {
27
+ if (this.running)
28
+ return;
29
+ this.running = true;
30
+ this.reconcile();
31
+ // Run an immediate tick so schedulers boot up without a 60s delay.
32
+ this.outerTick().catch((err) => logger.error({ err }, 'Initial agent heartbeat tick failed'));
33
+ this.timer = setInterval(() => {
34
+ this.outerTick().catch((err) => logger.error({ err }, 'Agent heartbeat outer tick failed'));
35
+ }, OUTER_TICK_MS);
36
+ logger.info({ agents: this.schedulers.size }, 'Agent heartbeat manager started');
37
+ }
38
+ stop() {
39
+ if (!this.running)
40
+ return;
41
+ this.running = false;
42
+ if (this.timer) {
43
+ clearInterval(this.timer);
44
+ this.timer = null;
45
+ }
46
+ this.schedulers.clear();
47
+ logger.info('Agent heartbeat manager stopped');
48
+ }
49
+ /** Add/remove schedulers to match the current AgentManager listing. */
50
+ reconcile() {
51
+ let active = [];
52
+ try {
53
+ active = this.agentManager
54
+ .listAll()
55
+ .filter((p) => p.slug !== 'clementine' && this.agentManager.isRunnable(p.slug))
56
+ .map((p) => p.slug);
57
+ }
58
+ catch (err) {
59
+ logger.warn({ err }, 'Failed to list agents during reconcile — keeping current set');
60
+ return;
61
+ }
62
+ const activeSet = new Set(active);
63
+ // Add new
64
+ for (const slug of active) {
65
+ if (!this.schedulers.has(slug)) {
66
+ this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager));
67
+ logger.info({ slug }, 'Agent heartbeat: registered scheduler');
68
+ }
69
+ }
70
+ // Remove gone-or-paused
71
+ for (const slug of [...this.schedulers.keys()]) {
72
+ if (!activeSet.has(slug)) {
73
+ this.schedulers.delete(slug);
74
+ logger.info({ slug }, 'Agent heartbeat: deregistered scheduler');
75
+ }
76
+ }
77
+ }
78
+ /**
79
+ * One outer-loop tick. Reconcile the registry, then fire agents whose
80
+ * nextCheckAt has come due. Runs serially to avoid races on shared
81
+ * state (goals dir, cron runs dir).
82
+ */
83
+ async outerTick(now = new Date()) {
84
+ if (this.ticking)
85
+ return; // prior outer tick still in flight — skip
86
+ this.ticking = true;
87
+ try {
88
+ this.reconcile();
89
+ for (const [slug, scheduler] of this.schedulers) {
90
+ try {
91
+ if (!scheduler.isDue(now))
92
+ continue;
93
+ await scheduler.tick(now);
94
+ }
95
+ catch (err) {
96
+ logger.warn({ err, slug }, 'Agent heartbeat tick failed — continuing');
97
+ }
98
+ }
99
+ }
100
+ finally {
101
+ this.ticking = false;
102
+ }
103
+ }
104
+ /** Diagnostic helper for the dashboard / CLI. */
105
+ getStatus() {
106
+ const out = [];
107
+ for (const [slug, scheduler] of this.schedulers) {
108
+ const state = scheduler.loadState();
109
+ out.push({
110
+ slug,
111
+ nextCheckAt: state.nextCheckAt,
112
+ lastTickAt: state.lastTickAt,
113
+ silentTickCount: state.silentTickCount,
114
+ });
115
+ }
116
+ return out;
117
+ }
118
+ /** Look up a scheduler — useful for CLI commands like "tick this agent now." */
119
+ getScheduler(slug) {
120
+ return this.schedulers.get(slug) ?? null;
121
+ }
122
+ }
123
+ //# sourceMappingURL=agent-heartbeat-manager.js.map
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Per-agent heartbeat scheduler — one instance per specialist agent
3
+ * (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
4
+ * own HeartbeatScheduler.
5
+ *
6
+ * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
+ * three signals (pending delegated tasks, recent goal updates, recent
8
+ * cron completions), updates fingerprint, and persists state.
9
+ *
10
+ * Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
11
+ * agent's profile) when the fingerprint indicates a real signal change.
12
+ */
13
+ import type { AgentHeartbeatState } from '../types.js';
14
+ import type { AgentManager } from '../agent/agent-manager.js';
15
+ export interface AgentHeartbeatOptions {
16
+ /** Override the base directory for test isolation. Defaults to config.BASE_DIR. */
17
+ baseDir?: string;
18
+ /** Override the agents directory for test isolation. Defaults to config.AGENTS_DIR. */
19
+ agentsDir?: string;
20
+ }
21
+ export declare class AgentHeartbeatScheduler {
22
+ private readonly slug;
23
+ private readonly agentManager;
24
+ private readonly baseDir;
25
+ private readonly agentsDir;
26
+ private readonly stateFile;
27
+ constructor(slug: string, agentManager: AgentManager, opts?: AgentHeartbeatOptions);
28
+ /** Read persisted state, or return a fresh state ready to tick now. */
29
+ loadState(): AgentHeartbeatState;
30
+ saveState(state: AgentHeartbeatState): void;
31
+ /** True if the agent is due for a tick. */
32
+ isDue(now?: Date): boolean;
33
+ /**
34
+ * Compute a cheap fingerprint of "anything material to this agent."
35
+ * Three signals: pending delegated tasks, latest goal update, latest
36
+ * cron run timestamp. Sync filesystem reads — bounded and small.
37
+ */
38
+ private buildFingerprint;
39
+ /**
40
+ * Cheap-path tick. Returns the new state. P3 will branch into an LLM
41
+ * call when the fingerprint changed; for now we just observe and log.
42
+ */
43
+ tick(now?: Date): Promise<AgentHeartbeatState>;
44
+ /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
45
+ setNextCheckIn(minutes: number, now?: Date): void;
46
+ getSlug(): string;
47
+ }
48
+ //# sourceMappingURL=agent-heartbeat-scheduler.d.ts.map
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Per-agent heartbeat scheduler — one instance per specialist agent
3
+ * (Ross, Sasha, Nora, etc.). Runs autonomously alongside Clementine's
4
+ * own HeartbeatScheduler.
5
+ *
6
+ * Phase 2 — cheap path only. No LLM call. The tick loads state, scans
7
+ * three signals (pending delegated tasks, recent goal updates, recent
8
+ * cron completions), updates fingerprint, and persists state.
9
+ *
10
+ * Phase 3 will add the LLM-path tick (assistant.heartbeat() with the
11
+ * agent's profile) when the fingerprint indicates a real signal change.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from 'node:fs';
15
+ import path from 'node:path';
16
+ import pino from 'pino';
17
+ import { AGENTS_DIR, BASE_DIR } from '../config.js';
18
+ import { listAllGoals } from '../tools/shared.js';
19
+ const logger = pino({ name: 'clementine.agent-heartbeat' });
20
+ const DEFAULT_INTERVAL_MIN = 30;
21
+ const MIN_INTERVAL_MIN = 5;
22
+ const MAX_INTERVAL_MIN = 12 * 60;
23
+ export class AgentHeartbeatScheduler {
24
+ slug;
25
+ agentManager;
26
+ baseDir;
27
+ agentsDir;
28
+ stateFile;
29
+ constructor(slug, agentManager, opts = {}) {
30
+ this.slug = slug;
31
+ this.agentManager = agentManager;
32
+ this.baseDir = opts.baseDir ?? BASE_DIR;
33
+ this.agentsDir = opts.agentsDir ?? AGENTS_DIR;
34
+ this.stateFile = path.join(this.baseDir, 'heartbeat', 'agents', slug, 'state.json');
35
+ }
36
+ /** Read persisted state, or return a fresh state ready to tick now. */
37
+ loadState() {
38
+ try {
39
+ if (existsSync(this.stateFile)) {
40
+ const raw = JSON.parse(readFileSync(this.stateFile, 'utf-8'));
41
+ return {
42
+ slug: this.slug,
43
+ lastTickAt: String(raw.lastTickAt ?? ''),
44
+ nextCheckAt: String(raw.nextCheckAt ?? new Date().toISOString()),
45
+ silentTickCount: Number(raw.silentTickCount ?? 0),
46
+ fingerprint: String(raw.fingerprint ?? ''),
47
+ ...(raw.lastSignalSummary ? { lastSignalSummary: raw.lastSignalSummary } : {}),
48
+ };
49
+ }
50
+ }
51
+ catch (err) {
52
+ logger.warn({ err, slug: this.slug }, 'Failed to load agent heartbeat state — starting fresh');
53
+ }
54
+ return {
55
+ slug: this.slug,
56
+ lastTickAt: '',
57
+ nextCheckAt: new Date().toISOString(),
58
+ silentTickCount: 0,
59
+ fingerprint: '',
60
+ };
61
+ }
62
+ saveState(state) {
63
+ try {
64
+ mkdirSync(path.dirname(this.stateFile), { recursive: true });
65
+ writeFileSync(this.stateFile, JSON.stringify(state, null, 2));
66
+ }
67
+ catch (err) {
68
+ logger.warn({ err, slug: this.slug }, 'Failed to save agent heartbeat state — non-fatal');
69
+ }
70
+ }
71
+ /** True if the agent is due for a tick. */
72
+ isDue(now = new Date()) {
73
+ const state = this.loadState();
74
+ if (!state.nextCheckAt)
75
+ return true;
76
+ return new Date(state.nextCheckAt).getTime() <= now.getTime();
77
+ }
78
+ /**
79
+ * Compute a cheap fingerprint of "anything material to this agent."
80
+ * Three signals: pending delegated tasks, latest goal update, latest
81
+ * cron run timestamp. Sync filesystem reads — bounded and small.
82
+ */
83
+ buildFingerprint() {
84
+ const signals = { slug: this.slug };
85
+ // 1. Pending delegated task count
86
+ try {
87
+ const tasksDir = path.join(this.agentsDir, this.slug, 'tasks');
88
+ if (existsSync(tasksDir)) {
89
+ const files = readdirSync(tasksDir).filter((f) => f.endsWith('.json'));
90
+ let pendingCount = 0;
91
+ for (const file of files) {
92
+ try {
93
+ const task = JSON.parse(readFileSync(path.join(tasksDir, file), 'utf-8'));
94
+ if (task && task.status === 'pending')
95
+ pendingCount++;
96
+ }
97
+ catch { /* skip malformed */ }
98
+ }
99
+ signals.pendingTasks = pendingCount;
100
+ }
101
+ else {
102
+ signals.pendingTasks = 0;
103
+ }
104
+ }
105
+ catch {
106
+ signals.pendingTasks = 0;
107
+ }
108
+ // 2. Latest goal updatedAt for this agent's goals
109
+ try {
110
+ let latest = '';
111
+ for (const { goal, owner } of listAllGoals()) {
112
+ if (owner !== this.slug)
113
+ continue;
114
+ const updatedAt = goal.updatedAt ?? '';
115
+ if (updatedAt > latest)
116
+ latest = updatedAt;
117
+ }
118
+ signals.latestGoalUpdate = latest;
119
+ }
120
+ catch {
121
+ signals.latestGoalUpdate = '';
122
+ }
123
+ // 3. Latest cron run for any of this agent's crons (file mtime is enough)
124
+ try {
125
+ const runsDir = path.join(this.baseDir, 'cron', 'runs');
126
+ let latestMs = 0;
127
+ if (existsSync(runsDir)) {
128
+ const prefix = `${this.slug}:`;
129
+ for (const file of readdirSync(runsDir)) {
130
+ if (!file.endsWith('.jsonl'))
131
+ continue;
132
+ if (!file.startsWith(prefix) && !file.startsWith(this.slug + '_'))
133
+ continue;
134
+ try {
135
+ const mtime = statSync(path.join(runsDir, file)).mtimeMs;
136
+ if (mtime > latestMs)
137
+ latestMs = mtime;
138
+ }
139
+ catch { /* skip */ }
140
+ }
141
+ }
142
+ signals.latestCronRunMs = latestMs;
143
+ }
144
+ catch {
145
+ signals.latestCronRunMs = 0;
146
+ }
147
+ const fingerprint = createHash('sha1')
148
+ .update(JSON.stringify(signals))
149
+ .digest('hex')
150
+ .slice(0, 16);
151
+ return { fingerprint, signals };
152
+ }
153
+ /**
154
+ * Cheap-path tick. Returns the new state. P3 will branch into an LLM
155
+ * call when the fingerprint changed; for now we just observe and log.
156
+ */
157
+ async tick(now = new Date()) {
158
+ const profile = this.agentManager.get(this.slug);
159
+ if (!profile) {
160
+ // Agent was removed mid-flight — return a state that won't tick again soon.
161
+ return {
162
+ slug: this.slug,
163
+ lastTickAt: now.toISOString(),
164
+ nextCheckAt: new Date(now.getTime() + MAX_INTERVAL_MIN * 60_000).toISOString(),
165
+ silentTickCount: 0,
166
+ fingerprint: '',
167
+ lastSignalSummary: 'agent profile not found',
168
+ };
169
+ }
170
+ if (!this.agentManager.isRunnable(this.slug)) {
171
+ logger.debug({ slug: this.slug, status: profile.status }, 'Agent not runnable — skipping tick');
172
+ const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
173
+ const prior = this.loadState();
174
+ const state = {
175
+ ...prior,
176
+ slug: this.slug,
177
+ lastTickAt: now.toISOString(),
178
+ nextCheckAt: next.toISOString(),
179
+ };
180
+ this.saveState(state);
181
+ return state;
182
+ }
183
+ const prior = this.loadState();
184
+ const { fingerprint, signals } = this.buildFingerprint();
185
+ const changed = fingerprint !== prior.fingerprint;
186
+ const next = new Date(now.getTime() + DEFAULT_INTERVAL_MIN * 60_000);
187
+ const state = {
188
+ slug: this.slug,
189
+ lastTickAt: now.toISOString(),
190
+ nextCheckAt: next.toISOString(),
191
+ silentTickCount: changed ? 0 : prior.silentTickCount + 1,
192
+ fingerprint,
193
+ ...(changed
194
+ ? { lastSignalSummary: `signal change: ${JSON.stringify(signals)}`.slice(0, 240) }
195
+ : prior.lastSignalSummary
196
+ ? { lastSignalSummary: prior.lastSignalSummary }
197
+ : {}),
198
+ };
199
+ this.saveState(state);
200
+ if (changed) {
201
+ logger.info({ slug: this.slug, signals, fingerprint }, 'Agent heartbeat: signal change detected (LLM path is P3)');
202
+ }
203
+ else {
204
+ logger.debug({ slug: this.slug, silentTicks: state.silentTickCount }, 'Agent heartbeat: silent tick');
205
+ }
206
+ return state;
207
+ }
208
+ /** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
209
+ setNextCheckIn(minutes, now = new Date()) {
210
+ const clamped = Math.max(MIN_INTERVAL_MIN, Math.min(MAX_INTERVAL_MIN, Math.floor(minutes)));
211
+ const prior = this.loadState();
212
+ const state = {
213
+ ...prior,
214
+ slug: this.slug,
215
+ nextCheckAt: new Date(now.getTime() + clamped * 60_000).toISOString(),
216
+ };
217
+ this.saveState(state);
218
+ }
219
+ getSlug() {
220
+ return this.slug;
221
+ }
222
+ }
223
+ //# sourceMappingURL=agent-heartbeat-scheduler.js.map
@@ -159,6 +159,7 @@ export declare class CronScheduler {
159
159
  * Creates the causal link: "action X was attempted for goal Y, result was Z."
160
160
  */
161
161
  private logGoalOutcome;
162
+ private logProactiveDecisionOutcome;
162
163
  /**
163
164
  * Apply non-destructive cron changes suggested by the daily planner.
164
165
  * Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
@@ -18,6 +18,7 @@ import { scanner } from '../security/scanner.js';
18
18
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
19
19
  import { SelfImproveLoop } from '../agent/self-improve.js';
20
20
  import { logAuditJsonl } from '../agent/hooks.js';
21
+ import { outcomeStatusFromGoalDisposition, recentDecisions, recordDecisionOutcome, } from '../agent/proactive-ledger.js';
21
22
  const logger = pino({ name: 'clementine.cron' });
22
23
  /** Default timeout for standard cron jobs (10 minutes). */
23
24
  const CRON_STANDARD_TIMEOUT_MS = 10 * 60 * 1000;
@@ -1643,7 +1644,7 @@ export class CronScheduler {
1643
1644
  const advice = getExecutionAdvice(jobName, syntheticJob);
1644
1645
  if (advice.shouldSkip) {
1645
1646
  logger.info({ goalId: trigger.goalId, reason: advice.skipReason }, 'Goal work skipped by advisor (circuit breaker)');
1646
- this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, `Skipped: ${advice.skipReason}`);
1647
+ this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, `Skipped: ${advice.skipReason}`, trigger.decision);
1647
1648
  return;
1648
1649
  }
1649
1650
  const effectiveMaxTurns = advice.adjustedMaxTurns ?? syntheticJob.maxTurns ?? 15;
@@ -1667,10 +1668,10 @@ export class CronScheduler {
1667
1668
  this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1668
1669
  }
1669
1670
  logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
1670
- this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
1671
+ this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
1671
1672
  }).catch((err) => {
1672
1673
  logger.error({ err, goalId: trigger.goalId }, 'Goal work session failed');
1673
- this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(err));
1674
+ this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(err), trigger.decision);
1674
1675
  });
1675
1676
  }).catch((err) => {
1676
1677
  // Advisor import failed — fall back to basic execution
@@ -1686,10 +1687,10 @@ export class CronScheduler {
1686
1687
  this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1687
1688
  }
1688
1689
  logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
1689
- this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
1690
+ this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', result, undefined, trigger.decision);
1690
1691
  }).catch((goalErr) => {
1691
1692
  logger.error({ err: goalErr, goalId: trigger.goalId }, 'Goal work session failed');
1692
- this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, null, String(goalErr));
1693
+ this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source ?? 'unknown', null, String(goalErr), trigger.decision);
1693
1694
  });
1694
1695
  });
1695
1696
  }
@@ -1706,7 +1707,7 @@ export class CronScheduler {
1706
1707
  * Log goal work session outcome to a per-goal progress JSONL file.
1707
1708
  * Creates the causal link: "action X was attempted for goal Y, result was Z."
1708
1709
  */
1709
- logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error) {
1710
+ logGoalOutcome(goalId, goalPath, prevUpdatedAt, prevNotesCount, focus, source, result, error, proactiveDecision) {
1710
1711
  try {
1711
1712
  let madeProgress = false;
1712
1713
  let newNotesCount = prevNotesCount;
@@ -1738,12 +1739,46 @@ export class CronScheduler {
1738
1739
  mkdirSync(progressDir, { recursive: true });
1739
1740
  const progressFile = path.join(progressDir, `${goalId}.progress.jsonl`);
1740
1741
  appendFileSync(progressFile, JSON.stringify(entry) + '\n');
1742
+ if (proactiveDecision) {
1743
+ this.logProactiveDecisionOutcome(proactiveDecision, {
1744
+ goalId,
1745
+ focus,
1746
+ source,
1747
+ disposition,
1748
+ madeProgress,
1749
+ resultSnippet: entry.resultSnippet,
1750
+ newProgressNotes: entry.newProgressNotes,
1751
+ });
1752
+ }
1741
1753
  logger.info({ goalId, madeProgress, disposition, status: entry.status }, 'Goal outcome logged');
1742
1754
  }
1743
1755
  catch (err) {
1744
1756
  logger.debug({ err, goalId }, 'Failed to log goal outcome (non-fatal)');
1745
1757
  }
1746
1758
  }
1759
+ logProactiveDecisionOutcome(decision, details) {
1760
+ try {
1761
+ const existing = recentDecisions({ idempotencyKey: decision.idempotencyKey }, undefined)[0];
1762
+ const decisionId = existing?.id ?? decision.idempotencyKey;
1763
+ recordDecisionOutcome(decisionId, decision, {
1764
+ signalType: 'goal-work-outcome',
1765
+ description: details.focus,
1766
+ goalId: details.goalId,
1767
+ metadata: {
1768
+ source: details.source,
1769
+ disposition: details.disposition,
1770
+ madeProgress: details.madeProgress,
1771
+ newProgressNotes: details.newProgressNotes,
1772
+ },
1773
+ }, {
1774
+ status: outcomeStatusFromGoalDisposition(details.disposition),
1775
+ summary: details.resultSnippet || details.disposition,
1776
+ });
1777
+ }
1778
+ catch (err) {
1779
+ logger.debug({ err, goalId: details.goalId }, 'Failed to log proactive decision outcome (non-fatal)');
1780
+ }
1781
+ }
1747
1782
  /**
1748
1783
  * Apply non-destructive cron changes suggested by the daily planner.
1749
1784
  * Only auto-applies suggestions that reference a high-priority goal with autoSchedule=true.
@@ -93,10 +93,12 @@ export declare class HeartbeatScheduler {
93
93
  private claimNextItem;
94
94
  private completeItem;
95
95
  private failItem;
96
+ private recordWorkItemOutcome;
96
97
  static enqueueWork(opts: {
97
98
  description: string;
98
99
  prompt: string;
99
100
  source: string;
101
+ idempotencyKey?: string;
100
102
  priority?: 'high' | 'normal';
101
103
  maxTurns?: number;
102
104
  tier?: number;