clementine-agent 1.18.67 → 1.18.68

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.
@@ -62,9 +62,15 @@ export interface SkillContextResult {
62
62
  * that don't resolve are surfaced via `missing[]` (warned, never fatal) so
63
63
  * the dashboard can flag broken references.
64
64
  *
65
+ * When `opts.skipAutoMatch` is true (predictable mode), only pinned skills
66
+ * load — the runtime keyword/semantic match is skipped entirely. The trick
67
+ * runs with ONLY the skills the user explicitly attached.
68
+ *
65
69
  * Exported only for testability — the production caller is `runAgentCron`.
66
70
  */
67
- export declare function buildSkillContext(jobName: string, jobPrompt: string, agentSlug: string | undefined, pinnedSkills: string[] | undefined, memoryStore?: MemoryStore | null): Promise<SkillContextResult>;
71
+ export declare function buildSkillContext(jobName: string, jobPrompt: string, agentSlug: string | undefined, pinnedSkills: string[] | undefined, memoryStore?: MemoryStore | null, opts?: {
72
+ skipAutoMatch?: boolean;
73
+ }): Promise<SkillContextResult>;
68
74
  /** Minimal interface for the post-task reflection + skill extraction
69
75
  * hooks. Lets `runAgentCron` stay decoupled from the full
70
76
  * PersonalAssistant import while still benefiting from the existing
@@ -115,6 +121,13 @@ export interface RunAgentCronOptions {
115
121
  * Applied after `buildExtraMcpForRunAgent` runs, so the effective set
116
122
  * is `profile ∩ trick`. */
117
123
  allowedMcpServers?: string[];
124
+ /** Predictable mode — when true, the runner skips the auto-injected
125
+ * context blocks (MEMORY.md, team comms, delegation queue) and the
126
+ * auto-matched skill search. The trick runs with ONLY what was
127
+ * explicitly attached: prompt, criteria, pinned skills, linked goals,
128
+ * prior progress. The fix for fire-time memory drift. Undefined =
129
+ * legacy behavior (inject everything). */
130
+ predictable?: boolean;
118
131
  }
119
132
  export interface RunAgentCronResult extends RunAgentResult {
120
133
  /** The final prompt that was sent to the agent (after context injection).
@@ -167,6 +180,10 @@ export interface CronExecutionPlan {
167
180
  maxBudgetUsd: number | undefined;
168
181
  agentSlug: string | undefined;
169
182
  ownerName: string;
183
+ /** Whether the trick is in predictable (contract) mode — true means
184
+ * MEMORY.md / team / delegation / auto-skills were intentionally
185
+ * skipped. Used by the Preview verdict line. */
186
+ predictable: boolean;
170
187
  }
171
188
  /**
172
189
  * Plan a cron run — assemble all context, resolve skills, intersect tool/MCP
@@ -226,9 +226,13 @@ function buildCriteriaContext(successCriteria) {
226
226
  * that don't resolve are surfaced via `missing[]` (warned, never fatal) so
227
227
  * the dashboard can flag broken references.
228
228
  *
229
+ * When `opts.skipAutoMatch` is true (predictable mode), only pinned skills
230
+ * load — the runtime keyword/semantic match is skipped entirely. The trick
231
+ * runs with ONLY the skills the user explicitly attached.
232
+ *
229
233
  * Exported only for testability — the production caller is `runAgentCron`.
230
234
  */
231
- export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSkills, memoryStore) {
235
+ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSkills, memoryStore, opts) {
232
236
  const applied = [];
233
237
  const missing = [];
234
238
  try {
@@ -259,8 +263,10 @@ export async function buildSkillContext(jobName, jobPrompt, agentSlug, pinnedSki
259
263
  }
260
264
  }
261
265
  // 2. Auto-match fills the remainder, deduped against pins.
266
+ // In predictable (contract) mode we skip this entirely — only
267
+ // pinned skills load, the runtime keyword/semantic search is off.
262
268
  const remaining = MAX_INJECTED_SKILLS - prepared.length;
263
- if (remaining > 0) {
269
+ if (remaining > 0 && !opts?.skipAutoMatch) {
264
270
  const matched = searchSkills(skillQuery, remaining + (pinnedSkills?.length ?? 0), agentSlug, { suppressedNames });
265
271
  for (const m of matched) {
266
272
  if (prepared.length >= MAX_INJECTED_SKILLS)
@@ -320,13 +326,22 @@ export async function buildCronExecutionPlan(opts) {
320
326
  const tier = opts.tier ?? 1;
321
327
  const agentSlug = opts.profile?.slug;
322
328
  const ownerName = process.env.OWNER_NAME ?? 'the user';
323
- const memoryContext = buildAutonomousMemoryContext(opts.profile);
324
- const progressContext = buildProgressContext(opts.jobName);
325
- const goalContext = buildGoalContext(opts.jobName);
326
- const delegationContext = buildDelegationContext(agentSlug);
327
- const teamContext = buildTeamContext(agentSlug);
329
+ // ── Predictable (contract) mode ────────────────────────────────────
330
+ // When `predictable: true`, the trick runs with ONLY what was explicitly
331
+ // attached prompt, criteria, pinned skills, linked goals, prior progress.
332
+ // We skip MEMORY.md, team comms, delegation queue, and the runtime skill
333
+ // auto-match. This is the fix for the email-cadence failure mode where the
334
+ // agent agreed to a plan in chat then re-derived from drifted memory at
335
+ // fire time. Legacy tricks (predictable === undefined) preserve existing
336
+ // behavior so we don't surprise anyone.
337
+ const predictable = opts.predictable === true;
338
+ const memoryContext = predictable ? '' : buildAutonomousMemoryContext(opts.profile);
339
+ const progressContext = buildProgressContext(opts.jobName); // opt-in via cron_progress writes
340
+ const goalContext = buildGoalContext(opts.jobName); // explicit links; not auto-inferred
341
+ const delegationContext = predictable ? '' : buildDelegationContext(agentSlug);
342
+ const teamContext = predictable ? '' : buildTeamContext(agentSlug);
328
343
  const criteriaContext = buildCriteriaContext(opts.successCriteria);
329
- const skillResult = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.pinnedSkills, opts.memoryStore);
344
+ const skillResult = await buildSkillContext(opts.jobName, opts.jobPrompt, agentSlug, opts.pinnedSkills, opts.memoryStore, { skipAutoMatch: predictable });
330
345
  const skillContext = skillResult.text;
331
346
  const howToRespond = `## How to respond\n` +
332
347
  `You're sending this directly to ${ownerName} as a DM. ` +
@@ -390,6 +405,7 @@ export async function buildCronExecutionPlan(opts) {
390
405
  maxBudgetUsd: maxBudget,
391
406
  agentSlug,
392
407
  ownerName,
408
+ predictable,
393
409
  };
394
410
  }
395
411
  /**
@@ -6237,7 +6237,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6237
6237
  // ── CRON CRUD routes (continued) ──────────────────────────────
6238
6238
  app.post('/api/cron', (req, res) => {
6239
6239
  try {
6240
- const { name, schedule, prompt, tier, enabled, work_dir, mode, max_hours, max_retries, after, agent, context, skills, allowedTools, allowedMcpServers, tags, category, } = req.body;
6240
+ const { name, schedule, prompt, tier, enabled, work_dir, mode, max_hours, max_retries, after, agent, context, skills, allowedTools, allowedMcpServers, tags, category, predictable, } = req.body;
6241
6241
  if (!name || !schedule || !prompt) {
6242
6242
  res.status(400).json({ error: 'name, schedule, and prompt are required' });
6243
6243
  return;
@@ -6293,6 +6293,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6293
6293
  if (typeof category === 'string' && category.trim()) {
6294
6294
  job.category = category.trim().slice(0, 64);
6295
6295
  }
6296
+ // Predictable mode — default to true (contract execution) for new
6297
+ // tricks created via the dashboard. Mirror the MCP tool default.
6298
+ job.predictable = (predictable === false) ? false : true;
6296
6299
  jobs.push(job);
6297
6300
  writeCronFileAt(cronFile, parsed, jobs);
6298
6301
  res.json({ ok: true, message: `Created cron job: ${name}` });
@@ -6431,6 +6434,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6431
6434
  delete jobs[idx].category;
6432
6435
  }
6433
6436
  }
6437
+ if (updates.predictable !== undefined) {
6438
+ jobs[idx].predictable = Boolean(updates.predictable);
6439
+ }
6434
6440
  if (updates.name !== undefined && updates.name !== bareJobName) {
6435
6441
  // Rename — check for duplicates
6436
6442
  const dup = jobs.find((j, i) => i !== idx && String(j.name ?? '').toLowerCase() === String(updates.name).toLowerCase());
@@ -6548,6 +6554,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6548
6554
  pinnedSkills: job.skills,
6549
6555
  allowedTools: job.allowedTools,
6550
6556
  allowedMcpServers: job.allowedMcpServers,
6557
+ predictable: job.predictable,
6551
6558
  });
6552
6559
  // Enrich each applied skill with its title/description/full markdown
6553
6560
  // body so the UI can render "what the agent will actually read".
@@ -6586,7 +6593,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6586
6593
  mode: job.mode ?? null,
6587
6594
  tags: job.tags ?? [],
6588
6595
  category: job.category ?? null,
6596
+ predictable: typeof job.predictable === 'boolean' ? job.predictable : null,
6589
6597
  },
6598
+ predictable: plan.predictable,
6590
6599
  profile: profile ? { slug: profile.slug, name: profile.name } : null,
6591
6600
  builtPrompt: plan.builtPrompt,
6592
6601
  contextBlocks: plan.contextBlocks,
@@ -19673,6 +19682,19 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19673
19682
  <div class="form-group">
19674
19683
  <label class="form-label">Capabilities <span style="color:var(--text-muted);font-weight:normal">(optional — pin skills + scope tools/MCP)</span></label>
19675
19684
  <div class="form-hint" style="margin-bottom:6px">Pin learned procedures and constrain which tools / MCP servers this trick can use. Empty = inherit defaults. Use Preview on the card to see exactly what gets sent.</div>
19685
+ <div class="cap-section">
19686
+ <label class="cap-section-label">Predictable Mode</label>
19687
+ <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;font-size:12px;color:var(--text-primary)">
19688
+ <input type="checkbox" id="cron-predictable" checked style="margin-top:3px">
19689
+ <span>
19690
+ <strong>Run with only what's attached</strong> (recommended)
19691
+ <div style="font-size:11px;color:var(--text-muted);margin-top:3px;line-height:1.5">
19692
+ ON: MEMORY.md, team comms, delegation queue, and auto-matched skills are SKIPPED at fire-time. The trick runs ONLY with the prompt + pinned skills + tools you see here. No drift, no surprise.<br>
19693
+ OFF: legacy mode — runner injects MEMORY.md and other live context. Use only when the trick legitimately needs to re-read memory each fire (e.g. "summarize yesterday's daily note").
19694
+ </div>
19695
+ </span>
19696
+ </label>
19697
+ </div>
19676
19698
  <div class="cap-section">
19677
19699
  <label class="cap-section-label">Pinned Skills</label>
19678
19700
  <div class="cap-picker-chips" id="cron-skills-chips"></div>
@@ -22726,6 +22748,8 @@ function renderScheduledTaskCard(task) {
22726
22748
  var badges = '';
22727
22749
  if (task.owner) badges += '<span class="badge badge-orange">' + esc(task.owner) + '</span>';
22728
22750
  if (task.category) badges += '<span class="badge badge-gray" title="Category">' + esc(task.category) + '</span>';
22751
+ if (task.predictable === true) badges += '<span class="badge badge-green" title="Contract mode — runs with only the prompt + pinned skills/tools. No MEMORY.md, no auto-matched skills, no team comms injection at fire-time.">🔒 predictable</span>';
22752
+ else if (task.predictable === false) badges += '<span class="badge badge-yellow" title="Dynamic mode — fire-time injects MEMORY.md, recent team activity, and auto-matched skills. Can drift from chat-time intent.">🔄 reads memory</span>';
22729
22753
  if (task.mode === 'unleashed') badges += '<span class="badge badge-purple">long-running</span>';
22730
22754
  if (task.after) badges += '<span class="badge badge-yellow" title="Triggered after ' + esc(task.after) + '">after ' + esc(task.after) + '</span>';
22731
22755
  if (task.maxRetries != null) badges += '<span class="badge badge-gray">' + esc(task.maxRetries) + ' retries</span>';
@@ -23789,6 +23813,9 @@ function resetTrickCapabilityState() {
23789
23813
  if (toolsToggle) toolsToggle.textContent = '▾ Show';
23790
23814
  var catEl = document.getElementById('cron-category');
23791
23815
  if (catEl) catEl.value = '';
23816
+ // Default: predictable ON for new tricks (matches add_cron_job default).
23817
+ var predEl = document.getElementById('cron-predictable');
23818
+ if (predEl) predEl.checked = true;
23792
23819
  renderSkillsPickerChips();
23793
23820
  renderMcpPickerChips();
23794
23821
  renderTagsPickerChips();
@@ -24102,6 +24129,10 @@ function openEditCronModal(jobName) {
24102
24129
  if (allowedTools.length > 0) toggleAllowedToolsPanel();
24103
24130
  var catEl = document.getElementById('cron-category');
24104
24131
  if (catEl) catEl.value = job.category || '';
24132
+ // Predictable: respect saved value; if undefined (legacy trick), keep
24133
+ // unchecked so we don't silently change runner behavior.
24134
+ var predEl = document.getElementById('cron-predictable');
24135
+ if (predEl) predEl.checked = (job.predictable === true);
24105
24136
  renderSkillsPickerChips();
24106
24137
  renderMcpPickerChips();
24107
24138
  renderTagsPickerChips();
@@ -24140,7 +24171,23 @@ function closeCronPreviewModal() {
24140
24171
 
24141
24172
  function renderCronPreview(d) {
24142
24173
  var html = '';
24143
- // Warnings band first
24174
+ // Predictable verdict line — the headline visibility win.
24175
+ html += '<div class="preview-section">';
24176
+ if (d.predictable === true) {
24177
+ html += '<div style="padding:10px 12px;border-radius:6px;background:rgba(16,185,129,0.12);color:var(--green);font-size:13px;font-weight:500">'
24178
+ + '🔒 <strong>Predictable</strong> — what you see here is exactly what will run. No MEMORY.md, no team comms, no auto-matched skills injected at fire-time.'
24179
+ + '</div>';
24180
+ } else if (d.predictable === false) {
24181
+ html += '<div style="padding:10px 12px;border-radius:6px;background:rgba(245,158,11,0.12);color:var(--yellow);font-size:13px;font-weight:500">'
24182
+ + '⚠ <strong>Reads memory at fire-time</strong> — fire-time will ALSO inject MEMORY.md, recent team comms, delegation queue, and auto-matched skills. Output may differ from this preview if those drift between now and fire.'
24183
+ + '</div>';
24184
+ } else {
24185
+ html += '<div style="padding:10px 12px;border-radius:6px;background:var(--bg-tertiary);color:var(--text-muted);font-size:12px">'
24186
+ + 'Legacy trick — predictable mode not set. Runs in dynamic mode (injects MEMORY.md, etc). Edit and turn on Predictable Mode to lock down behavior.'
24187
+ + '</div>';
24188
+ }
24189
+ html += '</div>';
24190
+ // Warnings band
24144
24191
  if (Array.isArray(d.warnings) && d.warnings.length > 0) {
24145
24192
  html += '<div class="preview-section">';
24146
24193
  for (var w = 0; w < d.warnings.length; w++) {
@@ -24341,6 +24388,7 @@ async function saveCronJob() {
24341
24388
  const categoryRaw = (document.getElementById('cron-category')?.value || '').trim();
24342
24389
  const category = categoryRaw || undefined;
24343
24390
  const allowedTools = parseAllowedToolsRaw();
24391
+ const predictable = !!document.getElementById('cron-predictable')?.checked;
24344
24392
 
24345
24393
  if (!name || !schedule || !prompt) {
24346
24394
  toast('Please fill in all fields', 'error');
@@ -24363,6 +24411,7 @@ async function saveCronJob() {
24363
24411
  : (_cronSelectedMcp.length ? _cronSelectedMcp : undefined),
24364
24412
  tags: editingCronJob ? _cronTags : (_cronTags.length ? _cronTags : undefined),
24365
24413
  category: editingCronJob ? (category || '') : category,
24414
+ predictable,
24366
24415
  };
24367
24416
 
24368
24417
  if (editingCronJob) {
@@ -97,6 +97,10 @@ export interface ScheduledTaskCard {
97
97
  tags?: string[];
98
98
  /** Optional category bucket. */
99
99
  category?: string;
100
+ /** Predictable (contract) mode — true means runner skips MEMORY.md /
101
+ * team comms / auto-matched skills. The visibility-on-card flag for
102
+ * "this trick will run with only what you see here." */
103
+ predictable?: boolean;
100
104
  }
101
105
  export interface ScheduledWorkflowCard {
102
106
  type: 'scheduled_workflow';
@@ -215,6 +215,7 @@ export function buildOperationsSnapshot(input) {
215
215
  allowedMcpServers: asStringArray(job.allowed_mcp_servers ?? job.allowedMcpServers),
216
216
  tags: asStringArray(job.tags),
217
217
  category: typeof job.category === 'string' && job.category.trim() ? job.category.trim() : undefined,
218
+ predictable: typeof job.predictable === 'boolean' ? job.predictable : undefined,
218
219
  };
219
220
  }).sort((a, b) => a.owner.localeCompare(b.owner) || a.displayName.localeCompare(b.displayName));
220
221
  const scheduledWorkflows = input.workflowSummaries
@@ -132,6 +132,8 @@ export function parseCronJobs() {
132
132
  const category = typeof categoryRaw === 'string' && categoryRaw.trim()
133
133
  ? categoryRaw.trim().slice(0, 64)
134
134
  : undefined;
135
+ // Predictable (contract) mode — undefined means legacy behavior.
136
+ const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
135
137
  if (!name || !schedule || !prompt) {
136
138
  logger.warn({ job }, 'Skipping malformed cron job');
137
139
  continue;
@@ -139,7 +141,7 @@ export function parseCronJobs() {
139
141
  jobs.push({
140
142
  name, schedule, prompt, enabled, tier, maxTurns, model, workDir, mode,
141
143
  maxHours, maxRetries, after, successCriteria, alwaysDeliver, context, preCheck, agentSlug,
142
- skills, allowedTools, allowedMcpServers, tags, category,
144
+ skills, allowedTools, allowedMcpServers, tags, category, predictable,
143
145
  });
144
146
  }
145
147
  return jobs;
@@ -199,6 +201,7 @@ export function parseAgentCronJobs(agentsDir) {
199
201
  const category = typeof categoryRaw === 'string' && categoryRaw.trim()
200
202
  ? categoryRaw.trim().slice(0, 64)
201
203
  : undefined;
204
+ const predictable = typeof job.predictable === 'boolean' ? job.predictable : undefined;
202
205
  if (!name || !schedule || !prompt) {
203
206
  logger.warn({ job, agent: slug }, 'Skipping malformed agent cron job');
204
207
  continue;
@@ -209,7 +212,7 @@ export function parseAgentCronJobs(agentsDir) {
209
212
  schedule, prompt, enabled, tier, maxTurns, model, workDir,
210
213
  mode, maxHours, maxRetries, after, successCriteria, context, preCheck,
211
214
  agentSlug: slug,
212
- skills, allowedTools, allowedMcpServers, tags, category,
215
+ skills, allowedTools, allowedMcpServers, tags, category, predictable,
213
216
  });
214
217
  }
215
218
  }
@@ -1094,12 +1097,12 @@ export class CronScheduler {
1094
1097
  const startedAt = new Date();
1095
1098
  try {
1096
1099
  // Standard cron jobs get a timeout via SDK AbortController (advisor may override)
1097
- let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers);
1100
+ let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable);
1098
1101
  // alwaysDeliver: retry once if the response is empty/noise
1099
1102
  if (job.alwaysDeliver && (!response || CronScheduler.isCronNoise(response))) {
1100
1103
  logger.info({ job: job.name }, 'alwaysDeliver: empty/noise response — retrying once');
1101
1104
  try {
1102
- const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers);
1105
+ const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug, job.skills, job.allowedTools, job.allowedMcpServers, job.predictable);
1103
1106
  if (retryResponse && !CronScheduler.isCronNoise(retryResponse)) {
1104
1107
  response = retryResponse;
1105
1108
  }
@@ -175,7 +175,9 @@ export declare class Gateway {
175
175
  handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string,
176
176
  /** Accepted for back-compat; canonical SDK path executes every job
177
177
  * identically. Affects only UI display + budget heuristics elsewhere. */
178
- _mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, pinnedSkills?: string[], allowedTools?: string[], allowedMcpServers?: string[]): Promise<string>;
178
+ _mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, pinnedSkills?: string[], allowedTools?: string[], allowedMcpServers?: string[],
179
+ /** Predictable (contract) mode — runner skips memory/team/auto-skills. */
180
+ predictable?: boolean): Promise<string>;
179
181
  /**
180
182
  * Process a team message as an autonomous task — same multi-phase execution
181
183
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -1969,7 +1969,9 @@ export class Gateway {
1969
1969
  * identically. Affects only UI display + budget heuristics elsewhere. */
1970
1970
  _mode, maxHours, timeoutMs, successCriteria, agentSlug,
1971
1971
  // ── Trick capabilities (optional; preserve today's behavior when omitted) ─
1972
- pinnedSkills, allowedTools, allowedMcpServers) {
1972
+ pinnedSkills, allowedTools, allowedMcpServers,
1973
+ /** Predictable (contract) mode — runner skips memory/team/auto-skills. */
1974
+ predictable) {
1973
1975
  const releaseLane = await lanes.acquire('cron');
1974
1976
  // Build a wall-clock abort timer from maxHours / timeoutMs.
1975
1977
  // Whichever is shorter wins. Defaults to 1h if neither is set.
@@ -2010,6 +2012,7 @@ export class Gateway {
2010
2012
  pinnedSkills,
2011
2013
  allowedTools,
2012
2014
  allowedMcpServers,
2015
+ predictable,
2013
2016
  });
2014
2017
  scanner.refreshIntegrity();
2015
2018
  // Stash trick-capability metadata for the scheduler to read when
@@ -1025,20 +1025,21 @@ export function registerAdminTools(server) {
1025
1025
  return textResult(lines.join('\n\n'));
1026
1026
  });
1027
1027
  // ── Add Cron Job ────────────────────────────────────────────────────────
1028
- server.tool('add_cron_job', 'Add a new scheduled cron job ("trick"). Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change. The canonical SDK path runs every job through runAgentCron there is no separate "unleashed" mode anymore; the SDK handles compaction + multi-turn work natively up to maxBudgetUsd. CAPABILITIES: pin specific skills with `skills`, constrain tools with `allowed_tools`, and constrain MCP servers with `allowed_mcp_servers` so the trick has predictable behavior at fire time. Without these, the runtime auto-matches skills which can surprise the user.', {
1028
+ server.tool('add_cron_job', 'Add a new scheduled task. BEFORE CALLING THIS TOOL: propose the concrete plan to the user in chat and get explicit approval. The `prompt` you save should be SELF-CONTAINED list the actual recipients, the actual template/content, the actual criteria. AVOID vague references like "recent leads" or "this week\'s items" that the trick will re-derive at fire-time, because re-derivation reads from MEMORY.md which drifts between chat-time agreement and fire-time execution. Good prompt: "Send template `monday-followup` to alice@x.com, bob@y.com, carol@z.com." Bad prompt: "Send follow-up to recent leads." The default `predictable: true` mode runs the trick with ONLY the prompt + explicitly-attached skills/tools — no MEMORY.md, no team-comms injection, no runtime skill auto-match. Set `predictable: false` ONLY if the user explicitly wants a dynamic trick that re-resolves data each fire (e.g., "summarize yesterday\'s daily note" where the data legitimately changes).', {
1029
1029
  name: z.string().describe('Job name (unique identifier)'),
1030
1030
  schedule: z.string().describe('Cron expression (e.g., "0 9 * * 1" for Monday 9 AM)'),
1031
- prompt: z.string().describe('The prompt/instruction for the assistant to execute'),
1031
+ prompt: z.string().describe('The prompt/instruction for the assistant to execute. SHOULD BE CONCRETE — list actual recipients, criteria, content. Vague prompts re-derive at fire-time and cause "agent agreed in chat but emailed wrong people" failures.'),
1032
1032
  tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval). Tier 2+ also raises the per-run budget cap.'),
1033
1033
  enabled: z.boolean().optional().default(true).describe('Whether the job is enabled'),
1034
1034
  work_dir: z.string().optional().describe('Project directory to run in (agent gets access to project tools, CLAUDE.md, files)'),
1035
1035
  max_hours: z.number().optional().describe('Wall-clock cap in hours. Defaults to 1h. Run aborts via AbortSignal when exceeded.'),
1036
- skills: z.array(z.string()).optional().describe('Pinned skill slugs (filename minus .md, slashes flattened to dashes). Loaded BEFORE runtime auto-match. Total injected per run capped at 4. Pin skills here so the trick has predictable behavior — empty/omitted falls back to runtime auto-match.'),
1036
+ predictable: z.boolean().optional().default(true).describe('PREDICTABLE MODE (default true, recommended). When true, the runner runs with ONLY the prompt + pinned skills + criteria + linked goals + prior progress — MEMORY.md, team comms, delegation queue, and runtime skill auto-match are SKIPPED. This is the contract model: trick executes the plan you saved, not whatever memory says today. Set to false only when the user EXPLICITLY needs dynamic behavior — and tell them what that means.'),
1037
+ skills: z.array(z.string()).optional().describe('Pinned skill slugs (filename minus .md, slashes flattened to dashes). Loaded BEFORE runtime auto-match. Total injected per run capped at 4. In predictable mode, ONLY pinned skills load (no auto-match).'),
1037
1038
  allowed_tools: z.array(z.string()).optional().describe('Per-trick tool whitelist. When set, intersected with the agent profile allowlist. Agent is always force-included for sub-agent delegation. Empty/omitted inherits from profile.'),
1038
1039
  allowed_mcp_servers: z.array(z.string()).optional().describe('Per-trick MCP server whitelist (server names from list_mcp_servers). Applied AFTER profile allowlist. Empty/omitted inherits from profile.'),
1039
1040
  tags: z.array(z.string()).optional().describe('Free-form tags for grouping/filtering in the dashboard.'),
1040
1041
  category: z.string().optional().describe('Single category bucket (e.g. "ops", "research").'),
1041
- }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours, skills, allowed_tools, allowed_mcp_servers, tags, category }) => {
1042
+ }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours, predictable, skills, allowed_tools, allowed_mcp_servers, tags, category }) => {
1042
1043
  // Validate cron expression
1043
1044
  const cronMod = await import('node-cron');
1044
1045
  if (!cronMod.default.validate(schedule)) {
@@ -1076,6 +1077,9 @@ export function registerAdminTools(server) {
1076
1077
  newJob.work_dir = work_dir;
1077
1078
  if (max_hours)
1078
1079
  newJob.max_hours = max_hours;
1080
+ // Predictable mode: persist explicitly so behavior is locked. Default
1081
+ // for new chat-created tricks is true (contract execution).
1082
+ newJob.predictable = predictable !== false;
1079
1083
  // ── Trick capabilities (snake_case YAML keys) ──────────────────
1080
1084
  if (Array.isArray(skills) && skills.length)
1081
1085
  newJob.skills = skills.map(s => String(s).trim()).filter(Boolean);
@@ -1121,6 +1125,7 @@ export function registerAdminTools(server) {
1121
1125
  details.push(` Project: ${work_dir}`);
1122
1126
  if (max_hours)
1123
1127
  details.push(` Wall-clock cap: ${max_hours}h`);
1128
+ details.push(` Predictable mode: ${newJob.predictable ? 'ON — runs with only the prompt + pinned skills/tools (no MEMORY.md drift)' : 'OFF — runs with MEMORY.md + auto-matched skills (dynamic, may surprise)'}`);
1124
1129
  if (Array.isArray(skills) && skills.length)
1125
1130
  details.push(` Pinned skills: ${skills.join(', ')}`);
1126
1131
  if (Array.isArray(allowed_tools) && allowed_tools.length)
@@ -1138,7 +1143,7 @@ export function registerAdminTools(server) {
1138
1143
  return textResult(`Added cron job "${jobName}":\n${details.join('\n')}\n\n${verifyMsg}${goalHint}`);
1139
1144
  });
1140
1145
  // ── Update Cron Job ─────────────────────────────────────────────────────
1141
- server.tool('update_cron_job', 'Update an existing cron job in CRON.md. Partial — only fields you supply change. To CLEAR a capability allowlist (skills/allowed_tools/allowed_mcp_servers/tags), pass an empty array. To clear category, pass an empty string. The daemon auto-reloads on file change. Use preview_cron_job to confirm what will run before the next fire.', {
1146
+ server.tool('update_cron_job', 'Update an existing cron job in CRON.md. Partial — only fields you supply change. To CLEAR a capability allowlist (skills/allowed_tools/allowed_mcp_servers/tags), pass an empty array. To clear category, pass an empty string. The daemon auto-reloads on file change. Use preview_cron_job to confirm what will run before the next fire. ⚠ Flipping `predictable` from true to false changes whether the trick reads MEMORY.md at fire-time — make sure the user understands the tradeoff before you toggle it.', {
1142
1147
  name: z.string().describe('Existing job name to update.'),
1143
1148
  schedule: z.string().optional().describe('New cron expression.'),
1144
1149
  prompt: z.string().optional().describe('New prompt.'),
@@ -1146,12 +1151,13 @@ export function registerAdminTools(server) {
1146
1151
  enabled: z.boolean().optional().describe('Enable/disable.'),
1147
1152
  work_dir: z.string().optional().describe('Project directory. Empty string clears.'),
1148
1153
  max_hours: z.number().optional().describe('Wall-clock cap in hours.'),
1154
+ predictable: z.boolean().optional().describe('Predictable (contract) mode. true = runner skips MEMORY.md / team comms / auto-matched skills, runs ONLY with the prompt + pinned items. false = legacy injects-everything mode (memory drift risk). Tell the user what they\'re opting into when flipping this.'),
1149
1155
  skills: z.array(z.string()).optional().describe('Pinned skill slugs. Empty array clears.'),
1150
1156
  allowed_tools: z.array(z.string()).optional().describe('Tool allowlist. Empty array clears.'),
1151
1157
  allowed_mcp_servers: z.array(z.string()).optional().describe('MCP allowlist. Empty array clears.'),
1152
1158
  tags: z.array(z.string()).optional().describe('Tags. Empty array clears.'),
1153
1159
  category: z.string().optional().describe('Category bucket. Empty string clears.'),
1154
- }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours, skills, allowed_tools, allowed_mcp_servers, tags, category }) => {
1160
+ }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours, predictable, skills, allowed_tools, allowed_mcp_servers, tags, category }) => {
1155
1161
  if (!existsSync(CRON_FILE)) {
1156
1162
  return textResult('CRON.md not found. Use add_cron_job to create one first.');
1157
1163
  }
@@ -1202,6 +1208,10 @@ export function registerAdminTools(server) {
1202
1208
  job.max_hours = max_hours;
1203
1209
  changed.push(`max_hours → ${max_hours}`);
1204
1210
  }
1211
+ if (predictable !== undefined) {
1212
+ job.predictable = predictable;
1213
+ changed.push(`predictable → ${predictable ? 'ON (contract mode — only what\'s attached)' : 'OFF (dynamic — reads MEMORY.md, may drift)'}`);
1214
+ }
1205
1215
  // ── Capabilities — empty array CLEARS, omitted leaves alone ────
1206
1216
  if (skills !== undefined) {
1207
1217
  if (skills.length) {
@@ -1298,11 +1308,20 @@ export function registerAdminTools(server) {
1298
1308
  pinnedSkills: job.skills,
1299
1309
  allowedTools: job.allowedTools,
1300
1310
  allowedMcpServers: job.allowedMcpServers,
1311
+ predictable: job.predictable,
1301
1312
  });
1302
1313
  const allServers = discoverMcpServers();
1303
1314
  const lines = [];
1304
1315
  lines.push(`# Preview: ${job.name}`);
1305
1316
  lines.push('');
1317
+ // Verdict line — the headline visibility win for the user.
1318
+ if (plan.predictable) {
1319
+ lines.push(`✓ **Predictable** — what you see here is exactly what will run. No MEMORY.md, no team activity, no auto-matched skills injected at fire-time. Pure contract execution.`);
1320
+ }
1321
+ else {
1322
+ lines.push(`⚠ **Reads memory at fire-time** — this trick is in dynamic mode. At fire-time, the runner ALSO injects MEMORY.md, recent team comms, delegation queue, and auto-matched skills. The agent's output may differ from this preview if those have drifted since chat-time. Set \`predictable: true\` if that's not what you want.`);
1323
+ }
1324
+ lines.push('');
1306
1325
  lines.push(`**Schedule:** ${job.schedule} **Tier:** ${plan.tier} (${plan.effort}${plan.maxBudgetUsd ? `, budget $${plan.maxBudgetUsd}` : ''})`);
1307
1326
  if (job.agentSlug)
1308
1327
  lines.push(`**Agent:** ${job.agentSlug}`);
package/dist/types.d.ts CHANGED
@@ -354,6 +354,22 @@ export interface CronJobDefinition {
354
354
  /** Single category bucket — convenience for default grouping in the
355
355
  * dashboard (e.g. "ops", "research", "morning"). */
356
356
  category?: string;
357
+ /**
358
+ * Predictable mode (the "contract" model) — runs the trick with ONLY
359
+ * the prompt + explicitly-attached skills/criteria/goals + tools. Skips
360
+ * MEMORY.md injection, auto-matched skills, team comms, and delegation
361
+ * queue. The fix for "agent said OK in chat then fired with stale memory."
362
+ *
363
+ * - undefined / false: legacy behavior — runner injects everything
364
+ * (MEMORY.md, auto-matched skills, team activity, delegation). What
365
+ * chat-style autonomous work needs, but contaminates scheduled tasks.
366
+ * - true: contract mode — runner only includes what was explicitly
367
+ * attached. The trick executes the plan you saw in chat, nothing more.
368
+ *
369
+ * `add_cron_job` defaults this to `true` for new chat-created tricks.
370
+ * Existing tricks (no field set) keep current behavior — backward compat.
371
+ */
372
+ predictable?: boolean;
357
373
  }
358
374
  export type LongTaskRisk = 'normal' | 'long' | 'huge' | 'unsafe';
359
375
  export type LongTaskRoute = 'standard' | 'checkpointed' | 'opus_1m' | 'sonnet_1m' | 'split_required';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.67",
3
+ "version": "1.18.68",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",