clementine-agent 1.1.1 → 1.1.3

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.
@@ -24,7 +24,15 @@ export interface ReflectionEntry {
24
24
  gap: string;
25
25
  commNote: string;
26
26
  }
27
+ export type AdvisorRulesMode = 'off' | 'shadow' | 'primary';
27
28
  export declare function getExecutionAdvice(jobName: string, job: CronJobDefinition): ExecutionAdvice;
29
+ /**
30
+ * Mode-parameterized variant of getExecutionAdvice. Public so tests can
31
+ * exercise primary mode without mutating module-level env state.
32
+ */
33
+ export declare function getExecutionAdviceWithMode(jobName: string, job: CronJobDefinition, mode: AdvisorRulesMode): ExecutionAdvice;
34
+ /** Test-only: clear the rule-init flag so subsequent calls re-init. */
35
+ export declare function _resetAdvisorRulesInit(): void;
28
36
  export declare function checkTurnLimitHits(runs: ReturnType<CronRunLog['readRecent']>, job: CronJobDefinition, advice: ExecutionAdvice): void;
29
37
  export declare function checkReflectionQuality(reflections: ReflectionEntry[], job: CronJobDefinition, advice: ExecutionAdvice): void;
30
38
  export declare function checkTimeoutHits(runs: ReturnType<CronRunLog['readRecent']>, job: CronJobDefinition, advice: ExecutionAdvice): void;
@@ -11,8 +11,12 @@ import pino from 'pino';
11
11
  import { ADVISOR_RULES_LOADER, CRON_REFLECTIONS_DIR, ADVISOR_LOG_PATH } from '../config.js';
12
12
  import { CronRunLog } from '../gateway/heartbeat.js';
13
13
  import { evolvePrompt } from './prompt-evolver.js';
14
+ import { loadAdvisorRules, getLoadedRules, watchUserRulesDir, } from './advisor-rules/loader.js';
15
+ import { buildRuleContext } from './advisor-rules/context.js';
16
+ import { applyRules } from './advisor-rules/engine.js';
14
17
  const logger = pino({ name: 'clementine.execution-advisor' });
15
18
  const shadowLogger = pino({ name: 'clementine.advisor-rules-shadow' });
19
+ const primaryLogger = pino({ name: 'clementine.advisor-rules-primary' });
16
20
  // ── Tier caps for maxTurns ──────────────────────────────────────────
17
21
  export const TIER_MAX_TURNS = {
18
22
  1: 15,
@@ -22,8 +26,32 @@ export const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
22
26
  export const MAX_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes
23
27
  export const CIRCUIT_BREAKER_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour between retry probes
24
28
  export const DEFAULT_MAX_TURNS_FALLBACK = 5; // when job.maxTurns is unset
25
- // ── Core function ───────────────────────────────────────────────────
26
29
  export function getExecutionAdvice(jobName, job) {
30
+ return getExecutionAdviceWithMode(jobName, job, ADVISOR_RULES_LOADER);
31
+ }
32
+ /**
33
+ * Mode-parameterized variant of getExecutionAdvice. Public so tests can
34
+ * exercise primary mode without mutating module-level env state.
35
+ */
36
+ export function getExecutionAdviceWithMode(jobName, job, mode) {
37
+ // Primary mode: rule engine is the source of truth. Falls through to the
38
+ // legacy TS path only if the loader is unavailable for some reason.
39
+ if (mode === 'primary') {
40
+ if (ensureRulesInitialized()) {
41
+ return computePrimaryAdvice(jobName, job);
42
+ }
43
+ primaryLogger.warn({ jobName }, 'Primary rule engine unavailable — falling back to legacy TS path');
44
+ }
45
+ const advice = computeLegacyAdvice(jobName, job);
46
+ // Shadow mode: run the YAML rule engine on the same job, log any divergence
47
+ // from the legacy TS advice. Non-throwing — never affects the returned advice.
48
+ if (mode === 'shadow' && ensureRulesInitialized()) {
49
+ runShadowComparison(jobName, job, advice);
50
+ }
51
+ return advice;
52
+ }
53
+ // ── Legacy TS path (kept as fallback for primary mode) ──────────────
54
+ function computeLegacyAdvice(jobName, job) {
27
55
  const advice = {
28
56
  adjustedMaxTurns: null,
29
57
  adjustedModel: null,
@@ -101,67 +129,65 @@ export function getExecutionAdvice(jobName, job) {
101
129
  catch (err) {
102
130
  logger.warn({ err, job: jobName }, 'Execution advisor error — proceeding with defaults');
103
131
  }
104
- // Shadow mode: run the YAML rule engine on the same job, log any divergence
105
- // from the legacy TS advice. Non-throwing — never affects the returned advice.
106
- if (ADVISOR_RULES_LOADER === 'shadow') {
107
- runShadowComparison(jobName, job, advice);
108
- }
109
132
  return advice;
110
133
  }
111
- // ── Shadow-mode comparison ──────────────────────────────────────────
112
- let shadowInitialized = false;
113
- let shadowAvailable = false;
114
- let shadowDeps = null;
115
- async function ensureShadowInitialized() {
116
- if (shadowInitialized)
117
- return;
118
- shadowInitialized = true;
134
+ // ── Rule-engine path ────────────────────────────────────────────────
135
+ let rulesInitialized = false;
136
+ let rulesAvailable = false;
137
+ /** Sync init for the rule loader. Idempotent. Safe to call from any mode. */
138
+ function ensureRulesInitialized() {
139
+ if (rulesInitialized)
140
+ return rulesAvailable;
141
+ rulesInitialized = true;
119
142
  try {
120
- const [loaderMod, contextMod, engineMod] = await Promise.all([
121
- import('./advisor-rules/loader.js'),
122
- import('./advisor-rules/context.js'),
123
- import('./advisor-rules/engine.js'),
124
- ]);
125
- shadowDeps = {
126
- loadAdvisorRules: loaderMod.loadAdvisorRules,
127
- getLoadedRules: loaderMod.getLoadedRules,
128
- watchUserRulesDir: loaderMod.watchUserRulesDir,
129
- buildRuleContext: contextMod.buildRuleContext,
130
- applyRules: engineMod.applyRules,
131
- };
132
- shadowDeps.loadAdvisorRules();
133
- shadowDeps.watchUserRulesDir();
134
- shadowAvailable = true;
135
- shadowLogger.info('Advisor rules shadow mode initialized');
143
+ loadAdvisorRules();
144
+ watchUserRulesDir();
145
+ rulesAvailable = true;
146
+ primaryLogger.info({ ruleCount: getLoadedRules().length }, 'Advisor rules initialized');
147
+ }
148
+ catch (err) {
149
+ primaryLogger.warn({ err }, 'Failed to initialize advisor rules — TS path will be used');
150
+ rulesAvailable = false;
151
+ }
152
+ return rulesAvailable;
153
+ }
154
+ function computePrimaryAdvice(jobName, job) {
155
+ try {
156
+ const rules = getLoadedRules();
157
+ const ctx = buildRuleContext(jobName, job);
158
+ const { advice, traces } = applyRules(rules, ctx);
159
+ const fired = traces.filter(t => t.fired).map(t => t.ruleId);
160
+ if (fired.length > 0) {
161
+ primaryLogger.debug({ jobName, firedRules: fired }, 'Rule engine produced advice');
162
+ }
163
+ return advice;
136
164
  }
137
165
  catch (err) {
138
- shadowLogger.warn({ err }, 'Failed to initialize advisor rules shadow mode');
166
+ primaryLogger.warn({ err, jobName }, 'Rule engine threw — falling back to legacy TS path for this call');
167
+ return computeLegacyAdvice(jobName, job);
139
168
  }
140
169
  }
141
170
  function runShadowComparison(jobName, job, tsAdvice) {
142
- // Fire-and-forget: kicks off async init the first time, then runs comparison
143
- // synchronously on subsequent calls. Never throws.
144
- ensureShadowInitialized()
145
- .then(() => {
146
- if (!shadowAvailable || !shadowDeps)
147
- return;
148
- try {
149
- const rules = shadowDeps.getLoadedRules();
150
- const ctx = shadowDeps.buildRuleContext(jobName, job);
151
- const { advice: yamlAdvice, traces } = shadowDeps.applyRules(rules, ctx);
152
- const diffs = diffAdvice(tsAdvice, yamlAdvice);
153
- if (diffs.length > 0) {
154
- shadowLogger.warn({ jobName, diffs, firedRules: traces.filter(t => t.fired).map(t => t.ruleId) }, 'Shadow advisor diverged from TS path');
155
- }
156
- else {
157
- shadowLogger.debug({ jobName, firedRules: traces.filter(t => t.fired).map(t => t.ruleId) }, 'Shadow advisor matches TS path');
158
- }
171
+ try {
172
+ const rules = getLoadedRules();
173
+ const ctx = buildRuleContext(jobName, job);
174
+ const { advice: yamlAdvice, traces } = applyRules(rules, ctx);
175
+ const diffs = diffAdvice(tsAdvice, yamlAdvice);
176
+ if (diffs.length > 0) {
177
+ shadowLogger.warn({ jobName, diffs, firedRules: traces.filter(t => t.fired).map(t => t.ruleId) }, 'Shadow advisor diverged from TS path');
159
178
  }
160
- catch (err) {
161
- shadowLogger.warn({ err, jobName }, 'Shadow advisor run failed');
179
+ else {
180
+ shadowLogger.debug({ jobName, firedRules: traces.filter(t => t.fired).map(t => t.ruleId) }, 'Shadow advisor matches TS path');
162
181
  }
163
- })
164
- .catch(() => { });
182
+ }
183
+ catch (err) {
184
+ shadowLogger.warn({ err, jobName }, 'Shadow advisor run failed');
185
+ }
186
+ }
187
+ /** Test-only: clear the rule-init flag so subsequent calls re-init. */
188
+ export function _resetAdvisorRulesInit() {
189
+ rulesInitialized = false;
190
+ rulesAvailable = false;
165
191
  }
166
192
  function diffAdvice(a, b) {
167
193
  const fields = [
@@ -7124,6 +7124,23 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7124
7124
  res.json({ byType: {}, totalOutcomes: 0 });
7125
7125
  }
7126
7126
  });
7127
+ app.get('/api/advisor/status', async (_req, res) => {
7128
+ try {
7129
+ const { ADVISOR_RULES_LOADER } = await import('../config.js');
7130
+ const { loadAdvisorRules } = await import('../agent/advisor-rules/loader.js');
7131
+ const rules = loadAdvisorRules();
7132
+ const builtinCount = rules.filter(r => r._sourcePath?.includes('/builtin/')).length;
7133
+ res.json({
7134
+ mode: ADVISOR_RULES_LOADER,
7135
+ ruleCount: rules.length,
7136
+ builtinCount,
7137
+ userCount: rules.length - builtinCount,
7138
+ });
7139
+ }
7140
+ catch (err) {
7141
+ res.json({ mode: 'off', ruleCount: 0, builtinCount: 0, userCount: 0, error: String(err) });
7142
+ }
7143
+ });
7127
7144
  // ── Remote access API ────────────────────────────────────────────
7128
7145
  app.get('/api/remote-access', (_req, res) => {
7129
7146
  const config = loadRemoteConfig();
@@ -19927,12 +19944,34 @@ async function refreshHomeSessions() {
19927
19944
  // ── Execution Analytics ───────────────────
19928
19945
  async function refreshAdvisorAnalytics() {
19929
19946
  try {
19930
- const r = await apiFetch('/api/advisor/analytics');
19947
+ const [statusR, r] = await Promise.all([
19948
+ apiFetch('/api/advisor/status'),
19949
+ apiFetch('/api/advisor/analytics'),
19950
+ ]);
19951
+ const status = await statusR.json();
19931
19952
  const data = await r.json();
19932
19953
  const container = document.getElementById('advisor-analytics-content');
19933
19954
 
19934
19955
  let html = '';
19935
19956
 
19957
+ // Mode chip — surfaces the active rule-engine mode at a glance.
19958
+ const modeColors = { off: 'var(--gray)', shadow: 'var(--blue)', primary: 'var(--green)' };
19959
+ const modeColor = modeColors[status.mode] || 'var(--gray)';
19960
+ const modeHints = {
19961
+ off: 'Legacy TS path is the source of truth.',
19962
+ shadow: 'Rule engine runs alongside the TS path; divergences are logged.',
19963
+ primary: 'Rule engine is the source of truth.',
19964
+ };
19965
+ const modeHint = modeHints[status.mode] || '';
19966
+ html += '<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:10px 14px;background:var(--bg-elev);border-radius:8px;border-left:3px solid ' + modeColor + '">';
19967
+ html += '<span style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:var(--text-dim)">Advisor mode</span>';
19968
+ html += '<span style="font-weight:600;color:' + modeColor + '">' + esc(status.mode) + '</span>';
19969
+ html += '<span style="font-size:12px;color:var(--text-dim)">' + esc(modeHint) + '</span>';
19970
+ html += '<span style="margin-left:auto;font-size:11px;color:var(--text-dim)">' + status.ruleCount + ' rule' + (status.ruleCount === 1 ? '' : 's') + ' loaded';
19971
+ if (status.userCount > 0) html += ' (' + status.userCount + ' user)';
19972
+ html += '</span>';
19973
+ html += '</div>';
19974
+
19936
19975
  // Summary cards row
19937
19976
  html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px">';
19938
19977
  const stats = [