clementine-agent 1.0.76 → 1.0.77

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.
@@ -108,7 +108,7 @@ export function gatherInsightSignals(gateway) {
108
108
  catch { /* non-fatal */ }
109
109
  // 2. Check for cron job failures
110
110
  try {
111
- const runLogDir = path.join(BASE_DIR, 'cron', 'run-log');
111
+ const runLogDir = path.join(BASE_DIR, 'cron', 'runs');
112
112
  if (existsSync(runLogDir)) {
113
113
  const logFiles = readdirSync(runLogDir).filter(f => f.endsWith('.jsonl')).slice(0, 10);
114
114
  for (const file of logFiles) {
@@ -118,7 +118,8 @@ export function gatherInsightSignals(gateway) {
118
118
  continue;
119
119
  try {
120
120
  const entry = JSON.parse(lastLine);
121
- if (new Date(entry.timestamp) > new Date(twoHoursAgo) && entry.status === 'error') {
121
+ const finishedAt = entry.finishedAt ?? entry.timestamp;
122
+ if (finishedAt && new Date(finishedAt) > new Date(twoHoursAgo) && entry.status === 'error') {
122
123
  signals.push(`Cron job "${entry.jobName || file.replace('.jsonl', '')}" failed: ${(entry.error || '').slice(0, 100)}`);
123
124
  }
124
125
  }
@@ -0,0 +1,50 @@
1
+ import type { DailyPlanPriority, PersistentGoal } from '../types.js';
2
+ export type ProactiveAction = 'act_now' | 'queue' | 'ask_user' | 'snooze' | 'ignore';
3
+ export type ProactiveSource = 'daily-plan' | 'goal' | 'cron' | 'inbox' | 'insight' | 'manual';
4
+ export interface ProactiveDecision {
5
+ action: ProactiveAction;
6
+ source: ProactiveSource;
7
+ reason: string;
8
+ urgency: number;
9
+ confidence: number;
10
+ authorityTier: 1 | 2 | 3;
11
+ idempotencyKey: string;
12
+ nextCheckAt?: string;
13
+ }
14
+ export interface DailyPlanDecisionInput {
15
+ priority: DailyPlanPriority;
16
+ date: string;
17
+ goalAutoSchedule?: boolean;
18
+ now?: Date;
19
+ }
20
+ export type ProactiveWorkType = 'stale-goal' | 'cron-failure' | 'overdue-tasks' | 'inbox' | `plan-${DailyPlanPriority['type']}` | 'unknown';
21
+ export interface ProactiveWorkItem {
22
+ type: ProactiveWorkType;
23
+ id?: string;
24
+ description: string;
25
+ urgency: number;
26
+ source?: ProactiveSource;
27
+ }
28
+ export interface GoalAdvancementDecision {
29
+ goal: PersistentGoal;
30
+ decision: ProactiveDecision;
31
+ score: number;
32
+ focus: string;
33
+ reason: string;
34
+ }
35
+ export interface GoalAdvancementInput {
36
+ goal: PersistentGoal;
37
+ now?: Date;
38
+ inCooldown?: boolean;
39
+ }
40
+ export declare function requiresHumanInput(text: string): boolean;
41
+ /**
42
+ * Deterministic first-pass decision for daily-plan priorities.
43
+ * The LLM decides what matters; this layer decides what the daemon may do.
44
+ */
45
+ export declare function decideDailyPlanPriority(input: DailyPlanDecisionInput): ProactiveDecision;
46
+ export declare function decisionShouldCreateGoalTrigger(decision: ProactiveDecision): boolean;
47
+ export declare function decisionShouldQueueHeartbeatWork(decision: ProactiveDecision): boolean;
48
+ export declare function decideDiscoveredWorkItem(item: ProactiveWorkItem, now?: Date): ProactiveDecision;
49
+ export declare function decideGoalAdvancement(input: GoalAdvancementInput): GoalAdvancementDecision | null;
50
+ //# sourceMappingURL=proactive-engine.d.ts.map
@@ -0,0 +1,256 @@
1
+ import { createHash } from 'node:crypto';
2
+ const HUMAN_INPUT_PATTERN = /\?|decide|choose|approve|confirm|review with|ask\s/i;
3
+ function shortHash(value) {
4
+ return createHash('sha1').update(value).digest('hex').slice(0, 10);
5
+ }
6
+ export function requiresHumanInput(text) {
7
+ return HUMAN_INPUT_PATTERN.test(text);
8
+ }
9
+ function clampUrgency(value) {
10
+ if (!Number.isFinite(value))
11
+ return 1;
12
+ return Math.max(1, Math.min(5, Math.round(value)));
13
+ }
14
+ function snoozeUntil(now, hours) {
15
+ return new Date(now.getTime() + hours * 60 * 60 * 1000).toISOString();
16
+ }
17
+ /**
18
+ * Deterministic first-pass decision for daily-plan priorities.
19
+ * The LLM decides what matters; this layer decides what the daemon may do.
20
+ */
21
+ export function decideDailyPlanPriority(input) {
22
+ const { priority, date, goalAutoSchedule = false, now = new Date() } = input;
23
+ const urgency = clampUrgency(priority.urgency);
24
+ const keyBase = `${date}:${priority.type}:${priority.id}:${priority.action}`;
25
+ const idempotencyKey = `daily-plan:${shortHash(keyBase)}`;
26
+ if (!priority.action.trim()) {
27
+ return {
28
+ action: 'ignore',
29
+ source: 'daily-plan',
30
+ reason: 'Daily plan priority had no actionable description.',
31
+ urgency,
32
+ confidence: 0.2,
33
+ authorityTier: 1,
34
+ idempotencyKey,
35
+ };
36
+ }
37
+ if (requiresHumanInput(priority.action)) {
38
+ return {
39
+ action: urgency >= 4 ? 'ask_user' : 'snooze',
40
+ source: 'daily-plan',
41
+ reason: 'Priority appears to require owner input before action.',
42
+ urgency,
43
+ confidence: 0.75,
44
+ authorityTier: 1,
45
+ idempotencyKey,
46
+ nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
47
+ };
48
+ }
49
+ if (priority.type === 'goal') {
50
+ if (urgency >= 4) {
51
+ return {
52
+ action: 'act_now',
53
+ source: 'daily-plan',
54
+ reason: 'High-urgency goal priority is autonomously actionable.',
55
+ urgency,
56
+ confidence: goalAutoSchedule ? 0.9 : 0.8,
57
+ authorityTier: 2,
58
+ idempotencyKey,
59
+ };
60
+ }
61
+ if (urgency >= 3) {
62
+ return {
63
+ action: 'queue',
64
+ source: 'daily-plan',
65
+ reason: 'Medium-urgency goal priority should be queued for heartbeat work.',
66
+ urgency,
67
+ confidence: 0.7,
68
+ authorityTier: 1,
69
+ idempotencyKey,
70
+ nextCheckAt: snoozeUntil(now, 2),
71
+ };
72
+ }
73
+ }
74
+ if (priority.type === 'cron-fix' && urgency >= 4) {
75
+ return {
76
+ action: 'ask_user',
77
+ source: 'daily-plan',
78
+ reason: 'Cron fixes can change autonomous behavior and should be confirmed.',
79
+ urgency,
80
+ confidence: 0.75,
81
+ authorityTier: 2,
82
+ idempotencyKey,
83
+ };
84
+ }
85
+ return {
86
+ action: urgency >= 3 ? 'snooze' : 'ignore',
87
+ source: 'daily-plan',
88
+ reason: 'Priority is not urgent enough for autonomous action.',
89
+ urgency,
90
+ confidence: 0.65,
91
+ authorityTier: 1,
92
+ idempotencyKey,
93
+ nextCheckAt: snoozeUntil(now, 24),
94
+ };
95
+ }
96
+ export function decisionShouldCreateGoalTrigger(decision) {
97
+ return decision.action === 'act_now' && decision.authorityTier <= 2 && decision.confidence >= 0.7;
98
+ }
99
+ export function decisionShouldQueueHeartbeatWork(decision) {
100
+ return decision.action === 'queue' && decision.authorityTier === 1 && decision.confidence >= 0.65;
101
+ }
102
+ export function decideDiscoveredWorkItem(item, now = new Date()) {
103
+ const urgency = clampUrgency(item.urgency);
104
+ const source = item.source ?? (item.type === 'cron-failure' ? 'cron'
105
+ : item.type === 'inbox' ? 'inbox'
106
+ : item.type === 'stale-goal' ? 'goal'
107
+ : item.type.startsWith('plan-') ? 'daily-plan'
108
+ : 'manual');
109
+ const idempotencyKey = `${source}:${shortHash(`${item.type}:${item.id ?? ''}:${item.description}`)}`;
110
+ if (!item.description.trim()) {
111
+ return {
112
+ action: 'ignore',
113
+ source,
114
+ reason: 'Work item had no actionable description.',
115
+ urgency,
116
+ confidence: 0.2,
117
+ authorityTier: 1,
118
+ idempotencyKey,
119
+ };
120
+ }
121
+ if (requiresHumanInput(item.description)) {
122
+ return {
123
+ action: urgency >= 4 ? 'ask_user' : 'snooze',
124
+ source,
125
+ reason: 'Work item appears to require owner input.',
126
+ urgency,
127
+ confidence: 0.7,
128
+ authorityTier: 1,
129
+ idempotencyKey,
130
+ nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
131
+ };
132
+ }
133
+ switch (item.type) {
134
+ case 'cron-failure':
135
+ return {
136
+ action: urgency >= 4 ? 'ask_user' : 'queue',
137
+ source,
138
+ reason: urgency >= 4
139
+ ? 'Repeated cron failure may require owner approval before changing automation.'
140
+ : 'Cron failure should be queued for diagnosis.',
141
+ urgency,
142
+ confidence: 0.75,
143
+ authorityTier: 2,
144
+ idempotencyKey,
145
+ };
146
+ case 'overdue-tasks':
147
+ return {
148
+ action: urgency >= 4 ? 'ask_user' : 'snooze',
149
+ source,
150
+ reason: 'Overdue tasks need owner attention unless a concrete next action is known.',
151
+ urgency,
152
+ confidence: 0.7,
153
+ authorityTier: 1,
154
+ idempotencyKey,
155
+ nextCheckAt: snoozeUntil(now, urgency >= 4 ? 4 : 24),
156
+ };
157
+ case 'inbox':
158
+ return {
159
+ action: urgency >= 2 ? 'act_now' : 'queue',
160
+ source,
161
+ reason: 'Inbox triage is a reversible tier-1 organization task.',
162
+ urgency,
163
+ confidence: 0.8,
164
+ authorityTier: 1,
165
+ idempotencyKey,
166
+ };
167
+ case 'stale-goal':
168
+ case 'plan-goal':
169
+ return {
170
+ action: urgency >= 4 ? 'act_now' : urgency >= 3 ? 'queue' : 'snooze',
171
+ source,
172
+ reason: 'Goal-related work can move autonomously when it has a concrete action.',
173
+ urgency,
174
+ confidence: 0.75,
175
+ authorityTier: urgency >= 4 ? 2 : 1,
176
+ idempotencyKey,
177
+ nextCheckAt: urgency < 4 ? snoozeUntil(now, 2) : undefined,
178
+ };
179
+ default:
180
+ return {
181
+ action: urgency >= 4 ? 'ask_user' : urgency >= 3 ? 'queue' : 'ignore',
182
+ source,
183
+ reason: 'Generic work item evaluated conservatively.',
184
+ urgency,
185
+ confidence: 0.6,
186
+ authorityTier: 1,
187
+ idempotencyKey,
188
+ nextCheckAt: urgency >= 3 && urgency < 4 ? snoozeUntil(now, 24) : undefined,
189
+ };
190
+ }
191
+ }
192
+ export function decideGoalAdvancement(input) {
193
+ const { goal, now = new Date(), inCooldown = false } = input;
194
+ if (goal.status !== 'active')
195
+ return null;
196
+ // Cooldown is a hard skip — the caller already decided we shouldn't act on
197
+ // this goal. Returning a snooze record here just got discarded downstream.
198
+ if (inCooldown)
199
+ return null;
200
+ const nowMs = now.getTime();
201
+ const dayMs = 86_400_000;
202
+ const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
203
+ const daysSinceUpdate = Math.floor((nowMs - lastUpdate) / dayMs);
204
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
205
+ const isStale = daysSinceUpdate > staleThreshold;
206
+ const hasWork = (goal.nextActions?.length ?? 0) > 0;
207
+ if (!isStale && !hasWork)
208
+ return null;
209
+ const priorityScore = goal.priority === 'high' ? 15 : goal.priority === 'medium' ? 8 : 2;
210
+ const stalenessScore = isStale ? Math.min(daysSinceUpdate - staleThreshold, 20) : 0;
211
+ const workScore = hasWork ? 5 : 0;
212
+ let deadlineScore = 0;
213
+ if (goal.targetDate) {
214
+ const daysUntilTarget = Math.floor((new Date(goal.targetDate).getTime() - nowMs) / dayMs);
215
+ if (daysUntilTarget <= 0)
216
+ deadlineScore = 20;
217
+ else if (daysUntilTarget <= 3)
218
+ deadlineScore = 10;
219
+ else if (daysUntilTarget <= 7)
220
+ deadlineScore = 5;
221
+ }
222
+ const score = priorityScore + stalenessScore + workScore + deadlineScore;
223
+ const reason = [
224
+ isStale ? `stale(${daysSinceUpdate}d)` : 'current',
225
+ `pri=${goal.priority}`,
226
+ hasWork ? 'has-work' : 'no-work',
227
+ deadlineScore > 0 ? `deadline-boost(${deadlineScore})` : '',
228
+ ].filter(Boolean).join(', ');
229
+ const focus = goal.nextActions?.length > 0
230
+ ? goal.nextActions[0]
231
+ : `Review and update progress on "${goal.title}"`;
232
+ const idempotencyKey = `goal:${shortHash(`${goal.id}:${focus}`)}`;
233
+ const urgency = Math.min(5, Math.max(1, Math.ceil(score / 8)));
234
+ const action = score >= 13 ? 'act_now' : score >= 8 ? 'queue' : 'snooze';
235
+ return {
236
+ goal,
237
+ score,
238
+ focus,
239
+ reason,
240
+ decision: {
241
+ action,
242
+ source: 'goal',
243
+ reason: action === 'act_now'
244
+ ? 'Goal score is high enough for autonomous advancement.'
245
+ : action === 'queue'
246
+ ? 'Goal should be queued, but not executed immediately.'
247
+ : 'Goal is low urgency; check later.',
248
+ urgency,
249
+ confidence: action === 'act_now' ? 0.8 : 0.7,
250
+ authorityTier: action === 'act_now' ? 2 : 1,
251
+ idempotencyKey,
252
+ nextCheckAt: action === 'act_now' ? undefined : snoozeUntil(now, 2),
253
+ },
254
+ };
255
+ }
256
+ //# sourceMappingURL=proactive-engine.js.map
@@ -0,0 +1,44 @@
1
+ import type { ProactiveDecision } from './proactive-engine.js';
2
+ export interface ProactiveDecisionContext {
3
+ signalType: string;
4
+ description: string;
5
+ goalId?: string;
6
+ owner?: string;
7
+ metadata?: Record<string, unknown>;
8
+ }
9
+ export interface ProactiveDecisionOutcome {
10
+ status: 'advanced' | 'queued' | 'asked-user' | 'snoozed' | 'ignored' | 'blocked-on-user' | 'blocked-on-external' | 'needs-different-approach' | 'monitoring' | 'no-change' | 'failed';
11
+ summary: string;
12
+ recordedAt: string;
13
+ }
14
+ export interface ProactiveDecisionRecord {
15
+ id: string;
16
+ timestamp: string;
17
+ decision: ProactiveDecision;
18
+ context: ProactiveDecisionContext;
19
+ outcome?: ProactiveDecisionOutcome;
20
+ }
21
+ export interface ProactiveLedgerOptions {
22
+ filePath?: string;
23
+ now?: Date;
24
+ }
25
+ export declare function recordDecision(decision: ProactiveDecision, context: ProactiveDecisionContext, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord;
26
+ /**
27
+ * Append an outcome record. Event-sourced: outcomes are written as a NEW
28
+ * record sharing the original decision's `id`, not as in-place updates.
29
+ *
30
+ * `wasRecentlyDecided` therefore counts outcome records as "decided" — that
31
+ * is intentional. We don't want to re-act on a signal we've already handled,
32
+ * regardless of whether the latest entry is the original decision or its
33
+ * follow-up outcome.
34
+ */
35
+ export declare function recordDecisionOutcome(decisionId: string, decision: ProactiveDecision, context: ProactiveDecisionContext, outcome: Omit<ProactiveDecisionOutcome, 'recordedAt'>, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord;
36
+ export declare function recentDecisions(filter?: Partial<{
37
+ idempotencyKey: string;
38
+ action: ProactiveDecision['action'];
39
+ source: ProactiveDecision['source'];
40
+ sinceMs: number;
41
+ }>, opts?: ProactiveLedgerOptions): ProactiveDecisionRecord[];
42
+ export declare function wasRecentlyDecided(idempotencyKey: string, windowMs: number, opts?: ProactiveLedgerOptions): boolean;
43
+ export declare function outcomeStatusFromGoalDisposition(disposition: string): ProactiveDecisionOutcome['status'];
44
+ //# sourceMappingURL=proactive-ledger.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { BASE_DIR } from '../config.js';
5
+ // Mirrors CronRunLog (cron-scheduler.ts) — keep the file bounded so reads
6
+ // don't grow linearly forever.
7
+ const MAX_BYTES = 2_000_000;
8
+ const MAX_LINES = 2000;
9
+ const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
10
+ // Per-file dedup cache: idempotencyKey → latest timestamp ms. Built lazily
11
+ // on first lookup, kept in sync on every append.
12
+ const dedupCache = new Map();
13
+ const DEFAULT_LEDGER_FILE = path.join(BASE_DIR, 'proactive', 'decisions.jsonl');
14
+ function ledgerPath(opts) {
15
+ return opts?.filePath ?? DEFAULT_LEDGER_FILE;
16
+ }
17
+ function hash(value) {
18
+ return createHash('sha1').update(value).digest('hex').slice(0, 12);
19
+ }
20
+ function readRecords(filePath) {
21
+ if (!existsSync(filePath))
22
+ return [];
23
+ try {
24
+ return readFileSync(filePath, 'utf-8')
25
+ .split('\n')
26
+ .filter(Boolean)
27
+ .map((line) => JSON.parse(line));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ function getDedupCache(filePath) {
34
+ let cache = dedupCache.get(filePath);
35
+ if (cache)
36
+ return cache;
37
+ cache = new Map();
38
+ for (const record of readRecords(filePath)) {
39
+ const ts = new Date(record.timestamp).getTime();
40
+ if (!Number.isFinite(ts))
41
+ continue;
42
+ const prev = cache.get(record.decision.idempotencyKey) ?? 0;
43
+ if (ts > prev)
44
+ cache.set(record.decision.idempotencyKey, ts);
45
+ }
46
+ dedupCache.set(filePath, cache);
47
+ return cache;
48
+ }
49
+ function touchDedupCache(filePath, idempotencyKey, timestampMs) {
50
+ const cache = dedupCache.get(filePath);
51
+ if (!cache)
52
+ return; // not yet initialized — will be built fresh on next read
53
+ const prev = cache.get(idempotencyKey) ?? 0;
54
+ if (timestampMs > prev)
55
+ cache.set(idempotencyKey, timestampMs);
56
+ }
57
+ function maybePrune(filePath) {
58
+ try {
59
+ const { size } = statSync(filePath);
60
+ if (size <= MAX_BYTES)
61
+ return;
62
+ const lines = readFileSync(filePath, 'utf-8').trim().split('\n').filter(Boolean);
63
+ if (lines.length <= MAX_LINES)
64
+ return;
65
+ const cutoff = Date.now() - MAX_AGE_MS;
66
+ const kept = [];
67
+ for (const line of lines.slice(-MAX_LINES)) {
68
+ try {
69
+ const record = JSON.parse(line);
70
+ const ts = new Date(record.timestamp).getTime();
71
+ if (Number.isFinite(ts) && ts >= cutoff)
72
+ kept.push(line);
73
+ }
74
+ catch { /* drop malformed */ }
75
+ }
76
+ writeFileSync(filePath, kept.join('\n') + (kept.length ? '\n' : ''));
77
+ // Cache is now stale w.r.t. dropped entries (older than dedup windows
78
+ // anyway), but rebuild lazily to avoid reading the file twice here.
79
+ dedupCache.delete(filePath);
80
+ }
81
+ catch {
82
+ // non-fatal
83
+ }
84
+ }
85
+ export function recordDecision(decision, context, opts) {
86
+ const now = opts?.now ?? new Date();
87
+ const record = {
88
+ id: hash(`${decision.idempotencyKey}:${now.toISOString()}:${context.signalType}`),
89
+ timestamp: now.toISOString(),
90
+ decision,
91
+ context,
92
+ };
93
+ const filePath = ledgerPath(opts);
94
+ mkdirSync(path.dirname(filePath), { recursive: true });
95
+ appendFileSync(filePath, JSON.stringify(record) + '\n');
96
+ touchDedupCache(filePath, decision.idempotencyKey, now.getTime());
97
+ setImmediate(() => maybePrune(filePath));
98
+ return record;
99
+ }
100
+ /**
101
+ * Append an outcome record. Event-sourced: outcomes are written as a NEW
102
+ * record sharing the original decision's `id`, not as in-place updates.
103
+ *
104
+ * `wasRecentlyDecided` therefore counts outcome records as "decided" — that
105
+ * is intentional. We don't want to re-act on a signal we've already handled,
106
+ * regardless of whether the latest entry is the original decision or its
107
+ * follow-up outcome.
108
+ */
109
+ export function recordDecisionOutcome(decisionId, decision, context, outcome, opts) {
110
+ const now = opts?.now ?? new Date();
111
+ const record = {
112
+ id: decisionId,
113
+ timestamp: now.toISOString(),
114
+ decision,
115
+ context,
116
+ outcome: {
117
+ ...outcome,
118
+ recordedAt: now.toISOString(),
119
+ },
120
+ };
121
+ const filePath = ledgerPath(opts);
122
+ mkdirSync(path.dirname(filePath), { recursive: true });
123
+ appendFileSync(filePath, JSON.stringify(record) + '\n');
124
+ touchDedupCache(filePath, decision.idempotencyKey, now.getTime());
125
+ setImmediate(() => maybePrune(filePath));
126
+ return record;
127
+ }
128
+ export function recentDecisions(filter = {}, opts) {
129
+ const filePath = ledgerPath(opts);
130
+ const nowMs = (opts?.now ?? new Date()).getTime();
131
+ return readRecords(filePath)
132
+ .filter((record) => {
133
+ if (filter.idempotencyKey && record.decision.idempotencyKey !== filter.idempotencyKey)
134
+ return false;
135
+ if (filter.action && record.decision.action !== filter.action)
136
+ return false;
137
+ if (filter.source && record.decision.source !== filter.source)
138
+ return false;
139
+ if (filter.sinceMs != null) {
140
+ const ts = new Date(record.timestamp).getTime();
141
+ if (!Number.isFinite(ts) || nowMs - ts > filter.sinceMs)
142
+ return false;
143
+ }
144
+ return true;
145
+ })
146
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
147
+ }
148
+ export function wasRecentlyDecided(idempotencyKey, windowMs, opts) {
149
+ const filePath = ledgerPath(opts);
150
+ const nowMs = (opts?.now ?? new Date()).getTime();
151
+ const cache = getDedupCache(filePath);
152
+ const latest = cache.get(idempotencyKey);
153
+ if (latest == null)
154
+ return false;
155
+ return nowMs - latest <= windowMs;
156
+ }
157
+ export function outcomeStatusFromGoalDisposition(disposition) {
158
+ switch (disposition) {
159
+ case 'advanced':
160
+ return 'advanced';
161
+ case 'blocked-on-user':
162
+ return 'blocked-on-user';
163
+ case 'blocked-on-external':
164
+ return 'blocked-on-external';
165
+ case 'needs-different-approach':
166
+ return 'needs-different-approach';
167
+ case 'monitoring':
168
+ return 'monitoring';
169
+ case 'no-change':
170
+ return 'no-change';
171
+ case 'error':
172
+ default:
173
+ return 'failed';
174
+ }
175
+ }
176
+ //# sourceMappingURL=proactive-ledger.js.map
@@ -1792,11 +1792,9 @@ export function validateProposal(area, target, proposedChange) {
1792
1792
  if (SOURCE_BLOCKLIST.has(target)) {
1793
1793
  return { valid: false, error: `Source file '${target}' is in the blocklist and cannot be modified by self-improvement` };
1794
1794
  }
1795
- // Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller)
1796
- // Check basic TypeScript structure: must contain at least one import or export
1797
- if (!proposedChange.includes('import ') && !proposedChange.includes('export ')) {
1798
- return { valid: false, error: 'Source proposal missing import/export statements — likely not valid TypeScript' };
1799
- }
1795
+ // Size sanity: reject wholesale rewrites (proposed content > 2x original would be caught by caller).
1796
+ // Source proposals may be small patches or modules without import/export statements;
1797
+ // callers that apply source changes do the syntax-aware validation.
1800
1798
  }
1801
1799
  return { valid: true };
1802
1800
  }
@@ -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;
@@ -12,8 +12,11 @@ import pino from 'pino';
12
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
13
  import { 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,18 +436,13 @@ 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)
399
- continue;
400
- // Skip if already queued
401
- if (pendingDescriptions.has(priority.action))
442
+ const decision = decideDailyPlanPriority({ priority, date: todayPlan.date });
443
+ if (priority.type !== 'goal' || !decisionShouldQueueHeartbeatWork(decision))
402
444
  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))
445
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
405
446
  continue;
406
447
  HeartbeatScheduler.enqueueWork({
407
448
  description: priority.action,
@@ -409,10 +450,17 @@ export class HeartbeatScheduler {
409
450
  `Use goal_work to make progress. If you need information from the owner to proceed, ` +
410
451
  `note the blocker and move on.`,
411
452
  source: `daily-plan:${priority.id}`,
453
+ idempotencyKey: decision.idempotencyKey,
412
454
  priority: 'high',
413
455
  maxTurns: 10,
414
456
  tier: 1,
415
457
  });
458
+ recordDecision(decision, {
459
+ signalType: 'daily-plan-priority',
460
+ description: priority.action,
461
+ goalId: priority.id,
462
+ metadata: { planDate: todayPlan.date, type: priority.type },
463
+ });
416
464
  logger.info({ goalId: priority.id, action: priority.action }, 'Auto-queued goal work from daily plan');
417
465
  }
418
466
  }
@@ -432,10 +480,12 @@ export class HeartbeatScheduler {
432
480
  this.completeItem(item.id, result || 'completed');
433
481
  completedWork.push({ description: item.description, result: result || 'completed' });
434
482
  logToDailyNote(`**Heartbeat work: ${item.description}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
483
+ this.recordWorkItemOutcome(item, 'advanced', result || 'completed');
435
484
  }
436
485
  catch (err) {
437
486
  this.failItem(item.id, String(err));
438
487
  logger.warn({ err, id: item.id }, 'Heartbeat work item failed');
488
+ this.recordWorkItemOutcome(item, 'failed', String(err));
439
489
  }
440
490
  }
441
491
  // ── Decide whether to invoke the LLM ──
@@ -907,7 +957,8 @@ export class HeartbeatScheduler {
907
957
  const activeGoals = allGoals.filter((g) => g && g.status === 'active');
908
958
  if (activeGoals.length === 0)
909
959
  return null;
910
- const now = Date.now();
960
+ const nowDate = new Date();
961
+ const now = nowDate.getTime();
911
962
  const DAY_MS = 86_400_000;
912
963
  const lines = activeGoals.map((g) => {
913
964
  const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
@@ -998,8 +1049,8 @@ export class HeartbeatScheduler {
998
1049
  const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
999
1050
  if (allGoals.length === 0)
1000
1051
  return;
1001
- const now = Date.now();
1002
- const DAY_MS = 86_400_000;
1052
+ const nowDate = new Date();
1053
+ const now = nowDate.getTime();
1003
1054
  // Load recent goal outcomes for disposition-based throttling.
1004
1055
  // The agent classifies each outcome (ADVANCED, BLOCKED_ON_USER, etc.)
1005
1056
  // and we use that to decide when/whether to retry.
@@ -1059,47 +1110,20 @@ export class HeartbeatScheduler {
1059
1110
  }
1060
1111
  }
1061
1112
  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.
1113
+ // Score ALL active goals through the proactive decision engine. Stale
1114
+ // goals get urgency, but current high-priority goals with pending work
1115
+ // also qualify.
1064
1116
  const scoredGoals = [];
1065
1117
  for (const goal of allGoals) {
1066
1118
  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
1119
+ const advancement = decideGoalAdvancement({
1120
+ goal,
1121
+ now: nowDate,
1122
+ inCooldown: goalCooldowns.has(goal.id),
1123
+ });
1124
+ if (advancement && decisionShouldCreateGoalTrigger(advancement.decision)) {
1125
+ scoredGoals.push(advancement);
1094
1126
  }
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
1127
  }
1104
1128
  catch {
1105
1129
  continue;
@@ -1110,10 +1134,9 @@ export class HeartbeatScheduler {
1110
1134
  // Sort by score descending, take top 2
1111
1135
  scoredGoals.sort((a, b) => b.score - a.score);
1112
1136
  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}"`;
1137
+ for (const { goal, reason, focus, decision, score } of toAdvance) {
1138
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
1139
+ continue;
1117
1140
  const trigger = {
1118
1141
  goalId: goal.id,
1119
1142
  focus,
@@ -1121,10 +1144,18 @@ export class HeartbeatScheduler {
1121
1144
  triggeredAt: new Date().toISOString(),
1122
1145
  source: 'heartbeat-advance',
1123
1146
  reason,
1147
+ decision,
1124
1148
  };
1125
- const triggerPath = path.join(goalTriggerDir, `${goal.id}.trigger.json`);
1149
+ const triggerPath = path.join(goalTriggerDir, `${decision.idempotencyKey}.trigger.json`);
1126
1150
  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');
1151
+ recordDecision(decision, {
1152
+ signalType: 'goal-advancement',
1153
+ description: focus,
1154
+ goalId: goal.id,
1155
+ owner: goal.owner,
1156
+ metadata: { score, reason, title: goal.title },
1157
+ });
1158
+ logger.info({ goalId: goal.id, title: goal.title, score, reason, focus, decision: decision.action }, 'Advancing goal via trigger');
1128
1159
  }
1129
1160
  // Note: task generation removed — the main goal trigger already includes
1130
1161
  // nextActions[0] as focus, so a separate task-gen trigger was redundant
@@ -1158,6 +1189,38 @@ export class HeartbeatScheduler {
1158
1189
  try {
1159
1190
  const content = readFileSync(filePath, 'utf-8');
1160
1191
  const title = file.replace(/\.md$/, '');
1192
+ const decision = decideDiscoveredWorkItem({
1193
+ type: 'inbox',
1194
+ id: title,
1195
+ description: `Triage inbox item: ${title}`,
1196
+ urgency: 2,
1197
+ source: 'inbox',
1198
+ });
1199
+ if (wasRecentlyDecided(decision.idempotencyKey, PROACTIVE_DECISION_DEDUPE_MS))
1200
+ continue;
1201
+ if (decision.action !== 'act_now') {
1202
+ HeartbeatScheduler.enqueueWork({
1203
+ description: `Triage inbox item: ${title}`,
1204
+ prompt: `Triage this inbox item later: ${title}`,
1205
+ source: `inbox:${title}`,
1206
+ idempotencyKey: decision.idempotencyKey,
1207
+ priority: 'normal',
1208
+ maxTurns: 5,
1209
+ tier: 1,
1210
+ });
1211
+ recordDecision(decision, {
1212
+ signalType: 'inbox-triage',
1213
+ description: `Triage inbox item: ${title}`,
1214
+ metadata: { file },
1215
+ });
1216
+ continue;
1217
+ }
1218
+ const decisionContext = {
1219
+ signalType: 'inbox-triage',
1220
+ description: `Triage inbox item: ${title}`,
1221
+ metadata: { file },
1222
+ };
1223
+ const decisionRecord = recordDecision(decision, decisionContext);
1161
1224
  // Move file before processing to prevent duplicate triage on next tick
1162
1225
  const destPath = path.join(processedDir, file);
1163
1226
  try {
@@ -1188,6 +1251,15 @@ export class HeartbeatScheduler {
1188
1251
  logToDailyNote(`**Inbox processed: ${title}** — ${result.slice(0, 100).replace(/\n/g, ' ')}`);
1189
1252
  }
1190
1253
  logger.info({ file: title }, 'Inbox item processed');
1254
+ try {
1255
+ recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
1256
+ status: 'advanced',
1257
+ summary: (result ?? 'processed').slice(0, 200),
1258
+ });
1259
+ }
1260
+ catch (err) {
1261
+ logger.debug({ err, file: title }, 'Failed to record inbox outcome (non-fatal)');
1262
+ }
1191
1263
  })
1192
1264
  .catch((err) => {
1193
1265
  // Restore file to inbox on failure so it retries
@@ -1197,6 +1269,15 @@ export class HeartbeatScheduler {
1197
1269
  }
1198
1270
  catch { /* best-effort restore */ }
1199
1271
  logger.warn({ err, file: title }, 'Failed to process inbox item');
1272
+ try {
1273
+ recordDecisionOutcome(decisionRecord.id, decision, decisionContext, {
1274
+ status: 'failed',
1275
+ summary: String(err).slice(0, 200),
1276
+ });
1277
+ }
1278
+ catch (recordErr) {
1279
+ logger.debug({ err: recordErr, file: title }, 'Failed to record inbox outcome (non-fatal)');
1280
+ }
1200
1281
  });
1201
1282
  }
1202
1283
  catch (err) {
@@ -1349,14 +1430,40 @@ export class HeartbeatScheduler {
1349
1430
  HeartbeatScheduler.saveWorkQueue(queue);
1350
1431
  }
1351
1432
  }
1433
+ recordWorkItemOutcome(item, status, summary) {
1434
+ const key = item.idempotencyKey;
1435
+ if (!key)
1436
+ return;
1437
+ try {
1438
+ const original = recentDecisions({ idempotencyKey: key }, undefined)[0];
1439
+ if (!original)
1440
+ return;
1441
+ recordDecisionOutcome(original.id, original.decision, original.context, {
1442
+ status,
1443
+ summary: summary.slice(0, 200),
1444
+ });
1445
+ }
1446
+ catch (err) {
1447
+ logger.debug({ err, id: item.id }, 'Failed to record work-item outcome (non-fatal)');
1448
+ }
1449
+ }
1352
1450
  static enqueueWork(opts) {
1353
1451
  const queue = HeartbeatScheduler.loadWorkQueue();
1452
+ // Only dedup against in-flight items here. Cross-tick "we already acted on this
1453
+ // signal" lives in the proactive ledger (wasRecentlyDecided); blocking on
1454
+ // 'completed' here would prevent legitimate re-runs of multi-session work.
1455
+ const dedupKey = opts.idempotencyKey ?? opts.source;
1456
+ const existing = queue.find((item) => (item.idempotencyKey ?? item.source) === dedupKey &&
1457
+ (item.status === 'pending' || item.status === 'running'));
1458
+ if (existing)
1459
+ return existing.id;
1354
1460
  const id = randomBytes(4).toString('hex');
1355
1461
  const item = {
1356
1462
  id,
1357
1463
  description: opts.description,
1358
1464
  prompt: opts.prompt,
1359
1465
  source: opts.source,
1466
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
1360
1467
  priority: opts.priority ?? 'normal',
1361
1468
  queuedAt: new Date().toISOString(),
1362
1469
  maxTurns: opts.maxTurns ?? 3,
@@ -5,6 +5,15 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
5
5
  import path from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { BASE_DIR, HANDOFFS_DIR, INBOX_DIR, TASKS_FILE, logger, textResult, todayStr, listAllGoals, } from './shared.js';
8
+ import { decideDiscoveredWorkItem } from '../agent/proactive-engine.js';
9
+ import { recentDecisions } from '../agent/proactive-ledger.js';
10
+ const ACTION_LABEL = {
11
+ act_now: 'Doing now',
12
+ queue: 'Queued',
13
+ ask_user: 'Needs you',
14
+ snooze: 'Snoozed',
15
+ ignore: 'Skipped',
16
+ };
8
17
  function ensureHandoffsDir() {
9
18
  if (!existsSync(HANDOFFS_DIR))
10
19
  mkdirSync(HANDOFFS_DIR, { recursive: true });
@@ -78,7 +87,7 @@ export function registerSessionTools(server) {
78
87
  const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
79
88
  if (daysSinceUpdate > staleThreshold) {
80
89
  const urgency = Math.min(5, Math.floor(daysSinceUpdate / staleThreshold) + (goal.priority === 'high' ? 2 : goal.priority === 'medium' ? 1 : 0));
81
- items.push({ type: 'stale-goal', urgency, description: `Goal "${goal.title}" stale for ${daysSinceUpdate}d (${goal.priority} priority)` });
90
+ items.push({ type: 'stale-goal', id: goal.id, urgency, description: `Goal "${goal.title}" stale for ${daysSinceUpdate}d (${goal.priority} priority)` });
82
91
  }
83
92
  }
84
93
  // 2. Failing cron jobs
@@ -92,7 +101,7 @@ export function registerSessionTools(server) {
92
101
  const errCount = consecutiveErrors === -1 ? recent.length : consecutiveErrors;
93
102
  if (errCount >= 2) {
94
103
  const jobName = recent[0]?.jobName ?? f.replace('.jsonl', '');
95
- items.push({ type: 'cron-failure', urgency: Math.min(5, errCount), description: `Job "${jobName}" has ${errCount} consecutive failures` });
104
+ items.push({ type: 'cron-failure', id: jobName, urgency: Math.min(5, errCount), description: `Job "${jobName}" has ${errCount} consecutive failures` });
96
105
  }
97
106
  }
98
107
  catch {
@@ -107,13 +116,13 @@ export function registerSessionTools(server) {
107
116
  const overdue = (content.match(/- \[ \].*?📅\s*(\d{4}-\d{2}-\d{2})/g) ?? [])
108
117
  .filter(line => { const m = line.match(/📅\s*(\d{4}-\d{2}-\d{2})/); return m && m[1] < today; });
109
118
  if (overdue.length > 0)
110
- items.push({ type: 'overdue-tasks', urgency: 4, description: `${overdue.length} overdue task(s) in TASKS.md` });
119
+ items.push({ type: 'overdue-tasks', id: 'tasks-overdue', urgency: 4, description: `${overdue.length} overdue task(s) in TASKS.md` });
111
120
  }
112
121
  // 4. Inbox items
113
122
  if (existsSync(INBOX_DIR)) {
114
123
  const inboxFiles = readdirSync(INBOX_DIR).filter(f => f.endsWith('.md') && !f.startsWith('_'));
115
124
  if (inboxFiles.length > 0)
116
- items.push({ type: 'inbox', urgency: 2, description: `${inboxFiles.length} unprocessed inbox item(s)` });
125
+ items.push({ type: 'inbox', id: 'inbox', urgency: 2, description: `${inboxFiles.length} unprocessed inbox item(s)` });
117
126
  }
118
127
  // 5. Daily plan priorities
119
128
  const planFile = path.join(BASE_DIR, 'plans', 'daily', `${todayStr()}.json`);
@@ -123,7 +132,7 @@ export function registerSessionTools(server) {
123
132
  for (const p of (plan.priorities ?? []).slice(0, 5)) {
124
133
  const alreadyListed = items.some(i => i.description.includes(p.id) || i.description.includes(p.action?.slice(0, 20)));
125
134
  if (!alreadyListed)
126
- items.push({ type: `plan-${p.type}`, urgency: p.urgency ?? 3, description: `[From daily plan] ${p.action}` });
135
+ items.push({ type: `plan-${p.type}`, id: p.id, urgency: p.urgency ?? 3, description: `[From daily plan] ${p.action}` });
127
136
  }
128
137
  }
129
138
  catch { /* non-fatal */ }
@@ -132,8 +141,51 @@ export function registerSessionTools(server) {
132
141
  const topItems = items.slice(0, maxItems);
133
142
  if (topItems.length === 0)
134
143
  return textResult('No work items discovered. All goals on track, no failures, inbox clear.');
135
- const lines = topItems.map(i => `- [${i.type}] Urgency ${i.urgency}/5: ${i.description}`);
144
+ const lines = topItems.map((i) => {
145
+ const decision = decideDiscoveredWorkItem(i);
146
+ const label = ACTION_LABEL[decision.action];
147
+ return `- [${i.type}] Urgency ${i.urgency}/5 → ${label}: ${i.description}`;
148
+ });
136
149
  return textResult(`## Discovered Work Items (${topItems.length})\n${lines.join('\n')}`);
137
150
  });
151
+ server.tool('proactive_stats', 'Report how the proactive engine has been deciding and what those decisions led to. Use to tune urgency thresholds.', {
152
+ since_hours: z.number().optional().describe('Window in hours (default: 168 = 7 days)'),
153
+ }, async ({ since_hours }) => {
154
+ const windowMs = (since_hours ?? 168) * 60 * 60 * 1000;
155
+ const records = recentDecisions({ sinceMs: windowMs }, undefined);
156
+ if (records.length === 0)
157
+ return textResult('No proactive decisions recorded in window.');
158
+ const bySource = new Map();
159
+ const outcomeByKey = new Map();
160
+ for (const r of records) {
161
+ if (r.outcome)
162
+ outcomeByKey.set(r.decision.idempotencyKey, r.outcome.status);
163
+ }
164
+ for (const r of records) {
165
+ if (r.outcome)
166
+ continue;
167
+ const sourceMap = bySource.get(r.decision.source) ?? new Map();
168
+ const bucket = sourceMap.get(r.decision.action) ?? { decided: 0, outcomes: {} };
169
+ bucket.decided++;
170
+ const outcome = outcomeByKey.get(r.decision.idempotencyKey);
171
+ if (outcome)
172
+ bucket.outcomes[outcome] = (bucket.outcomes[outcome] ?? 0) + 1;
173
+ sourceMap.set(r.decision.action, bucket);
174
+ bySource.set(r.decision.source, sourceMap);
175
+ }
176
+ const lines = [`## Proactive Stats (last ${since_hours ?? 168}h, ${records.length} records)\n`];
177
+ for (const [source, actionMap] of bySource) {
178
+ lines.push(`### ${source}`);
179
+ for (const [action, bucket] of actionMap) {
180
+ const outcomes = Object.entries(bucket.outcomes);
181
+ const outcomeStr = outcomes.length === 0
182
+ ? 'no outcomes recorded'
183
+ : outcomes.map(([k, v]) => `${k}=${v}`).join(', ');
184
+ lines.push(`- ${ACTION_LABEL[action]} (${action}): decided ${bucket.decided}× → ${outcomeStr}`);
185
+ }
186
+ lines.push('');
187
+ }
188
+ return textResult(lines.join('\n'));
189
+ });
138
190
  }
139
191
  //# sourceMappingURL=session-tools.js.map
package/dist/types.d.ts CHANGED
@@ -210,6 +210,7 @@ export interface HeartbeatWorkItem {
210
210
  description: string;
211
211
  prompt: string;
212
212
  source: string;
213
+ idempotencyKey?: string;
213
214
  priority: 'high' | 'normal';
214
215
  queuedAt: string;
215
216
  maxTurns: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.76",
3
+ "version": "1.0.77",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",