clementine-agent 1.0.11 → 1.0.12

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.
@@ -13,11 +13,11 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, GOALS_DIR, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
17
17
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
18
18
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, } from './hooks.js';
19
19
  import { scanner } from '../security/scanner.js';
20
- import { agentWorkingMemoryFile } from '../tools/shared.js';
20
+ import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
21
21
  import { AgentManager } from './agent-manager.js';
22
22
  import { extractLinks } from './link-extractor.js';
23
23
  import { StallGuard } from './stall-guard.js';
@@ -1491,17 +1491,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1491
1491
  }
1492
1492
  const goals = [];
1493
1493
  try {
1494
- if (!fs.existsSync(GOALS_DIR))
1495
- return goals;
1496
- for (const f of fs.readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'))) {
1497
- try {
1498
- const goal = JSON.parse(fs.readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
1499
- if (goal.status === 'active')
1500
- goals.push({ goal, file: f });
1501
- }
1502
- catch {
1503
- continue;
1504
- }
1494
+ for (const { goal, filePath } of listAllGoals()) {
1495
+ if (goal.status === 'active')
1496
+ goals.push({ goal, file: path.basename(filePath) });
1505
1497
  }
1506
1498
  }
1507
1499
  catch { /* non-fatal */ }
@@ -3039,15 +3031,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3039
3031
  // ── Goal context: inject linked goal info ───────────────────────
3040
3032
  let goalContext = '';
3041
3033
  try {
3042
- if (fs.existsSync(GOALS_DIR)) {
3043
- const goalFiles = fs.readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
3044
- const linkedGoals = goalFiles
3045
- .map(f => { try {
3046
- return JSON.parse(fs.readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
3047
- }
3048
- catch {
3049
- return null;
3050
- } })
3034
+ {
3035
+ const linkedGoals = listAllGoals()
3036
+ .map(({ goal }) => goal)
3051
3037
  .filter(g => g && g.status === 'active' && g.linkedCronJobs?.includes(jobName));
3052
3038
  if (linkedGoals.length > 0) {
3053
3039
  const goalLines = linkedGoals.map((g) => {
@@ -8,7 +8,8 @@
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
- import { BASE_DIR, GOALS_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
11
+ import { BASE_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
12
+ import { listAllGoals } from '../tools/shared.js';
12
13
  const logger = pino({ name: 'clementine.daily-planner' });
13
14
  const PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
14
15
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -95,21 +96,10 @@ export class DailyPlanner {
95
96
  return sections.join('\n\n') || 'No context available — all clear.';
96
97
  }
97
98
  loadActiveGoals() {
98
- if (!existsSync(GOALS_DIR))
99
- return [];
100
99
  try {
101
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
102
- const goals = [];
103
- for (const f of files) {
104
- try {
105
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
106
- if (goal.status === 'active')
107
- goals.push(goal);
108
- }
109
- catch {
110
- continue;
111
- }
112
- }
100
+ const goals = listAllGoals()
101
+ .map(({ goal }) => goal)
102
+ .filter(g => g && g.status === 'active');
113
103
  return goals.sort((a, b) => {
114
104
  const p = { high: 0, medium: 1, low: 2 };
115
105
  return (p[a.priority] ?? 2) - (p[b.priority] ?? 2);
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import pino from 'pino';
14
14
  import { GOALS_DIR, BASE_DIR } from '../config.js';
15
+ import { listAllGoals } from '../tools/shared.js';
15
16
  const logger = pino({ name: 'clementine.insight-engine' });
16
17
  const BASE_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
17
18
  const MAX_DAILY_INSIGHTS = 3;
@@ -133,20 +134,9 @@ export function gatherInsightSignals(gateway) {
133
134
  if (sessionCount === 0) {
134
135
  // No recent activity — could note quiet period if there are pending goals
135
136
  try {
136
- if (existsSync(GOALS_DIR)) {
137
- const goals = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
138
- const activeHighPriority = goals.filter(f => {
139
- try {
140
- const g = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
141
- return g.status === 'active' && g.priority === 'high';
142
- }
143
- catch {
144
- return false;
145
- }
146
- });
147
- if (activeHighPriority.length > 0) {
148
- signals.push(`Quiet period: ${activeHighPriority.length} high-priority goal(s) active but no recent user interaction`);
149
- }
137
+ const activeHighPriority = listAllGoals().filter(({ goal }) => goal.status === 'active' && goal.priority === 'high');
138
+ if (activeHighPriority.length > 0) {
139
+ signals.push(`Quiet period: ${activeHighPriority.length} high-priority goal(s) active but no recent user interaction`);
150
140
  }
151
141
  }
152
142
  catch { /* non-fatal */ }
@@ -5,10 +5,11 @@
5
5
  * reflections, progress state, and goal context. Returns the enrichment
6
6
  * string to be appended to the original prompt.
7
7
  */
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
- import { BASE_DIR, CRON_REFLECTIONS_DIR, CRON_PROGRESS_DIR, GOALS_DIR } from '../config.js';
11
+ import { BASE_DIR, CRON_REFLECTIONS_DIR, CRON_PROGRESS_DIR } from '../config.js';
12
+ import { listAllGoals } from '../tools/shared.js';
12
13
  const logger = pino({ name: 'clementine.prompt-evolver' });
13
14
  /**
14
15
  * Evolve a static cron prompt by enriching it with lessons from reflections,
@@ -225,22 +226,13 @@ function extractProgressInsights(jobName) {
225
226
  * Find goals that reference this cron job and inject alignment guidance.
226
227
  */
227
228
  function extractGoalGuidance(jobName) {
228
- if (!existsSync(GOALS_DIR))
229
- return null;
230
229
  try {
231
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
232
230
  const linkedGoals = [];
233
- for (const f of files) {
234
- try {
235
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
236
- if (goal.status !== 'active')
237
- continue;
238
- if (goal.linkedCronJobs?.includes(jobName)) {
239
- linkedGoals.push(goal);
240
- }
241
- }
242
- catch {
231
+ for (const { goal } of listAllGoals()) {
232
+ if (goal.status !== 'active')
243
233
  continue;
234
+ if (goal.linkedCronJobs?.includes(jobName)) {
235
+ linkedGoals.push(goal);
244
236
  }
245
237
  }
246
238
  if (linkedGoals.length === 0)
@@ -14,6 +14,7 @@ import matter from 'gray-matter';
14
14
  import path from 'node:path';
15
15
  import pino from 'pino';
16
16
  import { BASE_DIR, SELF_IMPROVE_DIR, SOUL_FILE, AGENTS_FILE, CRON_FILE, WORKFLOWS_DIR, VAULT_DIR, MEMORY_DB_PATH, AGENTS_DIR, PKG_DIR, CRON_REFLECTIONS_DIR, GOALS_DIR, } from '../config.js';
17
+ import { listAllGoals } from '../tools/shared.js';
17
18
  const logger = pino({ name: 'clementine.self-improve' });
18
19
  // ── Defaults ─────────────────────────────────────────────────────────
19
20
  const DEFAULT_CONFIG = {
@@ -481,34 +482,27 @@ export class SelfImproveLoop {
481
482
  }
482
483
  }
483
484
  catch { /* non-fatal */ }
484
- // Gather goal health data
485
+ // Gather goal health data (walks global + per-agent goals dirs)
485
486
  const goalHealth = [];
486
487
  try {
487
- if (existsSync(GOALS_DIR)) {
488
- const goalFiles = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
489
- const now = Date.now();
490
- const DAY_MS = 86_400_000;
491
- for (const file of goalFiles) {
492
- try {
493
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, file), 'utf-8'));
494
- const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
495
- const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
496
- const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
497
- goalHealth.push({
498
- id: goal.id,
499
- title: goal.title,
500
- status: goal.status,
501
- owner: goal.owner,
502
- priority: goal.priority,
503
- daysSinceUpdate,
504
- reviewFrequency: goal.reviewFrequency,
505
- isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
506
- linkedCronJobs: goal.linkedCronJobs || [],
507
- progressCount: goal.progressNotes?.length ?? 0,
508
- });
509
- }
510
- catch { /* skip malformed */ }
511
- }
488
+ const now = Date.now();
489
+ const DAY_MS = 86_400_000;
490
+ for (const { goal, owner } of listAllGoals()) {
491
+ const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
492
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
493
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
494
+ goalHealth.push({
495
+ id: goal.id,
496
+ title: goal.title,
497
+ status: goal.status,
498
+ owner: owner,
499
+ priority: goal.priority,
500
+ daysSinceUpdate,
501
+ reviewFrequency: goal.reviewFrequency,
502
+ isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
503
+ linkedCronJobs: goal.linkedCronJobs || [],
504
+ progressCount: goal.progressNotes?.length ?? 0,
505
+ });
512
506
  }
513
507
  }
514
508
  catch { /* non-fatal */ }
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
13
13
  import path from 'node:path';
14
14
  import pino from 'pino';
15
15
  import { BASE_DIR, GOALS_DIR, MODELS } from '../config.js';
16
+ import { listAllGoals } from '../tools/shared.js';
16
17
  const logger = pino({ name: 'clementine.strategic-planner' });
17
18
  const DAILY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
18
19
  const WEEKLY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'weekly');
@@ -265,34 +266,12 @@ export class StrategicPlanner {
265
266
  }).filter(Boolean);
266
267
  }
267
268
  loadActiveGoals() {
268
- if (!existsSync(GOALS_DIR))
269
- return [];
270
- return readdirSync(GOALS_DIR)
271
- .filter(f => f.endsWith('.json'))
272
- .map(f => {
273
- try {
274
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
275
- }
276
- catch {
277
- return null;
278
- }
279
- })
280
- .filter((g) => g && g.status === 'active');
269
+ return listAllGoals()
270
+ .map(({ goal }) => goal)
271
+ .filter(g => g && g.status === 'active');
281
272
  }
282
273
  loadAllGoals() {
283
- if (!existsSync(GOALS_DIR))
284
- return [];
285
- return readdirSync(GOALS_DIR)
286
- .filter(f => f.endsWith('.json'))
287
- .map(f => {
288
- try {
289
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
290
- }
291
- catch {
292
- return null;
293
- }
294
- })
295
- .filter(Boolean);
274
+ return listAllGoals().map(({ goal }) => goal);
296
275
  }
297
276
  }
298
277
  // ── Date helpers ───────────────────────────────────────────────────────
@@ -5,10 +5,10 @@
5
5
  * Logs to JSONL and optionally mirrors to a Discord channel.
6
6
  */
7
7
  import { createHash, randomBytes } from 'node:crypto';
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
- import os from 'node:os';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
9
  import path from 'node:path';
11
10
  import pino from 'pino';
11
+ import { listAllGoals } from '../tools/shared.js';
12
12
  const logger = pino({ name: 'clementine.team-bus' });
13
13
  /** Max inter-agent message depth before rejection (anti-loop). */
14
14
  const MAX_DEPTH = 3;
@@ -247,19 +247,10 @@ export class TeamBus {
247
247
  }
248
248
  /** Broadcast a message to all team agents (optionally scoped to a goal). */
249
249
  async broadcast(fromSlug, content, goalId, _sessionKey) {
250
- const goalsDir = path.join(process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine'), 'goals');
251
250
  let targetSlugs = [];
252
- if (existsSync(goalsDir)) {
253
- for (const f of readdirSync(goalsDir).filter(f => f.endsWith('.json'))) {
254
- try {
255
- const goal = JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
256
- if (goal.id === goalId && goal.owner && goal.owner !== fromSlug) {
257
- targetSlugs.push(goal.owner);
258
- }
259
- }
260
- catch {
261
- continue;
262
- }
251
+ for (const { goal, owner } of listAllGoals()) {
252
+ if (goal.id === goalId && owner && owner !== fromSlug) {
253
+ targetSlugs.push(owner);
263
254
  }
264
255
  }
265
256
  const allAgents = this.teamRouter.listTeamAgents();
@@ -5684,15 +5684,14 @@ export async function cmdDashboard(opts) {
5684
5684
  res.json(analytics);
5685
5685
  });
5686
5686
  // ── Goals, Delegations, Workflows, Digest — extracted routers ──
5687
- const GOALS_DIR = path.join(BASE_DIR, 'goals');
5688
5687
  const CRON_RUNS_DIR = path.join(BASE_DIR, 'cron', 'runs');
5689
5688
  const AGENTS_BASE = path.join(VAULT_DIR, '00-System', 'agents');
5690
5689
  const WORKFLOWS_DIR = path.join(VAULT_DIR, '00-System', 'workflows');
5691
5690
  const WORKFLOW_RUNS_DIR = path.join(BASE_DIR, 'workflows', 'runs');
5692
- app.use('/api/goals', goalsRouter({ goalsDir: GOALS_DIR, cronRunsDir: CRON_RUNS_DIR, vaultDir: VAULT_DIR, cronFile: CRON_FILE, getGateway }));
5691
+ app.use('/api/goals', goalsRouter({ cronRunsDir: CRON_RUNS_DIR, vaultDir: VAULT_DIR, cronFile: CRON_FILE, getGateway }));
5693
5692
  app.use('/api/delegations', delegationsRouter({ agentsBase: AGENTS_BASE, getGateway, broadcastEvent }));
5694
5693
  app.use('/api/workflows', workflowsRouter({ workflowsDir: WORKFLOWS_DIR, workflowRunsDir: WORKFLOW_RUNS_DIR, agentsBase: AGENTS_BASE, getGateway, broadcastEvent, cachedAsync }));
5695
- app.use('/api/digest', digestRouter({ baseDir: BASE_DIR, vaultDir: VAULT_DIR, goalsDir: GOALS_DIR, memoryDbPath: MEMORY_DB_PATH, parseEnvFile, getGateway, cached }));
5694
+ app.use('/api/digest', digestRouter({ baseDir: BASE_DIR, vaultDir: VAULT_DIR, memoryDbPath: MEMORY_DB_PATH, parseEnvFile, getGateway, cached }));
5696
5695
  // Voice audio route (served from digest router but needs top-level mount for /api/voice/ path)
5697
5696
  app.get('/api/voice/audio/:hash', (req, res) => {
5698
5697
  const hash = req.params.hash.replace(/[^a-f0-9]/g, '');
@@ -6,7 +6,6 @@ import type { Gateway } from '../../gateway/router.js';
6
6
  export interface DigestRouterDeps {
7
7
  baseDir: string;
8
8
  vaultDir: string;
9
- goalsDir: string;
10
9
  memoryDbPath: string;
11
10
  parseEnvFile: () => Record<string, string>;
12
11
  getGateway: () => Promise<Gateway>;
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, } from
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import path from 'node:path';
9
9
  import matter from 'gray-matter';
10
+ import { listAllGoals } from '../../tools/shared.js';
10
11
  export function getDigestPrefs(prefsFile) {
11
12
  const defaults = {
12
13
  enabled: false,
@@ -27,7 +28,7 @@ export function getDigestPrefs(prefsFile) {
27
28
  }
28
29
  export function digestRouter(deps) {
29
30
  const router = Router();
30
- const { baseDir, vaultDir, goalsDir, memoryDbPath, parseEnvFile, getGateway, cached } = deps;
31
+ const { baseDir, vaultDir, memoryDbPath, parseEnvFile, getGateway, cached } = deps;
31
32
  const prefsFile = path.join(baseDir, 'digest-preferences.json');
32
33
  const voiceCacheDir = path.join(baseDir, 'cache', 'voice');
33
34
  // Graph API helper for email
@@ -101,16 +102,10 @@ export function digestRouter(deps) {
101
102
  }
102
103
  if (secs.goals !== false) {
103
104
  try {
104
- if (existsSync(goalsDir)) {
105
- const files = readdirSync(goalsDir).filter(f => f.endsWith('.json'));
106
- const goals = files.map(f => { try {
107
- return JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
108
- }
109
- catch {
110
- return null;
111
- } }).filter(Boolean);
112
- const active = goals.filter((g) => g.status === 'active');
113
- const blocked = goals.filter((g) => g.status === 'blocked');
105
+ {
106
+ const goals = listAllGoals().map(e => e.goal);
107
+ const active = goals.filter(g => g.status === 'active');
108
+ const blocked = goals.filter(g => g.status === 'blocked');
114
109
  let goalText = `${active.length} active, ${blocked.length} blocked\n`;
115
110
  active.slice(0, 5).forEach((g) => {
116
111
  goalText += ` - ${g.title} [${g.priority}]`;
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Goals API routes — extracted from dashboard.ts
2
+ * Goals API routes — extracted from dashboard.ts.
3
+ *
4
+ * Goals live per-owner: Clementine's at ~/.clementine/goals/, each agent's at
5
+ * ~/.clementine/vault/00-System/agents/{slug}/goals/. Uses the goal-store
6
+ * helpers in tools/shared.ts so the dashboard doesn't need to know the layout.
3
7
  */
4
8
  import { Router } from 'express';
5
9
  import type { Gateway } from '../../gateway/router.js';
6
10
  export interface GoalsRouterDeps {
7
- goalsDir: string;
8
11
  cronRunsDir: string;
9
12
  vaultDir: string;
10
13
  cronFile: string;
@@ -1,79 +1,73 @@
1
1
  /**
2
- * Goals API routes — extracted from dashboard.ts
2
+ * Goals API routes — extracted from dashboard.ts.
3
+ *
4
+ * Goals live per-owner: Clementine's at ~/.clementine/goals/, each agent's at
5
+ * ~/.clementine/vault/00-System/agents/{slug}/goals/. Uses the goal-store
6
+ * helpers in tools/shared.ts so the dashboard doesn't need to know the layout.
3
7
  */
4
8
  import { Router } from 'express';
5
9
  import express from 'express';
6
- import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync, } from 'node:fs';
10
+ import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync, } from 'node:fs';
7
11
  import path from 'node:path';
8
12
  import matter from 'gray-matter';
13
+ import { listAllGoals, findGoalPath, readGoalById, writeGoalForOwner } from '../../tools/shared.js';
9
14
  export function goalsRouter(deps) {
10
15
  const router = Router();
11
- const { goalsDir, cronRunsDir, vaultDir, cronFile, getGateway } = deps;
16
+ const { cronRunsDir, vaultDir, cronFile, getGateway } = deps;
12
17
  // List goals with contributions + delegations
13
18
  router.get('/progress', (_req, res) => {
14
- if (!existsSync(goalsDir)) {
15
- res.json({ goals: [] });
16
- return;
17
- }
18
19
  try {
19
- const files = readdirSync(goalsDir).filter(f => f.endsWith('.json'));
20
- const goals = files.map(f => {
21
- try {
22
- const goal = JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
23
- const agentContributions = {};
24
- if (goal.linkedCronJobs?.length && existsSync(cronRunsDir)) {
25
- for (const jobName of goal.linkedCronJobs) {
26
- const safe = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
27
- const logFile = path.join(cronRunsDir, `${safe}.jsonl`);
28
- if (!existsSync(logFile))
20
+ const goals = listAllGoals().map(({ goal, owner }) => {
21
+ const agentContributions = {};
22
+ if (goal.linkedCronJobs?.length && existsSync(cronRunsDir)) {
23
+ for (const jobName of goal.linkedCronJobs) {
24
+ const safe = jobName.replace(/[^a-zA-Z0-9_-]/g, '_');
25
+ const logFile = path.join(cronRunsDir, `${safe}.jsonl`);
26
+ if (!existsSync(logFile))
27
+ continue;
28
+ const lines = readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
29
+ for (const line of lines.slice(-20)) {
30
+ try {
31
+ const entry = JSON.parse(line);
32
+ const agent = entry.agentSlug || jobName;
33
+ if (!agentContributions[agent])
34
+ agentContributions[agent] = { runs: 0, successes: 0 };
35
+ agentContributions[agent].runs++;
36
+ if (entry.status === 'ok')
37
+ agentContributions[agent].successes++;
38
+ agentContributions[agent].lastRun = entry.finishedAt;
39
+ }
40
+ catch {
29
41
  continue;
30
- const lines = readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
31
- for (const line of lines.slice(-20)) {
32
- try {
33
- const entry = JSON.parse(line);
34
- const agent = entry.agentSlug || jobName;
35
- if (!agentContributions[agent])
36
- agentContributions[agent] = { runs: 0, successes: 0 };
37
- agentContributions[agent].runs++;
38
- if (entry.status === 'ok')
39
- agentContributions[agent].successes++;
40
- agentContributions[agent].lastRun = entry.finishedAt;
41
- }
42
- catch {
43
- continue;
44
- }
45
42
  }
46
43
  }
47
44
  }
48
- const delegationsDir = path.join(vaultDir, '00-System', 'agents');
49
- const delegations = [];
50
- if (existsSync(delegationsDir)) {
51
- try {
52
- for (const agentDir of readdirSync(delegationsDir)) {
53
- const tasksDir = path.join(delegationsDir, agentDir, 'delegations');
54
- if (!existsSync(tasksDir))
55
- continue;
56
- for (const tf of readdirSync(tasksDir).filter(tf => tf.endsWith('.json'))) {
57
- try {
58
- const task = JSON.parse(readFileSync(path.join(tasksDir, tf), 'utf-8'));
59
- if (task.goalId === goal.id) {
60
- delegations.push({ agent: task.toAgent || agentDir, task: task.task || tf, status: task.status || 'pending' });
61
- }
62
- }
63
- catch {
64
- continue;
45
+ }
46
+ const delegationsDir = path.join(vaultDir, '00-System', 'agents');
47
+ const delegations = [];
48
+ if (existsSync(delegationsDir)) {
49
+ try {
50
+ for (const agentDir of readdirSync(delegationsDir)) {
51
+ const tasksDir = path.join(delegationsDir, agentDir, 'delegations');
52
+ if (!existsSync(tasksDir))
53
+ continue;
54
+ for (const tf of readdirSync(tasksDir).filter(tf => tf.endsWith('.json'))) {
55
+ try {
56
+ const task = JSON.parse(readFileSync(path.join(tasksDir, tf), 'utf-8'));
57
+ if (task.goalId === goal.id) {
58
+ delegations.push({ agent: task.toAgent || agentDir, task: task.task || tf, status: task.status || 'pending' });
65
59
  }
66
60
  }
61
+ catch {
62
+ continue;
63
+ }
67
64
  }
68
65
  }
69
- catch { /* ignore */ }
70
66
  }
71
- return { ...goal, agentContributions, delegations };
72
- }
73
- catch {
74
- return null;
67
+ catch { /* ignore */ }
75
68
  }
76
- }).filter(Boolean);
69
+ return { ...goal, owner, agentContributions, delegations };
70
+ });
77
71
  res.json({ goals });
78
72
  }
79
73
  catch {
@@ -83,8 +77,6 @@ export function goalsRouter(deps) {
83
77
  // Create goal
84
78
  router.post('/', express.json(), (req, res) => {
85
79
  try {
86
- if (!existsSync(goalsDir))
87
- mkdirSync(goalsDir, { recursive: true });
88
80
  const id = Math.random().toString(16).slice(2, 10);
89
81
  const { title, description, owner, priority, status, targetDate, linkedCronJobs, nextActions, blockers, reviewFrequency } = req.body;
90
82
  if (!title) {
@@ -99,7 +91,7 @@ export function goalsRouter(deps) {
99
91
  reviewFrequency: reviewFrequency || 'weekly', linkedCronJobs: linkedCronJobs || [],
100
92
  targetDate: targetDate || undefined,
101
93
  };
102
- writeFileSync(path.join(goalsDir, `${id}.json`), JSON.stringify(goal, null, 2));
94
+ writeGoalForOwner(goal);
103
95
  res.json({ ok: true, goal });
104
96
  }
105
97
  catch (e) {
@@ -109,19 +101,21 @@ export function goalsRouter(deps) {
109
101
  // Update goal
110
102
  router.put('/:id', express.json(), (req, res) => {
111
103
  try {
112
- const goalPath = path.join(goalsDir, `${req.params.id}.json`);
113
- if (!existsSync(goalPath)) {
104
+ const found = findGoalPath(req.params.id);
105
+ if (!found) {
106
+ res.status(404).json({ ok: false, error: 'Goal not found' });
107
+ return;
108
+ }
109
+ const existing = readGoalById(req.params.id);
110
+ if (!existing) {
114
111
  res.status(404).json({ ok: false, error: 'Goal not found' });
115
112
  return;
116
113
  }
117
- const existing = JSON.parse(readFileSync(goalPath, 'utf-8'));
118
114
  const { title, description, owner, priority, status, targetDate, linkedCronJobs, nextActions, blockers, reviewFrequency } = req.body;
119
115
  if (title !== undefined)
120
116
  existing.title = title;
121
117
  if (description !== undefined)
122
118
  existing.description = description;
123
- if (owner !== undefined)
124
- existing.owner = owner;
125
119
  if (priority !== undefined)
126
120
  existing.priority = priority;
127
121
  if (status !== undefined)
@@ -137,7 +131,21 @@ export function goalsRouter(deps) {
137
131
  if (reviewFrequency !== undefined)
138
132
  existing.reviewFrequency = reviewFrequency;
139
133
  existing.updatedAt = new Date().toISOString();
140
- writeFileSync(goalPath, JSON.stringify(existing, null, 2));
134
+ // If owner changed, re-route to the new owner's dir and remove from old location.
135
+ // Write the new copy first so we don't lose the goal if unlink fails.
136
+ if (owner !== undefined && owner !== found.owner) {
137
+ existing.owner = owner;
138
+ writeGoalForOwner(existing);
139
+ try {
140
+ unlinkSync(found.filePath);
141
+ }
142
+ catch (unlinkErr) {
143
+ console.warn(`[goals] Failed to remove old goal file ${found.filePath} after owner change to ${owner}:`, unlinkErr);
144
+ }
145
+ }
146
+ else {
147
+ writeFileSync(found.filePath, JSON.stringify(existing, null, 2));
148
+ }
141
149
  res.json({ ok: true, goal: existing });
142
150
  }
143
151
  catch (e) {
@@ -147,12 +155,12 @@ export function goalsRouter(deps) {
147
155
  // Delete goal
148
156
  router.delete('/:id', (_req, res) => {
149
157
  try {
150
- const goalPath = path.join(goalsDir, `${_req.params.id}.json`);
151
- if (!existsSync(goalPath)) {
158
+ const found = findGoalPath(_req.params.id);
159
+ if (!found) {
152
160
  res.status(404).json({ ok: false, error: 'Goal not found' });
153
161
  return;
154
162
  }
155
- unlinkSync(goalPath);
163
+ unlinkSync(found.filePath);
156
164
  res.json({ ok: true });
157
165
  }
158
166
  catch (e) {
@@ -162,12 +170,11 @@ export function goalsRouter(deps) {
162
170
  // Generate cron proposals from goal
163
171
  router.post('/:id/generate-crons', async (req, res) => {
164
172
  try {
165
- const goalPath = path.join(goalsDir, `${req.params.id}.json`);
166
- if (!existsSync(goalPath)) {
173
+ const goal = readGoalById(req.params.id);
174
+ if (!goal) {
167
175
  res.status(404).json({ ok: false, error: 'Goal not found' });
168
176
  return;
169
177
  }
170
- const goal = JSON.parse(readFileSync(goalPath, 'utf-8'));
171
178
  const prompt = `You are analyzing a goal and proposing automated scheduled tasks (cron jobs) to make progress on it.
172
179
 
173
180
  ## Goal: ${goal.title}
@@ -213,12 +220,16 @@ Respond ONLY with valid JSON:
213
220
  // Approve cron proposals
214
221
  router.post('/:id/approve-crons', express.json(), (req, res) => {
215
222
  try {
216
- const goalPath = path.join(goalsDir, `${req.params.id}.json`);
217
- if (!existsSync(goalPath)) {
223
+ const found = findGoalPath(req.params.id);
224
+ if (!found) {
225
+ res.status(404).json({ ok: false, error: 'Goal not found' });
226
+ return;
227
+ }
228
+ const goal = readGoalById(req.params.id);
229
+ if (!goal) {
218
230
  res.status(404).json({ ok: false, error: 'Goal not found' });
219
231
  return;
220
232
  }
221
- const goal = JSON.parse(readFileSync(goalPath, 'utf-8'));
222
233
  const crons = req.body.crons || [];
223
234
  if (crons.length === 0) {
224
235
  res.status(400).json({ ok: false, error: 'No crons to approve' });
@@ -245,7 +256,7 @@ Respond ONLY with valid JSON:
245
256
  goal.linkedCronJobs.push(name);
246
257
  }
247
258
  goal.updatedAt = new Date().toISOString();
248
- writeFileSync(goalPath, JSON.stringify(goal, null, 2));
259
+ writeFileSync(found.filePath, JSON.stringify(goal, null, 2));
249
260
  }
250
261
  res.json({ ok: true, added, skipped: crons.length - added.length });
251
262
  }
@@ -13,6 +13,7 @@ import cron from 'node-cron';
13
13
  import matter from 'gray-matter';
14
14
  import pino from 'pino';
15
15
  import { CRON_FILE, WORKFLOWS_DIR, AGENTS_DIR, DAILY_NOTES_DIR, BASE_DIR, DISCORD_OWNER_ID, GOALS_DIR, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH, TIMEZONE, } from '../config.js';
16
+ import { listAllGoals, findGoalPath, readGoalById } from '../tools/shared.js';
16
17
  import { scanner } from '../security/scanner.js';
17
18
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
18
19
  import { SelfImproveLoop } from '../agent/self-improve.js';
@@ -1154,6 +1155,13 @@ export class CronScheduler {
1154
1155
  const trimmed = response.trim();
1155
1156
  if (trimmed === '__NOTHING__')
1156
1157
  return true;
1158
+ // Bare "NOTHING" (with or without underscores/parenthetical), matching heartbeat scheduler.
1159
+ if (/^_*NOTHING_*\s*(\(|$)/im.test(trimmed))
1160
+ return true;
1161
+ // Goal-work [MONITORING] classification with no substantive body —
1162
+ // Clementine checked the goal and nothing changed, so suppress the notification.
1163
+ if (/^(_*NOTHING_*\s*)?\[MONITORING\]\s*$/i.test(trimmed))
1164
+ return true;
1157
1165
  // Only treat as noise if the response is short — avoids filtering out
1158
1166
  // substantive responses that happen to start with "No updates, but..."
1159
1167
  if (trimmed.length > 80)
@@ -1418,13 +1426,14 @@ export class CronScheduler {
1418
1426
  unlinkSync(filePath);
1419
1427
  if (!trigger.goalId)
1420
1428
  continue;
1421
- const goalPath = path.join(GOALS_DIR, `${trigger.goalId}.json`);
1422
- if (!existsSync(goalPath)) {
1429
+ const found = findGoalPath(trigger.goalId);
1430
+ if (!found) {
1423
1431
  logger.warn({ goalId: trigger.goalId }, 'Goal trigger references missing goal — skipping');
1424
1432
  continue;
1425
1433
  }
1426
- const goal = JSON.parse(readFileSync(goalPath, 'utf-8'));
1427
- if (goal.status !== 'active')
1434
+ const goalPath = found.filePath;
1435
+ const goal = readGoalById(trigger.goalId);
1436
+ if (!goal || goal.status !== 'active')
1428
1437
  continue;
1429
1438
  logger.info({ goalId: trigger.goalId, title: goal.title, focus: trigger.focus }, 'Processing goal work trigger');
1430
1439
  // Load recent progress outcomes so the agent has context about what it already did
@@ -1455,14 +1464,14 @@ export class CronScheduler {
1455
1464
  const prompt = `You are working on a focused goal session.\n\n` +
1456
1465
  `## Goal: ${goal.title}\n${goal.description}\n\n` +
1457
1466
  `## Focus for this session\n${trigger.focus}\n\n` +
1458
- (goal.progressNotes?.length > 0
1467
+ (goal.progressNotes && goal.progressNotes.length > 0
1459
1468
  ? `## Prior progress\n${goal.progressNotes.slice(-5).map((n) => `- ${n}`).join('\n')}\n\n`
1460
1469
  : '') +
1461
1470
  recentOutcomesContext +
1462
- (goal.nextActions?.length > 0
1471
+ (goal.nextActions && goal.nextActions.length > 0
1463
1472
  ? `## Planned next actions\n${goal.nextActions.map((a) => `- ${a}`).join('\n')}\n\n`
1464
1473
  : '') +
1465
- (goal.blockers?.length > 0
1474
+ (goal.blockers && goal.blockers.length > 0
1466
1475
  ? `## Current blockers\n${goal.blockers.map((b) => `- ${b}`).join('\n')}\n\n`
1467
1476
  : '') +
1468
1477
  `## Instructions\n` +
@@ -1477,8 +1486,16 @@ export class CronScheduler {
1477
1486
  ` - [MONITORING] — checked status, nothing changed, will check again later\n` +
1478
1487
  `5. Keep your output concise — summarize what you accomplished.`;
1479
1488
  const jobName = `goal:${goal.title.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40)}`;
1480
- const goalSnapshotUpdatedAt = goal.updatedAt;
1489
+ const goalSnapshotUpdatedAt = goal.updatedAt ?? '';
1481
1490
  const goalSnapshotNotes = goal.progressNotes?.length ?? 0;
1491
+ // Route goal work to the owning agent so the session runs with their
1492
+ // tools/model and output lands in their Discord channel. Clementine-owned
1493
+ // goals continue to run under Clementine's identity.
1494
+ const ownerSlug = (found.owner && found.owner !== 'clementine') ? found.owner : null;
1495
+ const dispatchOpts = ownerSlug ? { agentSlug: ownerSlug } : undefined;
1496
+ if (ownerSlug) {
1497
+ this.gateway.setSessionProfile(`cron:${jobName}`, ownerSlug);
1498
+ }
1482
1499
  // ── Route through execution advisor (same path as regular cron jobs) ──
1483
1500
  // Creates a synthetic CronJobDefinition so the advisor can apply circuit
1484
1501
  // breakers, turn-limit adjustments, model upgrades, and unleashed escalation.
@@ -1490,6 +1507,7 @@ export class CronScheduler {
1490
1507
  tier: 2,
1491
1508
  maxTurns: trigger.maxTurns ?? 15,
1492
1509
  mode: 'standard',
1510
+ ...(ownerSlug ? { agentSlug: ownerSlug } : {}),
1493
1511
  };
1494
1512
  import('../agent/execution-advisor.js').then(({ getExecutionAdvice }) => {
1495
1513
  const advice = getExecutionAdvice(jobName, syntheticJob);
@@ -1513,7 +1531,7 @@ export class CronScheduler {
1513
1531
  this.gateway.handleCronJob(jobName, enrichedPrompt, 2, effectiveMaxTurns, effectiveModel, undefined, // workDir
1514
1532
  useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined).then((result) => {
1515
1533
  if (result && !CronScheduler.isCronNoise(result)) {
1516
- this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1534
+ 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'));
1517
1535
  }
1518
1536
  logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
1519
1537
  this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
@@ -1526,7 +1544,7 @@ export class CronScheduler {
1526
1544
  logger.warn({ err, goalId: trigger.goalId }, 'Advisor unavailable — running goal work with defaults');
1527
1545
  this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15).then((result) => {
1528
1546
  if (result && !CronScheduler.isCronNoise(result)) {
1529
- this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1547
+ 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'));
1530
1548
  }
1531
1549
  logToDailyNote(`**Goal work: ${goal.title}** — ${(result || 'completed').slice(0, 100).replace(/\n/g, ' ')}`);
1532
1550
  this.logGoalOutcome(trigger.goalId, goalPath, goalSnapshotUpdatedAt, goalSnapshotNotes, trigger.focus, trigger.source, result);
@@ -1597,17 +1615,10 @@ export class CronScheduler {
1597
1615
  return;
1598
1616
  try {
1599
1617
  // Only apply suggestions linked to high-priority autoSchedule goals
1600
- const goalFiles = existsSync(GOALS_DIR) ? readdirSync(GOALS_DIR).filter(f => f.endsWith('.json')) : [];
1601
1618
  const autoScheduleGoalTitles = new Set();
1602
- for (const f of goalFiles) {
1603
- try {
1604
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
1605
- if (goal.status === 'active' && goal.priority === 'high' && goal.autoSchedule) {
1606
- autoScheduleGoalTitles.add(goal.title.toLowerCase());
1607
- }
1608
- }
1609
- catch {
1610
- continue;
1619
+ for (const { goal } of listAllGoals()) {
1620
+ if (goal.status === 'active' && goal.priority === 'high' && goal.autoSchedule) {
1621
+ autoScheduleGoalTitles.add(String(goal.title).toLowerCase());
1611
1622
  }
1612
1623
  }
1613
1624
  if (autoScheduleGoalTitles.size === 0)
@@ -47,11 +47,9 @@ export declare class HeartbeatScheduler {
47
47
  */
48
48
  private getRecentActivitySummary;
49
49
  /**
50
- * Read and parse all goal JSON files from GOALS_DIR once. Callers that
51
- * need filtered subsets (active only, priority-based, etc.) do their own
52
- * filtering over the returned array. Used by heartbeatTick to avoid
53
- * repeating the readdirSync+readFileSync pass for every goal-consuming
54
- * method.
50
+ * Read and parse all goal JSON files across Clementine's global goals dir
51
+ * AND every per-agent goals dir. Callers that need filtered subsets
52
+ * (active only, priority-based, etc.) do their own filtering.
55
53
  */
56
54
  static loadAllGoals(): Array<any>;
57
55
  /**
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import matter from 'gray-matter';
11
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
+ import { listAllGoals } from '../tools/shared.js';
13
14
  import { gatherInsightSignals, buildInsightPrompt, parseInsightResponse, canSendInsight, recordInsightSent, recordInsightAcked, maybeIncreaseCooldown, } from '../agent/insight-engine.js';
14
15
  import { CronRunLog, logToDailyNote, todayISO } from './cron-scheduler.js';
15
16
  const logger = pino({ name: 'clementine.heartbeat' });
@@ -796,25 +797,16 @@ export class HeartbeatScheduler {
796
797
  return lines.join('\n');
797
798
  }
798
799
  /**
799
- * Read and parse all goal JSON files from GOALS_DIR once. Callers that
800
- * need filtered subsets (active only, priority-based, etc.) do their own
801
- * filtering over the returned array. Used by heartbeatTick to avoid
802
- * repeating the readdirSync+readFileSync pass for every goal-consuming
803
- * method.
800
+ * Read and parse all goal JSON files across Clementine's global goals dir
801
+ * AND every per-agent goals dir. Callers that need filtered subsets
802
+ * (active only, priority-based, etc.) do their own filtering.
804
803
  */
805
804
  static loadAllGoals() {
806
805
  try {
807
- if (!existsSync(GOALS_DIR))
808
- return [];
809
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
810
- return files
811
- .map(f => { try {
812
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
813
- }
814
- catch {
815
- return null;
816
- } })
817
- .filter((g) => g !== null);
806
+ return listAllGoals().map(({ goal, owner }) => ({
807
+ ...goal,
808
+ owner: goal.owner || owner,
809
+ }));
818
810
  }
819
811
  catch (err) {
820
812
  logger.warn({ err }, 'loadAllGoals failed');
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Clementine TypeScript — Goal MCP tools.
3
3
  *
4
- * Persistent goals that drive proactive agent behavior and
5
- * can be linked to cron jobs for autonomous progress.
4
+ * Persistent goals that survive across sessions and drive proactive behavior.
5
+ * Goals live per-owner: Clementine's at ~/.clementine/goals/, each agent's at
6
+ * ~/.clementine/vault/00-System/agents/{slug}/goals/. Helpers in shared.ts
7
+ * handle routing so tools here don't need to know the layout.
6
8
  */
7
9
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
10
  export declare function registerGoalTools(server: McpServer): void;
@@ -1,25 +1,22 @@
1
1
  /**
2
2
  * Clementine TypeScript — Goal MCP tools.
3
3
  *
4
- * Persistent goals that drive proactive agent behavior and
5
- * can be linked to cron jobs for autonomous progress.
4
+ * Persistent goals that survive across sessions and drive proactive behavior.
5
+ * Goals live per-owner: Clementine's at ~/.clementine/goals/, each agent's at
6
+ * ~/.clementine/vault/00-System/agents/{slug}/goals/. Helpers in shared.ts
7
+ * handle routing so tools here don't need to know the layout.
6
8
  */
7
9
  import { randomBytes } from 'node:crypto';
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
9
11
  import path from 'node:path';
10
12
  import { z } from 'zod';
11
- import { BASE_DIR, logger, textResult } from './shared.js';
12
- const GOALS_DIR = path.join(BASE_DIR, 'goals');
13
+ import { BASE_DIR, logger, textResult, listAllGoals, findGoalPath, readGoalById, writeGoalForOwner, } from './shared.js';
13
14
  const GOAL_TRIGGER_DIR = path.join(BASE_DIR, 'cron', 'goal-triggers');
14
- function ensureGoalsDir() {
15
- if (!existsSync(GOALS_DIR))
16
- mkdirSync(GOALS_DIR, { recursive: true });
17
- }
18
15
  export function registerGoalTools(server) {
19
- server.tool('goal_create', 'Create a new persistent goal that survives across sessions. Goals drive proactive agent behavior and can be linked to cron jobs.', {
16
+ server.tool('goal_create', 'Create a new persistent goal that survives across sessions. Goals drive proactive agent behavior and can be linked to cron jobs. Agent goals live in that agent\'s own directory.', {
20
17
  title: z.string().describe('Short goal title'),
21
18
  description: z.string().describe('Detailed description of what this goal aims to achieve'),
22
- owner: z.string().optional().describe('Agent slug that owns this goal (default: "clementine")'),
19
+ owner: z.string().optional().describe('Agent slug that owns this goal (default: "clementine"). Goal file is routed to the owner\'s directory.'),
23
20
  priority: z.enum(['high', 'medium', 'low']).optional().describe('Priority level (default: "medium")'),
24
21
  targetDate: z.string().optional().describe('Target completion date (YYYY-MM-DD)'),
25
22
  nextActions: z.array(z.string()).optional().describe('Initial next actions to take'),
@@ -27,7 +24,6 @@ export function registerGoalTools(server) {
27
24
  linkedCronJobs: z.array(z.string()).optional().describe('Cron job names that contribute to this goal'),
28
25
  autoSchedule: z.boolean().optional().describe('Allow the daily planner to auto-create/adjust cron jobs for this goal (default: false)'),
29
26
  }, async ({ title, description, owner, priority, targetDate, nextActions, reviewFrequency, linkedCronJobs, autoSchedule }) => {
30
- ensureGoalsDir();
31
27
  const id = randomBytes(4).toString('hex');
32
28
  const now = new Date().toISOString();
33
29
  const goal = {
@@ -43,9 +39,9 @@ export function registerGoalTools(server) {
43
39
  linkedCronJobs: linkedCronJobs || [],
44
40
  ...(autoSchedule ? { autoSchedule } : {}),
45
41
  };
46
- writeFileSync(path.join(GOALS_DIR, `${id}.json`), JSON.stringify(goal, null, 2));
47
- logger.info({ goalId: id, title }, 'Goal created');
48
- return textResult(`Goal created: "${title}" (ID: ${id})`);
42
+ const filePath = writeGoalForOwner(goal);
43
+ logger.info({ goalId: id, title, owner: goal.owner, filePath }, 'Goal created');
44
+ return textResult(`Goal created: "${title}" (ID: ${id}) — owner: ${goal.owner}`);
49
45
  });
50
46
  server.tool('goal_update', 'Update an existing persistent goal — add progress notes, change status, update next actions, or add blockers.', {
51
47
  id: z.string().describe('Goal ID'),
@@ -57,14 +53,16 @@ export function registerGoalTools(server) {
57
53
  priority: z.enum(['high', 'medium', 'low']).optional().describe('Change priority'),
58
54
  autoSchedule: z.boolean().optional().describe('Allow the daily planner to auto-create/adjust cron jobs for this goal'),
59
55
  }, async ({ id, status, progressNote, nextActions, blockers, linkedCronJobs, priority, autoSchedule }) => {
60
- const filePath = path.join(GOALS_DIR, `${id}.json`);
61
- if (!existsSync(filePath))
56
+ const found = findGoalPath(id);
57
+ if (!found)
58
+ return textResult(`Goal not found: ${id}`);
59
+ const goal = readGoalById(id);
60
+ if (!goal)
62
61
  return textResult(`Goal not found: ${id}`);
63
- const goal = JSON.parse(readFileSync(filePath, 'utf-8'));
64
62
  if (status)
65
63
  goal.status = status;
66
64
  if (progressNote)
67
- goal.progressNotes.push(`[${new Date().toISOString().slice(0, 16)}] ${progressNote}`);
65
+ (goal.progressNotes ||= []).push(`[${new Date().toISOString().slice(0, 16)}] ${progressNote}`);
68
66
  if (nextActions)
69
67
  goal.nextActions = nextActions;
70
68
  if (blockers)
@@ -76,42 +74,32 @@ export function registerGoalTools(server) {
76
74
  if (autoSchedule !== undefined)
77
75
  goal.autoSchedule = autoSchedule;
78
76
  goal.updatedAt = new Date().toISOString();
79
- writeFileSync(filePath, JSON.stringify(goal, null, 2));
80
- logger.info({ goalId: id, status: goal.status }, 'Goal updated');
77
+ writeFileSync(found.filePath, JSON.stringify(goal, null, 2));
78
+ logger.info({ goalId: id, status: goal.status, owner: found.owner }, 'Goal updated');
81
79
  return textResult(`Goal "${goal.title}" updated (status: ${goal.status})`);
82
80
  });
83
- server.tool('goal_list', 'List persistent goals, optionally filtered by owner or status.', {
81
+ server.tool('goal_list', 'List persistent goals, optionally filtered by owner or status. Walks Clementine\'s global goals dir plus every agent\'s goals dir.', {
84
82
  owner: z.string().optional().describe('Filter by owner agent slug'),
85
83
  status: z.enum(['active', 'paused', 'completed', 'blocked']).optional().describe('Filter by status'),
86
84
  }, async ({ owner, status }) => {
87
- ensureGoalsDir();
88
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
89
- let goals = files.map(f => {
90
- try {
91
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
92
- }
93
- catch {
94
- return null;
95
- }
96
- }).filter(Boolean);
85
+ let entries = listAllGoals();
97
86
  if (owner)
98
- goals = goals.filter((g) => g.owner === owner);
87
+ entries = entries.filter(e => e.owner === owner);
99
88
  if (status)
100
- goals = goals.filter((g) => g.status === status);
101
- if (goals.length === 0)
89
+ entries = entries.filter(e => e.goal.status === status);
90
+ if (entries.length === 0)
102
91
  return textResult('No goals found matching the criteria.');
103
- const lines = goals.map((g) => {
104
- const nextAct = g.nextActions?.length > 0 ? ` | Next: ${g.nextActions[0]}` : '';
105
- const linked = g.linkedCronJobs?.length > 0 ? ` | Crons: ${g.linkedCronJobs.join(', ')}` : '';
106
- return `- [${g.status.toUpperCase()}] **${g.title}** (${g.id}) — ${g.priority} priority, owner: ${g.owner}${nextAct}${linked}`;
92
+ const lines = entries.map(({ goal, owner: goalOwner }) => {
93
+ const nextAct = goal.nextActions?.length ? ` | Next: ${goal.nextActions[0]}` : '';
94
+ const linked = goal.linkedCronJobs?.length ? ` | Crons: ${goal.linkedCronJobs.join(', ')}` : '';
95
+ return `- [${String(goal.status).toUpperCase()}] **${goal.title}** (${goal.id}) — ${goal.priority} priority, owner: ${goalOwner}${nextAct}${linked}`;
107
96
  });
108
- return textResult(`Goals (${goals.length}):\n${lines.join('\n')}`);
97
+ return textResult(`Goals (${entries.length}):\n${lines.join('\n')}`);
109
98
  });
110
99
  server.tool('goal_get', 'Get a single persistent goal with full history — progress notes, next actions, blockers, and linked cron jobs.', { id: z.string().describe('Goal ID') }, async ({ id }) => {
111
- const filePath = path.join(GOALS_DIR, `${id}.json`);
112
- if (!existsSync(filePath))
100
+ const goal = readGoalById(id);
101
+ if (!goal)
113
102
  return textResult(`Goal not found: ${id}`);
114
- const goal = JSON.parse(readFileSync(filePath, 'utf-8'));
115
103
  const sections = [
116
104
  `# ${goal.title}`,
117
105
  `**ID:** ${goal.id} | **Status:** ${goal.status} | **Priority:** ${goal.priority} | **Owner:** ${goal.owner}`,
@@ -119,29 +107,28 @@ export function registerGoalTools(server) {
119
107
  `**Review:** ${goal.reviewFrequency}`,
120
108
  `\n## Description\n${goal.description}`,
121
109
  ];
122
- if (goal.progressNotes?.length > 0)
123
- sections.push(`\n## Progress Notes\n${goal.progressNotes.map((n) => `- ${n}`).join('\n')}`);
124
- if (goal.nextActions?.length > 0)
125
- sections.push(`\n## Next Actions\n${goal.nextActions.map((a) => `- [ ] ${a}`).join('\n')}`);
126
- if (goal.blockers?.length > 0)
127
- sections.push(`\n## Blockers\n${goal.blockers.map((b) => `- ${b}`).join('\n')}`);
128
- if (goal.linkedCronJobs?.length > 0)
129
- sections.push(`\n## Linked Cron Jobs\n${goal.linkedCronJobs.map((c) => `- ${c}`).join('\n')}`);
110
+ if (goal.progressNotes?.length)
111
+ sections.push(`\n## Progress Notes\n${goal.progressNotes.map(n => `- ${n}`).join('\n')}`);
112
+ if (goal.nextActions?.length)
113
+ sections.push(`\n## Next Actions\n${goal.nextActions.map(a => `- [ ] ${a}`).join('\n')}`);
114
+ if (goal.blockers?.length)
115
+ sections.push(`\n## Blockers\n${goal.blockers.map(b => `- ${b}`).join('\n')}`);
116
+ if (goal.linkedCronJobs?.length)
117
+ sections.push(`\n## Linked Cron Jobs\n${goal.linkedCronJobs.map(c => `- ${c}`).join('\n')}`);
130
118
  return textResult(sections.join('\n'));
131
119
  });
132
- server.tool('goal_work', 'Spawn a focused background work session on a specific goal. The daemon picks up the trigger and runs a goal-directed session asynchronously results are delivered via notifications.', {
120
+ server.tool('goal_work', 'Spawn a focused background work session on a specific goal. The daemon picks up the trigger and runs a goal-directed session as the goal\'s owner (so output lands in that agent\'s channel with that agent\'s tools). Results delivered via notifications.', {
133
121
  goal_id: z.string().describe('ID of the goal to work on'),
134
122
  focus: z.string().optional().describe('Specific aspect to focus on. Defaults to the goal\'s first nextAction.'),
135
123
  max_turns: z.number().optional().default(15).describe('Max agent turns for this work session'),
136
124
  }, async ({ goal_id, focus, max_turns }) => {
137
- ensureGoalsDir();
138
- const goalPath = path.join(GOALS_DIR, `${goal_id}.json`);
139
- if (!existsSync(goalPath))
125
+ const goal = readGoalById(goal_id);
126
+ if (!goal)
140
127
  return textResult(`Goal not found: ${goal_id}. Use goal_list to see available goals.`);
141
- const goal = JSON.parse(readFileSync(goalPath, 'utf-8'));
142
128
  if (goal.status !== 'active')
143
129
  return textResult(`Goal "${goal.title}" is ${goal.status} — only active goals can be worked on.`);
144
- mkdirSync(GOAL_TRIGGER_DIR, { recursive: true });
130
+ if (!existsSync(GOAL_TRIGGER_DIR))
131
+ mkdirSync(GOAL_TRIGGER_DIR, { recursive: true });
145
132
  const trigger = {
146
133
  goalId: goal_id,
147
134
  focus: focus || goal.nextActions?.[0] || goal.description,
@@ -150,8 +137,9 @@ export function registerGoalTools(server) {
150
137
  };
151
138
  const triggerFile = path.join(GOAL_TRIGGER_DIR, `${Date.now()}-${goal_id}.trigger.json`);
152
139
  writeFileSync(triggerFile, JSON.stringify(trigger, null, 2));
153
- logger.info({ goalId: goal_id, focus: trigger.focus }, 'Goal work session triggered');
140
+ logger.info({ goalId: goal_id, owner: goal.owner, focus: trigger.focus }, 'Goal work session triggered');
154
141
  return textResult(`Triggered goal work session for "${goal.title}" (${goal_id}).\n` +
142
+ `Owner: ${goal.owner}\n` +
155
143
  `Focus: ${trigger.focus}\n` +
156
144
  `The daemon will pick it up within a few seconds. Results delivered via notifications.`);
157
145
  });
@@ -4,7 +4,7 @@
4
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { z } from 'zod';
7
- import { BASE_DIR, HANDOFFS_DIR, INBOX_DIR, TASKS_FILE, logger, textResult, todayStr, } from './shared.js';
7
+ import { BASE_DIR, HANDOFFS_DIR, INBOX_DIR, TASKS_FILE, logger, textResult, todayStr, listAllGoals, } from './shared.js';
8
8
  function ensureHandoffsDir() {
9
9
  if (!existsSync(HANDOFFS_DIR))
10
10
  mkdirSync(HANDOFFS_DIR, { recursive: true });
@@ -66,26 +66,19 @@ export function registerSessionTools(server) {
66
66
  }, async ({ agent_slug, limit }) => {
67
67
  const maxItems = Math.min(limit ?? 10, 30);
68
68
  const items = [];
69
- // 1. Stale goals
70
- const goalsDir = path.join(BASE_DIR, 'goals');
71
- if (existsSync(goalsDir)) {
72
- for (const f of readdirSync(goalsDir).filter(f => f.endsWith('.json'))) {
73
- try {
74
- const goal = JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
75
- if (goal.status !== 'active')
76
- continue;
77
- if (agent_slug && goal.owner !== agent_slug)
78
- continue;
79
- const daysSinceUpdate = Math.floor((Date.now() - new Date(goal.updatedAt).getTime()) / 86400000);
80
- const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
81
- if (daysSinceUpdate > staleThreshold) {
82
- const urgency = Math.min(5, Math.floor(daysSinceUpdate / staleThreshold) + (goal.priority === 'high' ? 2 : goal.priority === 'medium' ? 1 : 0));
83
- items.push({ type: 'stale-goal', urgency, description: `Goal "${goal.title}" stale for ${daysSinceUpdate}d (${goal.priority} priority)` });
84
- }
85
- }
86
- catch {
87
- continue;
88
- }
69
+ // 1. Stale goals (walks global + per-agent dirs)
70
+ for (const { goal, owner } of listAllGoals()) {
71
+ if (goal.status !== 'active')
72
+ continue;
73
+ if (agent_slug && owner !== agent_slug)
74
+ continue;
75
+ if (!goal.updatedAt)
76
+ continue;
77
+ const daysSinceUpdate = Math.floor((Date.now() - new Date(goal.updatedAt).getTime()) / 86400000);
78
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
79
+ if (daysSinceUpdate > staleThreshold) {
80
+ 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)` });
89
82
  }
90
83
  }
91
84
  // 2. Failing cron jobs
@@ -169,6 +169,46 @@ export declare function agentTasksFile(slug: string | null): string;
169
169
  export declare function agentWorkingMemoryFile(slug: string | null): string;
170
170
  export declare function agentGoalsDir(slug: string | null): string;
171
171
  export declare function agentDailyNotesDir(slug: string | null): string;
172
+ export type GoalRecord = {
173
+ id: string;
174
+ title: string;
175
+ description?: string;
176
+ owner: string;
177
+ status?: string;
178
+ priority?: string;
179
+ createdAt?: string;
180
+ updatedAt?: string;
181
+ progressNotes?: string[];
182
+ nextActions?: string[];
183
+ blockers?: string[];
184
+ reviewFrequency?: string;
185
+ linkedCronJobs?: string[];
186
+ [key: string]: unknown;
187
+ };
188
+ /** Return the directory where a goal owned by `owner` should live. */
189
+ export declare function goalDirForOwner(owner: string): string;
190
+ /**
191
+ * Walk Clementine's global goals dir AND every per-agent goals dir.
192
+ * Returns {goal, path, owner} for each goal found. Owner is derived from
193
+ * the goal's `owner` field if set, else inferred from which directory it was in.
194
+ */
195
+ export declare function listAllGoals(): Array<{
196
+ goal: GoalRecord;
197
+ filePath: string;
198
+ owner: string;
199
+ }>;
200
+ /** Find a goal's file path by id across global + all agent dirs. */
201
+ export declare function findGoalPath(id: string): {
202
+ filePath: string;
203
+ owner: string;
204
+ } | null;
205
+ /** Read a goal by id; returns null if not found. */
206
+ export declare function readGoalById(id: string): GoalRecord | null;
207
+ /**
208
+ * Write a goal to the correct directory based on its owner field.
209
+ * Creates the target directory if needed. Returns the path written.
210
+ */
211
+ export declare function writeGoalForOwner(goal: GoalRecord): string;
172
212
  export declare function todayStr(): string;
173
213
  export declare function yesterdayStr(): string;
174
214
  export declare function nowTime(): string;
@@ -96,6 +96,90 @@ export function agentDailyNotesDir(slug) {
96
96
  return DAILY_NOTES_DIR;
97
97
  return path.join(AGENTS_DIR, slug, 'daily-notes');
98
98
  }
99
+ /** Return the directory where a goal owned by `owner` should live. */
100
+ export function goalDirForOwner(owner) {
101
+ if (!owner || owner === 'clementine')
102
+ return GOALS_DIR;
103
+ return path.join(AGENTS_DIR, owner, 'goals');
104
+ }
105
+ /**
106
+ * Walk Clementine's global goals dir AND every per-agent goals dir.
107
+ * Returns {goal, path, owner} for each goal found. Owner is derived from
108
+ * the goal's `owner` field if set, else inferred from which directory it was in.
109
+ */
110
+ export function listAllGoals() {
111
+ const results = [];
112
+ const readDir = (dir, inferredOwner) => {
113
+ if (!existsSync(dir))
114
+ return;
115
+ let files;
116
+ try {
117
+ files = readdirSync(dir).filter(f => f.endsWith('.json'));
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ for (const f of files) {
123
+ const fp = path.join(dir, f);
124
+ try {
125
+ const goal = JSON.parse(readFileSync(fp, 'utf-8'));
126
+ if (!goal.id)
127
+ continue;
128
+ const owner = (typeof goal.owner === 'string' && goal.owner) || inferredOwner;
129
+ results.push({ goal, filePath: fp, owner });
130
+ }
131
+ catch { /* skip malformed */ }
132
+ }
133
+ };
134
+ readDir(GOALS_DIR, 'clementine');
135
+ if (existsSync(AGENTS_DIR)) {
136
+ let agentDirs = [];
137
+ try {
138
+ agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
139
+ .filter(d => d.isDirectory() && !d.name.startsWith('_'))
140
+ .map(d => d.name);
141
+ }
142
+ catch { /* ignore */ }
143
+ for (const slug of agentDirs) {
144
+ readDir(path.join(AGENTS_DIR, slug, 'goals'), slug);
145
+ }
146
+ }
147
+ return results;
148
+ }
149
+ /** Find a goal's file path by id across global + all agent dirs. */
150
+ export function findGoalPath(id) {
151
+ for (const entry of listAllGoals()) {
152
+ if (entry.goal.id === id) {
153
+ return { filePath: entry.filePath, owner: entry.owner };
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+ /** Read a goal by id; returns null if not found. */
159
+ export function readGoalById(id) {
160
+ const found = findGoalPath(id);
161
+ if (!found)
162
+ return null;
163
+ try {
164
+ return JSON.parse(readFileSync(found.filePath, 'utf-8'));
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ /**
171
+ * Write a goal to the correct directory based on its owner field.
172
+ * Creates the target directory if needed. Returns the path written.
173
+ */
174
+ export function writeGoalForOwner(goal) {
175
+ const owner = goal.owner || 'clementine';
176
+ const dir = goalDirForOwner(owner);
177
+ if (!existsSync(dir))
178
+ mkdirSync(dir, { recursive: true });
179
+ const fp = path.join(dir, `${goal.id}.json`);
180
+ writeFileSync(fp, JSON.stringify(goal, null, 2));
181
+ return fp;
182
+ }
99
183
  // ── Date/Time helpers ───────────────────────────────���──────────────────
100
184
  export function todayStr() {
101
185
  const d = new Date();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",