clementine-agent 1.1.1 → 1.1.2

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 = [
package/dist/cli/index.js CHANGED
@@ -24,6 +24,7 @@ import { cmdCronList, cmdCronRun, cmdCronRunDue, cmdCronRuns, cmdCronAdd, cmdCro
24
24
  import { cmdDashboard } from './dashboard.js';
25
25
  import { cmdChat } from './chat.js';
26
26
  import { cmdIngestSeed, cmdIngestRun, cmdIngestList, cmdIngestStatus } from './ingest.js';
27
+ import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = path.dirname(__filename);
29
30
  // ── Path resolution ─────────────────────────────────────────────────
@@ -935,9 +936,7 @@ function cmdConfigList() {
935
936
  const match = line.match(/^([A-Z_]+)=(.+)$/);
936
937
  if (match) {
937
938
  const [, k, v] = match;
938
- const sensitiveKeys = ['TOKEN', 'SECRET', 'API_KEY', 'AUTH_TOKEN', 'SID'];
939
- const isSensitive = sensitiveKeys.some((s) => k.includes(s));
940
- if (isSensitive && v.length > 8) {
939
+ if (isSensitiveEnvKey(k) && v.length > 8) {
941
940
  console.log(` ${k}=${v.slice(0, 4)}${'*'.repeat(v.length - 8)}${v.slice(-4)}`);
942
941
  }
943
942
  else {
@@ -951,6 +950,294 @@ function cmdConfigList() {
951
950
  }
952
951
  console.log();
953
952
  }
953
+ // ── Config show ──────────────────────────────────────────────────────
954
+ async function cmdConfigShow(opts) {
955
+ const { computeEffectiveConfig } = await import('../config/effective-config.js');
956
+ const cfg = computeEffectiveConfig(BASE_DIR);
957
+ if (opts.json) {
958
+ console.log(JSON.stringify(cfg, null, 2));
959
+ return;
960
+ }
961
+ const DIM = '\x1b[0;90m';
962
+ const BOLD = '\x1b[1m';
963
+ const CYAN = '\x1b[0;36m';
964
+ const GREEN = '\x1b[0;32m';
965
+ const YELLOW = '\x1b[0;33m';
966
+ const BLUE = '\x1b[0;34m';
967
+ const RESET = '\x1b[0m';
968
+ const sourceColor = {
969
+ 'process.env': YELLOW,
970
+ '.env': GREEN,
971
+ 'clementine.json': CYAN,
972
+ 'system': BLUE,
973
+ 'default': DIM,
974
+ };
975
+ console.log();
976
+ console.log(` ${BOLD}Data home:${RESET} ${cfg.baseDir}`);
977
+ console.log(` ${BOLD}.env present:${RESET} ${cfg.hasEnvFile ? GREEN + 'yes' : DIM + 'no'}${RESET}`);
978
+ console.log(` ${BOLD}clementine.json:${RESET} ${cfg.hasJsonFile ? GREEN + 'present' : DIM + 'missing — defaults active'}${RESET}`);
979
+ console.log();
980
+ console.log(` ${DIM}Sources (highest precedence first):${RESET}`);
981
+ console.log(` ${YELLOW}process.env${RESET} runtime override`);
982
+ console.log(` ${GREEN}.env${RESET} ~/.clementine/.env`);
983
+ console.log(` ${CYAN}clementine.json${RESET} canonical user config`);
984
+ console.log(` ${BLUE}system${RESET} OS-derived default (e.g., timezone)`);
985
+ console.log(` ${DIM}default${RESET} compiled fallback`);
986
+ console.log();
987
+ // Group entries
988
+ const filtered = opts.group
989
+ ? cfg.entries.filter(e => e.group === opts.group)
990
+ : cfg.entries;
991
+ const byGroup = new Map();
992
+ for (const entry of filtered) {
993
+ const g = entry.group ?? 'misc';
994
+ if (!byGroup.has(g))
995
+ byGroup.set(g, []);
996
+ byGroup.get(g).push(entry);
997
+ }
998
+ if (filtered.length === 0) {
999
+ console.log(` ${DIM}No entries${opts.group ? ` in group "${opts.group}"` : ''}.${RESET}`);
1000
+ console.log();
1001
+ return;
1002
+ }
1003
+ // Column widths
1004
+ const keyWidth = Math.max(...filtered.map(e => e.key.length));
1005
+ const valueWidth = Math.max(...filtered.map(e => String(e.value).length), 12);
1006
+ const RED = '\x1b[0;31m';
1007
+ for (const [group, entries] of byGroup) {
1008
+ console.log(` ${BOLD}${group}${RESET}`);
1009
+ for (const entry of entries) {
1010
+ const c = sourceColor[entry.source] ?? RESET;
1011
+ const valueStr = String(entry.value);
1012
+ const annotations = [];
1013
+ if (entry.resolvedFrom === 'keychain')
1014
+ annotations.push(`${BLUE}via keychain${RESET}`);
1015
+ if (entry.unresolvedRef)
1016
+ annotations.push(`${RED}UNRESOLVED REF — using fallback${RESET}`);
1017
+ if (entry.shadowedBy && entry.shadowedBy.length > 0)
1018
+ annotations.push(`${DIM}shadows: ${entry.shadowedBy.join(', ')}${RESET}`);
1019
+ const annot = annotations.length > 0 ? ` ${DIM}(${RESET}${annotations.join(`${DIM},${RESET} `)}${DIM})${RESET}` : '';
1020
+ console.log(` ${entry.key.padEnd(keyWidth)} ${valueStr.padEnd(valueWidth)} ${c}${entry.source}${RESET}${annot}`);
1021
+ }
1022
+ console.log();
1023
+ }
1024
+ }
1025
+ // ── Config doctor ────────────────────────────────────────────────────
1026
+ async function cmdConfigDoctor(opts) {
1027
+ const { runDoctor } = await import('../config/config-doctor.js');
1028
+ const report = runDoctor(BASE_DIR);
1029
+ if (opts.json) {
1030
+ console.log(JSON.stringify(report, null, 2));
1031
+ process.exit(report.exitCode);
1032
+ }
1033
+ const DIM = '\x1b[0;90m';
1034
+ const BOLD = '\x1b[1m';
1035
+ const GREEN = '\x1b[0;32m';
1036
+ const YELLOW = '\x1b[0;33m';
1037
+ const RED = '\x1b[0;31m';
1038
+ const RESET = '\x1b[0m';
1039
+ const sevColor = { error: RED, warning: YELLOW, info: DIM };
1040
+ const sevSymbol = { error: '✗', warning: '⚠', info: '·' };
1041
+ console.log();
1042
+ console.log(` ${BOLD}Data home:${RESET} ${report.baseDir}`);
1043
+ console.log(` ${BOLD}.env present:${RESET} ${report.hasEnvFile ? GREEN + 'yes' : DIM + 'no'}${RESET}`);
1044
+ console.log(` ${BOLD}clementine.json:${RESET} ${report.hasJsonFile ? GREEN + 'present' : DIM + 'missing'}${RESET}`);
1045
+ console.log();
1046
+ if (report.findings.length === 0) {
1047
+ console.log(` ${GREEN}✓ All checks passed.${RESET}`);
1048
+ console.log();
1049
+ process.exit(0);
1050
+ }
1051
+ for (const f of report.findings) {
1052
+ const c = sevColor[f.severity];
1053
+ const sym = sevSymbol[f.severity];
1054
+ const keyTag = f.key ? `${BOLD}${f.key}${RESET} ${DIM}—${RESET} ` : '';
1055
+ console.log(` ${c}${sym}${RESET} ${keyTag}${f.message}`);
1056
+ if (f.fix) {
1057
+ console.log(` ${DIM}↳${RESET} ${f.fix}`);
1058
+ }
1059
+ }
1060
+ console.log();
1061
+ const summary = [];
1062
+ if (report.counts.error > 0)
1063
+ summary.push(`${RED}${report.counts.error} error${report.counts.error === 1 ? '' : 's'}${RESET}`);
1064
+ if (report.counts.warning > 0)
1065
+ summary.push(`${YELLOW}${report.counts.warning} warning${report.counts.warning === 1 ? '' : 's'}${RESET}`);
1066
+ if (report.counts.info > 0)
1067
+ summary.push(`${DIM}${report.counts.info} info${RESET}`);
1068
+ console.log(` ${summary.join(', ')}`);
1069
+ console.log();
1070
+ process.exit(report.exitCode);
1071
+ }
1072
+ // ── Config migrate-to-keychain ───────────────────────────────────────
1073
+ async function cmdConfigMigrateToKeychain(opts) {
1074
+ const { planMigration, applyMigration } = await import('../config/migrate-keychain.js');
1075
+ const DIM = '\x1b[0;90m';
1076
+ const BOLD = '\x1b[1m';
1077
+ const GREEN = '\x1b[0;32m';
1078
+ const YELLOW = '\x1b[0;33m';
1079
+ const RED = '\x1b[0;31m';
1080
+ const CYAN = '\x1b[0;36m';
1081
+ const RESET = '\x1b[0m';
1082
+ // Commander gives us either ['a', 'b'] or ['a,b'] depending on how the
1083
+ // user passed the flag — normalize.
1084
+ const only = opts.key
1085
+ ? opts.key.flatMap(k => k.split(',').map(s => s.trim()).filter(Boolean))
1086
+ : undefined;
1087
+ const plan = planMigration(BASE_DIR, only ? { only } : {});
1088
+ console.log();
1089
+ console.log(` ${BOLD}.env path:${RESET} ${plan.envPath}`);
1090
+ console.log();
1091
+ if (plan.candidates.length === 0) {
1092
+ console.log(` ${DIM}No env entries found (.env may be empty or missing).${RESET}`);
1093
+ console.log();
1094
+ return;
1095
+ }
1096
+ // Group by status for readable output
1097
+ const groups = {};
1098
+ for (const c of plan.candidates) {
1099
+ (groups[c.status] ??= []).push(c);
1100
+ }
1101
+ const renderGroup = (label, color, items) => {
1102
+ if (!items || items.length === 0)
1103
+ return;
1104
+ console.log(` ${color}${label}${RESET} ${DIM}(${items.length})${RESET}`);
1105
+ for (const c of items) {
1106
+ console.log(` ${c.key} ${DIM}(${c.valueLength} chars)${RESET}`);
1107
+ }
1108
+ console.log();
1109
+ };
1110
+ renderGroup('Will migrate to keychain', CYAN, groups.migrated);
1111
+ renderGroup('Already in keychain (skipped)', DIM, groups['already-keychain']);
1112
+ renderGroup('Not credential-shaped (skipped)', DIM, groups['not-sensitive']);
1113
+ renderGroup('Too short to be a credential (skipped)', DIM, groups['too-short']);
1114
+ if (plan.toMigrate.length === 0) {
1115
+ console.log(` ${GREEN}Nothing to migrate.${RESET}`);
1116
+ console.log();
1117
+ return;
1118
+ }
1119
+ if (opts.dryRun) {
1120
+ console.log(` ${YELLOW}Dry run — no changes written.${RESET}`);
1121
+ console.log(` ${DIM}Re-run without --dry-run to apply.${RESET}`);
1122
+ console.log();
1123
+ return;
1124
+ }
1125
+ console.log(` ${BOLD}Applying...${RESET}`);
1126
+ let result;
1127
+ try {
1128
+ result = applyMigration(BASE_DIR, only ? { only } : {});
1129
+ }
1130
+ catch (err) {
1131
+ console.error(` ${RED}Failed:${RESET} ${err.message}`);
1132
+ process.exit(1);
1133
+ }
1134
+ if (result.failed.length > 0) {
1135
+ console.log(` ${RED}Some keychain writes failed — .env was NOT modified:${RESET}`);
1136
+ for (const f of result.failed) {
1137
+ console.log(` ${RED}✗${RESET} ${f.key}: ${f.error}`);
1138
+ }
1139
+ console.log();
1140
+ process.exit(1);
1141
+ }
1142
+ for (const key of result.migrated) {
1143
+ console.log(` ${GREEN}✓${RESET} ${key} ${DIM}→ keychain${RESET}`);
1144
+ }
1145
+ console.log();
1146
+ console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'}.${RESET}`);
1147
+ console.log(` ${DIM}First daemon read of each ref will trigger a one-time keychain prompt;${RESET}`);
1148
+ console.log(` ${DIM}choose Always Allow to make the prompt permanent.${RESET}`);
1149
+ console.log();
1150
+ }
1151
+ // ── Advisor commands ────────────────────────────────────────────────
1152
+ const ADVISOR_MODES = ['off', 'shadow', 'primary'];
1153
+ function readAdvisorMode() {
1154
+ if (!existsSync(ENV_PATH))
1155
+ return 'off';
1156
+ const content = readFileSync(ENV_PATH, 'utf-8');
1157
+ const match = content.match(/^CLEMENTINE_ADVISOR_RULES_LOADER=(.*)$/m);
1158
+ if (!match)
1159
+ return 'off';
1160
+ const raw = match[1].trim().toLowerCase();
1161
+ if (raw === 'shadow' || raw === 'primary')
1162
+ return raw;
1163
+ return 'off';
1164
+ }
1165
+ async function cmdAdvisorStatus() {
1166
+ const DIM = '\x1b[0;90m';
1167
+ const BOLD = '\x1b[1m';
1168
+ const CYAN = '\x1b[0;36m';
1169
+ const YELLOW = '\x1b[0;33m';
1170
+ const GREEN = '\x1b[0;32m';
1171
+ const RESET = '\x1b[0m';
1172
+ const mode = readAdvisorMode();
1173
+ const modeColor = mode === 'primary' ? GREEN : mode === 'shadow' ? CYAN : DIM;
1174
+ console.log();
1175
+ console.log(` ${BOLD}Advisor mode:${RESET} ${modeColor}${mode}${RESET}`);
1176
+ if (mode === 'off') {
1177
+ console.log(` ${DIM}Legacy TS path is the source of truth. Rule engine not loaded.${RESET}`);
1178
+ }
1179
+ else if (mode === 'shadow') {
1180
+ console.log(` ${DIM}Rule engine runs alongside TS path; divergences logged but TS path wins.${RESET}`);
1181
+ }
1182
+ else {
1183
+ console.log(` ${DIM}Rule engine is the source of truth; TS path used only as fallback.${RESET}`);
1184
+ }
1185
+ // Load the rules from disk to show the user-visible inventory.
1186
+ try {
1187
+ const { loadAdvisorRules } = await import('../agent/advisor-rules/loader.js');
1188
+ const rules = loadAdvisorRules();
1189
+ const builtinCount = rules.filter(r => r._sourcePath?.includes('/builtin/')).length;
1190
+ const userCount = rules.length - builtinCount;
1191
+ console.log();
1192
+ console.log(` ${BOLD}Loaded rules:${RESET} ${rules.length} ${DIM}(${builtinCount} builtin, ${userCount} user)${RESET}`);
1193
+ }
1194
+ catch (err) {
1195
+ console.log(` ${YELLOW}Could not load rules:${RESET} ${err.message}`);
1196
+ }
1197
+ console.log();
1198
+ console.log(` ${DIM}Switch mode: clementine advisor mode <off|shadow|primary>${RESET}`);
1199
+ console.log(` ${DIM}List rules: clementine advisor rules${RESET}`);
1200
+ console.log();
1201
+ }
1202
+ function cmdAdvisorMode(mode) {
1203
+ const YELLOW = '\x1b[0;33m';
1204
+ const GREEN = '\x1b[0;32m';
1205
+ const RESET = '\x1b[0m';
1206
+ const lower = mode.toLowerCase();
1207
+ if (!ADVISOR_MODES.includes(lower)) {
1208
+ console.error(` Invalid mode "${mode}". Choose one of: ${ADVISOR_MODES.join(', ')}`);
1209
+ process.exit(1);
1210
+ }
1211
+ cmdConfigSet('CLEMENTINE_ADVISOR_RULES_LOADER', lower);
1212
+ console.log(` ${GREEN}Advisor mode set to ${lower}.${RESET}`);
1213
+ console.log(` ${YELLOW}Restart the daemon for the change to take effect:${RESET} clementine restart`);
1214
+ }
1215
+ async function cmdAdvisorRules() {
1216
+ const DIM = '\x1b[0;90m';
1217
+ const BOLD = '\x1b[1m';
1218
+ const CYAN = '\x1b[0;36m';
1219
+ const RESET = '\x1b[0m';
1220
+ try {
1221
+ const { loadAdvisorRules } = await import('../agent/advisor-rules/loader.js');
1222
+ const rules = loadAdvisorRules();
1223
+ if (rules.length === 0) {
1224
+ console.log(' No advisor rules loaded.');
1225
+ return;
1226
+ }
1227
+ console.log();
1228
+ console.log(` ${BOLD}${'PRI'.padEnd(5)}${'ID'.padEnd(38)}SOURCE DESCRIPTION${RESET}`);
1229
+ for (const r of rules) {
1230
+ const source = r._sourcePath?.includes('/builtin/') ? 'builtin' : 'user ';
1231
+ const desc = (r.description || '').slice(0, 60);
1232
+ console.log(` ${String(r.priority).padEnd(5)}${CYAN}${r.id.padEnd(38)}${RESET}${DIM}${source}${RESET} ${desc}`);
1233
+ }
1234
+ console.log();
1235
+ }
1236
+ catch (err) {
1237
+ console.error(` Error loading rules: ${err.message}`);
1238
+ process.exit(1);
1239
+ }
1240
+ }
954
1241
  // ── Tools command ───────────────────────────────────────────────────
955
1242
  function cmdTools() {
956
1243
  const DIM = '\x1b[0;90m';
@@ -1396,6 +1683,18 @@ program
1396
1683
  .command('tools')
1397
1684
  .description('List available MCP tools, plugins, and channels')
1398
1685
  .action(cmdTools);
1686
+ const advisorCmd = program
1687
+ .command('advisor')
1688
+ .description('Inspect and configure the execution advisor')
1689
+ .action(() => cmdAdvisorStatus());
1690
+ advisorCmd
1691
+ .command('mode <mode>')
1692
+ .description('Set advisor mode (off | shadow | primary) — restart required')
1693
+ .action(cmdAdvisorMode);
1694
+ advisorCmd
1695
+ .command('rules')
1696
+ .description('List loaded advisor rules')
1697
+ .action(cmdAdvisorRules);
1399
1698
  const dashCmd = program
1400
1699
  .command('dashboard')
1401
1700
  .description('Launch local command center')
@@ -1480,6 +1779,29 @@ configCmd
1480
1779
  .command('list')
1481
1780
  .description('List all config values')
1482
1781
  .action(cmdConfigList);
1782
+ configCmd
1783
+ .command('show')
1784
+ .description('Show effective config with provenance (env / json / default)')
1785
+ .option('--json', 'Emit machine-readable JSON instead of a table')
1786
+ .option('-g, --group <name>', 'Filter to a single group (e.g. budgets)')
1787
+ .action(async (opts) => {
1788
+ await cmdConfigShow(opts);
1789
+ });
1790
+ configCmd
1791
+ .command('doctor')
1792
+ .description('Validate config: stale keychain refs, type errors, missing channel deps')
1793
+ .option('--json', 'Emit machine-readable JSON instead of a checklist')
1794
+ .action(async (opts) => {
1795
+ await cmdConfigDoctor(opts);
1796
+ });
1797
+ configCmd
1798
+ .command('migrate-to-keychain')
1799
+ .description('Move plaintext credentials in .env into the macOS keychain (in place)')
1800
+ .option('--dry-run', 'Show what would migrate without writing anything')
1801
+ .option('-k, --key <name...>', 'Limit to specific key(s); repeat or comma-separate for multiple')
1802
+ .action(async (opts) => {
1803
+ await cmdConfigMigrateToKeychain(opts);
1804
+ });
1483
1805
  configCmd
1484
1806
  .command('edit')
1485
1807
  .description('Open .env in your editor')
@@ -54,4 +54,11 @@ export declare function clementineJsonPath(baseDir: string): string;
54
54
  export declare function loadClementineJson(baseDir: string): ClementineJson;
55
55
  /** Test-only: clear the loader cache. */
56
56
  export declare function _resetClementineJsonCache(): void;
57
+ /** String resolution. */
58
+ export declare function resolveString(envValue: string, jsonValue: string | undefined, fallback: string): string;
59
+ /**
60
+ * Numeric resolution. Env values that don't parse as finite numbers
61
+ * fall through to JSON, then the default — mirrors optionalTokenEnv tolerance.
62
+ */
63
+ export declare function resolveNumber(envValue: string, jsonValue: number | undefined, fallback: number): number;
57
64
  //# sourceMappingURL=clementine-json.d.ts.map
@@ -92,4 +92,32 @@ export function loadClementineJson(baseDir) {
92
92
  export function _resetClementineJsonCache() {
93
93
  cache.clear();
94
94
  }
95
+ // ── Resolution helpers (pure) ────────────────────────────────────────
96
+ //
97
+ // `getEnv` in config.ts feeds `envValue` here. Precedence is the env
98
+ // value (already process.env > .env merged by getEnv), then the JSON
99
+ // value, then the compiled default. Empty string from env is treated
100
+ // as unset to match the .env parser's "key with empty value" behavior.
101
+ /** String resolution. */
102
+ export function resolveString(envValue, jsonValue, fallback) {
103
+ if (envValue)
104
+ return envValue;
105
+ if (jsonValue)
106
+ return jsonValue;
107
+ return fallback;
108
+ }
109
+ /**
110
+ * Numeric resolution. Env values that don't parse as finite numbers
111
+ * fall through to JSON, then the default — mirrors optionalTokenEnv tolerance.
112
+ */
113
+ export function resolveNumber(envValue, jsonValue, fallback) {
114
+ if (envValue) {
115
+ const n = Number(envValue);
116
+ if (Number.isFinite(n))
117
+ return n;
118
+ }
119
+ if (jsonValue !== undefined)
120
+ return jsonValue;
121
+ return fallback;
122
+ }
95
123
  //# sourceMappingURL=clementine-json.js.map