clementine-agent 1.1.0 → 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.
- package/dist/agent/execution-advisor.d.ts +8 -0
- package/dist/agent/execution-advisor.js +78 -52
- package/dist/cli/dashboard.js +40 -1
- package/dist/cli/index.js +325 -3
- package/dist/config/clementine-json.d.ts +64 -0
- package/dist/config/clementine-json.js +123 -0
- package/dist/config/config-doctor.d.ts +38 -0
- package/dist/config/config-doctor.js +270 -0
- package/dist/config/effective-config.d.ts +40 -0
- package/dist/config/effective-config.js +213 -0
- package/dist/config/migrate-keychain.d.ts +55 -0
- package/dist/config/migrate-keychain.js +144 -0
- package/dist/config.d.ts +5 -3
- package/dist/config.js +95 -25
- package/dist/secrets/sensitivity.d.ts +18 -0
- package/dist/secrets/sensitivity.js +43 -0
- package/dist/tools/admin-tools.js +8 -3
- package/dist/vault-migrations/0005-create-clementine-json.d.ts +14 -0
- package/dist/vault-migrations/0005-create-clementine-json.js +157 -0
- package/package.json +1 -1
|
@@ -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
|
-
// ──
|
|
112
|
-
let
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
return;
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
161
|
-
shadowLogger.
|
|
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
|
-
|
|
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 = [
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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')
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine JSON config loader.
|
|
3
|
+
*
|
|
4
|
+
* `~/.clementine/clementine.json` is the canonical user-editable config file.
|
|
5
|
+
* Each field is optional — missing values fall through to .env, then to
|
|
6
|
+
* compiled defaults. Precedence (highest first):
|
|
7
|
+
*
|
|
8
|
+
* 1. process.env (CI/runtime overrides)
|
|
9
|
+
* 2. ~/.clementine/.env (existing user-edited config)
|
|
10
|
+
* 3. ~/.clementine/clementine.json (this file)
|
|
11
|
+
* 4. Compiled defaults
|
|
12
|
+
*
|
|
13
|
+
* The file is created on first run by the 0005 migration (kind: 'config').
|
|
14
|
+
* Loader validates with zod and falls back gracefully on malformed input —
|
|
15
|
+
* a corrupt file is logged and treated as empty.
|
|
16
|
+
*
|
|
17
|
+
* Cached by mtime so subsequent reads are O(1) absent file changes.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
export declare const clementineJsonSchema: z.ZodObject<{
|
|
21
|
+
schemaVersion: z.ZodLiteral<1>;
|
|
22
|
+
ownerName: z.ZodOptional<z.ZodString>;
|
|
23
|
+
assistantName: z.ZodOptional<z.ZodString>;
|
|
24
|
+
timezone: z.ZodOptional<z.ZodString>;
|
|
25
|
+
models: z.ZodOptional<z.ZodObject<{
|
|
26
|
+
default: z.ZodOptional<z.ZodString>;
|
|
27
|
+
haiku: z.ZodOptional<z.ZodString>;
|
|
28
|
+
sonnet: z.ZodOptional<z.ZodString>;
|
|
29
|
+
opus: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$strip>>;
|
|
31
|
+
budgets: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
heartbeat: z.ZodOptional<z.ZodNumber>;
|
|
33
|
+
cronT1: z.ZodOptional<z.ZodNumber>;
|
|
34
|
+
cronT2: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
chat: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
37
|
+
heartbeat: z.ZodOptional<z.ZodObject<{
|
|
38
|
+
intervalMinutes: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
activeStart: z.ZodOptional<z.ZodNumber>;
|
|
40
|
+
activeEnd: z.ZodOptional<z.ZodNumber>;
|
|
41
|
+
}, z.core.$strip>>;
|
|
42
|
+
unleashed: z.ZodOptional<z.ZodObject<{
|
|
43
|
+
phaseTurns: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
defaultMaxHours: z.ZodOptional<z.ZodNumber>;
|
|
45
|
+
maxPhases: z.ZodOptional<z.ZodNumber>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
}, z.core.$strip>;
|
|
48
|
+
export type ClementineJson = z.infer<typeof clementineJsonSchema>;
|
|
49
|
+
export declare function clementineJsonPath(baseDir: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Load and validate clementine.json. Returns an empty object if the file
|
|
52
|
+
* is missing, unreadable, or fails validation. Cached by mtime.
|
|
53
|
+
*/
|
|
54
|
+
export declare function loadClementineJson(baseDir: string): ClementineJson;
|
|
55
|
+
/** Test-only: clear the loader cache. */
|
|
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;
|
|
64
|
+
//# sourceMappingURL=clementine-json.d.ts.map
|