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.
@@ -0,0 +1,123 @@
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 { existsSync, readFileSync, statSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import pino from 'pino';
22
+ import { z } from 'zod';
23
+ const logger = pino({ name: 'clementine.config-json' });
24
+ // ── Schema ───────────────────────────────────────────────────────────
25
+ export const clementineJsonSchema = z.object({
26
+ schemaVersion: z.literal(1),
27
+ ownerName: z.string().optional(),
28
+ assistantName: z.string().optional(),
29
+ timezone: z.string().optional(),
30
+ models: z.object({
31
+ default: z.string().optional(),
32
+ haiku: z.string().optional(),
33
+ sonnet: z.string().optional(),
34
+ opus: z.string().optional(),
35
+ }).optional(),
36
+ budgets: z.object({
37
+ heartbeat: z.number().nonnegative().optional(),
38
+ cronT1: z.number().nonnegative().optional(),
39
+ cronT2: z.number().nonnegative().optional(),
40
+ chat: z.number().nonnegative().optional(),
41
+ }).optional(),
42
+ heartbeat: z.object({
43
+ intervalMinutes: z.number().int().positive().optional(),
44
+ activeStart: z.number().int().min(0).max(23).optional(),
45
+ activeEnd: z.number().int().min(0).max(23).optional(),
46
+ }).optional(),
47
+ unleashed: z.object({
48
+ phaseTurns: z.number().int().positive().optional(),
49
+ defaultMaxHours: z.number().positive().optional(),
50
+ maxPhases: z.number().int().positive().optional(),
51
+ }).optional(),
52
+ });
53
+ const cache = new Map();
54
+ export function clementineJsonPath(baseDir) {
55
+ return path.join(baseDir, 'clementine.json');
56
+ }
57
+ /**
58
+ * Load and validate clementine.json. Returns an empty object if the file
59
+ * is missing, unreadable, or fails validation. Cached by mtime.
60
+ */
61
+ export function loadClementineJson(baseDir) {
62
+ const filePath = clementineJsonPath(baseDir);
63
+ if (!existsSync(filePath))
64
+ return { schemaVersion: 1 };
65
+ let mtime;
66
+ try {
67
+ mtime = statSync(filePath).mtimeMs;
68
+ }
69
+ catch {
70
+ return { schemaVersion: 1 };
71
+ }
72
+ const cached = cache.get(filePath);
73
+ if (cached && cached.mtime === mtime)
74
+ return cached.data;
75
+ let raw;
76
+ try {
77
+ raw = JSON.parse(readFileSync(filePath, 'utf-8'));
78
+ }
79
+ catch (err) {
80
+ logger.warn({ err, filePath }, 'Failed to parse clementine.json — using empty config');
81
+ return { schemaVersion: 1 };
82
+ }
83
+ const parsed = clementineJsonSchema.safeParse(raw);
84
+ if (!parsed.success) {
85
+ logger.warn({ filePath, errors: parsed.error.issues.slice(0, 3) }, 'clementine.json failed validation — using empty config');
86
+ return { schemaVersion: 1 };
87
+ }
88
+ cache.set(filePath, { mtime, data: parsed.data });
89
+ return parsed.data;
90
+ }
91
+ /** Test-only: clear the loader cache. */
92
+ export function _resetClementineJsonCache() {
93
+ cache.clear();
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
+ }
123
+ //# sourceMappingURL=clementine-json.js.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Config doctor — proactive validation of ~/.clementine/.
3
+ *
4
+ * Runs a series of checks against the effective config (env + clementine.json
5
+ * + defaults) and surfaces issues that would otherwise silently degrade the
6
+ * daemon. Each check produces a Finding {severity, key, message, fix}.
7
+ *
8
+ * Severity:
9
+ * error — daemon is misconfigured in a way that affects behavior
10
+ * warning — works, but bad practice or fragile
11
+ * info — note worth surfacing, not actionable
12
+ *
13
+ * Pure: no module-level side effects, no shell calls beyond what
14
+ * computeEffectiveConfig already does (lazy keychain resolution).
15
+ */
16
+ export type Severity = 'error' | 'warning' | 'info';
17
+ export interface Finding {
18
+ severity: Severity;
19
+ key?: string;
20
+ message: string;
21
+ /** Suggested next-step command or action. */
22
+ fix?: string;
23
+ }
24
+ export interface DoctorReport {
25
+ baseDir: string;
26
+ hasEnvFile: boolean;
27
+ hasJsonFile: boolean;
28
+ findings: Finding[];
29
+ /** Counts by severity, for quick rendering. */
30
+ counts: Record<Severity, number>;
31
+ /** Suggested process exit code: 1 if any errors, 0 otherwise. */
32
+ exitCode: 0 | 1;
33
+ }
34
+ export declare function runDoctor(baseDir: string): DoctorReport;
35
+ /** Keys-list helper for tests. */
36
+ export declare function listNumericKeys(): readonly string[];
37
+ export declare function listEnumKeys(): readonly string[];
38
+ //# sourceMappingURL=config-doctor.d.ts.map
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Config doctor — proactive validation of ~/.clementine/.
3
+ *
4
+ * Runs a series of checks against the effective config (env + clementine.json
5
+ * + defaults) and surfaces issues that would otherwise silently degrade the
6
+ * daemon. Each check produces a Finding {severity, key, message, fix}.
7
+ *
8
+ * Severity:
9
+ * error — daemon is misconfigured in a way that affects behavior
10
+ * warning — works, but bad practice or fragile
11
+ * info — note worth surfacing, not actionable
12
+ *
13
+ * Pure: no module-level side effects, no shell calls beyond what
14
+ * computeEffectiveConfig already does (lazy keychain resolution).
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { computeEffectiveConfig } from './effective-config.js';
19
+ import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
20
+ // ── Type expectations ───────────────────────────────────────────────
21
+ //
22
+ // Keys that must parse as a finite number when set. The inspector already
23
+ // handles default fallback for missing values, so we only fail on present-
24
+ // but-wrong values.
25
+ const NUMERIC_KEYS = new Set([
26
+ 'BUDGET_HEARTBEAT_USD',
27
+ 'BUDGET_CRON_T1_USD',
28
+ 'BUDGET_CRON_T2_USD',
29
+ 'BUDGET_CHAT_USD',
30
+ 'HEARTBEAT_INTERVAL_MINUTES',
31
+ 'HEARTBEAT_ACTIVE_START',
32
+ 'HEARTBEAT_ACTIVE_END',
33
+ 'UNLEASHED_PHASE_TURNS',
34
+ 'UNLEASHED_DEFAULT_MAX_HOURS',
35
+ 'UNLEASHED_MAX_PHASES',
36
+ 'WEBHOOK_PORT',
37
+ ]);
38
+ const ENUM_KEYS = {
39
+ CLEMENTINE_ADVISOR_RULES_LOADER: ['off', 'shadow', 'primary'],
40
+ DEFAULT_MODEL_TIER: ['haiku', 'sonnet', 'opus'],
41
+ WEBHOOK_ENABLED: ['true', 'false'],
42
+ ALLOW_ALL_USERS: ['true', 'false'],
43
+ CLEMENTINE_ALLOW_SOURCE_EDITS: ['true', 'false', '1', '0', 'yes', 'no'],
44
+ };
45
+ const CHANNEL_REQUIREMENTS = [
46
+ {
47
+ channel: 'Discord',
48
+ // Discord is implicit: presence of DISCORD_OWNER_ID != "0" implies usage.
49
+ enableKey: 'DISCORD_OWNER_ID',
50
+ enableValuePredicate: (v) => v !== '0' && v !== '',
51
+ requires: ['DISCORD_OWNER_ID'],
52
+ },
53
+ {
54
+ channel: 'Webhook',
55
+ enableKey: 'WEBHOOK_ENABLED',
56
+ enableValuePredicate: (v) => v.toLowerCase() === 'true',
57
+ requires: ['WEBHOOK_PORT', 'WEBHOOK_BIND'],
58
+ },
59
+ ];
60
+ // ── Doctor ──────────────────────────────────────────────────────────
61
+ export function runDoctor(baseDir) {
62
+ const cfg = computeEffectiveConfig(baseDir);
63
+ const findings = [];
64
+ checkBootstrap(cfg, findings);
65
+ checkUnresolvedKeychainRefs(cfg, findings);
66
+ checkNumericTypes(cfg, findings);
67
+ checkEnumTypes(cfg, findings);
68
+ checkChannelRequirements(cfg, findings);
69
+ checkPlaintextSecretsInEnv(cfg, baseDir, findings);
70
+ checkRangeSanity(cfg, findings);
71
+ checkSchemaVersion(baseDir, findings);
72
+ const counts = { error: 0, warning: 0, info: 0 };
73
+ for (const f of findings)
74
+ counts[f.severity]++;
75
+ return {
76
+ baseDir: cfg.baseDir,
77
+ hasEnvFile: cfg.hasEnvFile,
78
+ hasJsonFile: cfg.hasJsonFile,
79
+ findings,
80
+ counts,
81
+ exitCode: counts.error > 0 ? 1 : 0,
82
+ };
83
+ }
84
+ function checkBootstrap(cfg, findings) {
85
+ if (!cfg.hasEnvFile && !cfg.hasJsonFile) {
86
+ findings.push({
87
+ severity: 'warning',
88
+ message: 'No .env or clementine.json found — running entirely on compiled defaults',
89
+ fix: 'clementine config setup',
90
+ });
91
+ }
92
+ }
93
+ function checkUnresolvedKeychainRefs(cfg, findings) {
94
+ for (const entry of cfg.entries) {
95
+ if (entry.unresolvedRef) {
96
+ findings.push({
97
+ severity: 'error',
98
+ key: entry.key,
99
+ message: `${entry.key} is set to ${entry.unresolvedRef} but the keychain entry is missing or unreadable. Daemon is silently using the default (${entry.value}).`,
100
+ fix: `clementine config set ${entry.key} <real value> # writes plaintext to .env, removing the stale ref`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ function checkNumericTypes(cfg, findings) {
106
+ for (const entry of cfg.entries) {
107
+ if (!NUMERIC_KEYS.has(entry.key))
108
+ continue;
109
+ if (entry.source === 'default' || entry.source === 'system')
110
+ continue;
111
+ const n = Number(entry.value);
112
+ if (!Number.isFinite(n)) {
113
+ findings.push({
114
+ severity: 'error',
115
+ key: entry.key,
116
+ message: `${entry.key} is "${entry.value}" (from ${entry.source}) — does not parse as a number. Numeric coercion silently produces NaN, which means downstream comparisons always fail.`,
117
+ fix: `clementine config set ${entry.key} <numeric value>`,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ function checkEnumTypes(cfg, findings) {
123
+ for (const entry of cfg.entries) {
124
+ const allowed = ENUM_KEYS[entry.key];
125
+ if (!allowed)
126
+ continue;
127
+ if (entry.source === 'default')
128
+ continue;
129
+ if (!allowed.includes(String(entry.value).toLowerCase())) {
130
+ findings.push({
131
+ severity: 'error',
132
+ key: entry.key,
133
+ message: `${entry.key} is "${entry.value}" (from ${entry.source}) — must be one of: ${allowed.join(', ')}. Daemon silently treats this as the first valid option.`,
134
+ fix: `clementine config set ${entry.key} <one of: ${allowed.join('|')}>`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ function checkChannelRequirements(cfg, findings) {
140
+ const byKey = new Map(cfg.entries.map(e => [e.key, e]));
141
+ for (const req of CHANNEL_REQUIREMENTS) {
142
+ const enableEntry = req.enableKey ? byKey.get(req.enableKey) : undefined;
143
+ if (!enableEntry)
144
+ continue;
145
+ const enableValue = String(enableEntry.value);
146
+ const enabled = req.enableValuePredicate
147
+ ? req.enableValuePredicate(enableValue)
148
+ : Boolean(enableValue);
149
+ if (!enabled)
150
+ continue;
151
+ for (const reqKey of req.requires) {
152
+ const entry = byKey.get(reqKey);
153
+ if (!entry)
154
+ continue;
155
+ const v = String(entry.value).trim();
156
+ if (!v || v === '0') {
157
+ findings.push({
158
+ severity: 'warning',
159
+ key: reqKey,
160
+ message: `${req.channel} channel is enabled but ${reqKey} is empty. Owner-only commands and notifications may misbehave.`,
161
+ fix: `clementine config set ${reqKey} <value>`,
162
+ });
163
+ }
164
+ }
165
+ }
166
+ }
167
+ function checkPlaintextSecretsInEnv(_cfg, baseDir, findings) {
168
+ // Scan .env directly for sensitive-looking keys that hold long plaintext
169
+ // values. Stops bot tokens from quietly sitting in .env when they should
170
+ // be in the keychain.
171
+ const envPath = path.join(baseDir, '.env');
172
+ if (!existsSync(envPath))
173
+ return;
174
+ let raw;
175
+ try {
176
+ raw = readFileSync(envPath, 'utf-8');
177
+ }
178
+ catch {
179
+ return;
180
+ }
181
+ for (const line of raw.split('\n')) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed || trimmed.startsWith('#'))
184
+ continue;
185
+ const eq = trimmed.indexOf('=');
186
+ if (eq === -1)
187
+ continue;
188
+ const key = trimmed.slice(0, eq);
189
+ const value = trimmed.slice(eq + 1);
190
+ if (!isSensitiveEnvKey(key))
191
+ continue;
192
+ if (value.startsWith('keychain:'))
193
+ continue; // already a ref — fine
194
+ if (value.length < 16)
195
+ continue; // probably a config-shaped value (port number, etc.)
196
+ findings.push({
197
+ severity: 'warning',
198
+ key,
199
+ message: `${key} is stored as plaintext in .env. Credential-shaped keys should live in the keychain on macOS.`,
200
+ fix: `# In a chat with Clementine: env_set ${key} <value> storage=auto (auto routes credentials to keychain)`,
201
+ });
202
+ }
203
+ }
204
+ function checkRangeSanity(cfg, findings) {
205
+ const byKey = new Map(cfg.entries.map(e => [e.key, e]));
206
+ const start = byKey.get('HEARTBEAT_ACTIVE_START');
207
+ const end = byKey.get('HEARTBEAT_ACTIVE_END');
208
+ if (start && end) {
209
+ const s = Number(start.value);
210
+ const e = Number(end.value);
211
+ if (Number.isFinite(s) && Number.isFinite(e)) {
212
+ if (s < 0 || s > 23) {
213
+ findings.push({ severity: 'error', key: 'HEARTBEAT_ACTIVE_START', message: `must be 0-23, got ${s}` });
214
+ }
215
+ if (e < 0 || e > 23) {
216
+ findings.push({ severity: 'error', key: 'HEARTBEAT_ACTIVE_END', message: `must be 0-23, got ${e}` });
217
+ }
218
+ if (Number.isFinite(s) && Number.isFinite(e) && s >= e) {
219
+ findings.push({
220
+ severity: 'warning',
221
+ message: `HEARTBEAT_ACTIVE_START (${s}) is not before HEARTBEAT_ACTIVE_END (${e}). Heartbeat will only run during a zero-length window.`,
222
+ fix: `clementine config set HEARTBEAT_ACTIVE_END <hour later than ${s}>`,
223
+ });
224
+ }
225
+ }
226
+ }
227
+ // Budget sanity: cronT1 should be <= cronT2 (T1 is cheap tier).
228
+ const t1 = byKey.get('BUDGET_CRON_T1_USD');
229
+ const t2 = byKey.get('BUDGET_CRON_T2_USD');
230
+ if (t1 && t2) {
231
+ const v1 = Number(t1.value);
232
+ const v2 = Number(t2.value);
233
+ if (Number.isFinite(v1) && Number.isFinite(v2) && v1 > v2) {
234
+ findings.push({
235
+ severity: 'warning',
236
+ message: `BUDGET_CRON_T1_USD ($${v1}) exceeds BUDGET_CRON_T2_USD ($${v2}). Tier 1 is the cheap tier — these are likely swapped.`,
237
+ });
238
+ }
239
+ }
240
+ }
241
+ function checkSchemaVersion(baseDir, findings) {
242
+ const jsonPath = path.join(baseDir, 'clementine.json');
243
+ if (!existsSync(jsonPath))
244
+ return;
245
+ try {
246
+ const raw = JSON.parse(readFileSync(jsonPath, 'utf-8'));
247
+ if (raw.schemaVersion !== 1) {
248
+ findings.push({
249
+ severity: 'error',
250
+ message: `clementine.json schemaVersion is ${String(raw.schemaVersion)}; expected 1. Loader is treating the whole file as empty.`,
251
+ fix: 'Set "schemaVersion": 1 in clementine.json',
252
+ });
253
+ }
254
+ }
255
+ catch {
256
+ findings.push({
257
+ severity: 'error',
258
+ message: 'clementine.json exists but is not valid JSON. Loader is treating the whole file as empty.',
259
+ fix: 'Repair the JSON syntax or delete the file (next start regenerates it from .env)',
260
+ });
261
+ }
262
+ }
263
+ /** Keys-list helper for tests. */
264
+ export function listNumericKeys() {
265
+ return Array.from(NUMERIC_KEYS);
266
+ }
267
+ export function listEnumKeys() {
268
+ return Object.keys(ENUM_KEYS);
269
+ }
270
+ //# sourceMappingURL=config-doctor.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Effective-config inspector.
3
+ *
4
+ * Re-reads the same sources config.ts uses (process.env, ~/.clementine/.env,
5
+ * ~/.clementine/clementine.json) and produces a structured report of every
6
+ * known key — value plus provenance.
7
+ *
8
+ * Pure: no module-level side effects. Safe to call from any CLI / dashboard
9
+ * surface without touching the running daemon's config snapshot.
10
+ *
11
+ * Mirrors the precedence: process.env > .env > clementine.json > compiled default.
12
+ */
13
+ export type ConfigSource = 'process.env' | '.env' | 'clementine.json' | 'default' | 'system';
14
+ export interface ConfigEntry {
15
+ key: string;
16
+ value: string | number | boolean;
17
+ source: ConfigSource;
18
+ /** Optional: which other source(s) had a non-empty value for this key (helps debug overrides). */
19
+ shadowedBy?: ConfigSource[];
20
+ /** Optional: human-readable section/group for the report. */
21
+ group?: string;
22
+ /** Set when the source held a `keychain:` ref that the inspector resolved on-the-fly. */
23
+ resolvedFrom?: 'keychain';
24
+ /** Set when the source held a keychain ref but resolution failed (no entry / denied). */
25
+ unresolvedRef?: string;
26
+ }
27
+ export interface EffectiveConfig {
28
+ baseDir: string;
29
+ hasEnvFile: boolean;
30
+ hasJsonFile: boolean;
31
+ entries: ConfigEntry[];
32
+ }
33
+ /**
34
+ * Compute the effective config: every known key with its resolved value
35
+ * and the source it came from. Optionally include sensitive entries unmasked.
36
+ */
37
+ export declare function computeEffectiveConfig(baseDir: string): EffectiveConfig;
38
+ /** Return the SPECS array — exported so tests can iterate keys without re-importing. */
39
+ export declare function listKnownConfigKeys(): readonly string[];
40
+ //# sourceMappingURL=effective-config.d.ts.map
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Effective-config inspector.
3
+ *
4
+ * Re-reads the same sources config.ts uses (process.env, ~/.clementine/.env,
5
+ * ~/.clementine/clementine.json) and produces a structured report of every
6
+ * known key — value plus provenance.
7
+ *
8
+ * Pure: no module-level side effects. Safe to call from any CLI / dashboard
9
+ * surface without touching the running daemon's config snapshot.
10
+ *
11
+ * Mirrors the precedence: process.env > .env > clementine.json > compiled default.
12
+ */
13
+ import { execSync } from 'node:child_process';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { loadClementineJson } from './clementine-json.js';
17
+ const SPECS = [
18
+ // Identity
19
+ { key: 'OWNER_NAME', group: 'identity', jsonPath: 'ownerName', default: '' },
20
+ { key: 'ASSISTANT_NAME', group: 'identity', jsonPath: 'assistantName', default: 'Clementine' },
21
+ { key: 'ASSISTANT_NICKNAME', group: 'identity', default: 'Clemmy' },
22
+ { key: 'TIMEZONE', group: 'identity', jsonPath: 'timezone', systemDefault: () => Intl.DateTimeFormat().resolvedOptions().timeZone },
23
+ // Models
24
+ { key: 'DEFAULT_MODEL_TIER', group: 'models', jsonPath: 'models.default', default: 'sonnet' },
25
+ { key: 'HAIKU_MODEL', group: 'models', jsonPath: 'models.haiku', default: 'claude-haiku-4-5-20251001' },
26
+ { key: 'SONNET_MODEL', group: 'models', jsonPath: 'models.sonnet', default: 'claude-sonnet-4-6' },
27
+ { key: 'OPUS_MODEL', group: 'models', jsonPath: 'models.opus', default: 'claude-opus-4-6' },
28
+ // Budgets
29
+ { key: 'BUDGET_HEARTBEAT_USD', group: 'budgets', jsonPath: 'budgets.heartbeat', default: 0.50 },
30
+ { key: 'BUDGET_CRON_T1_USD', group: 'budgets', jsonPath: 'budgets.cronT1', default: 2.00 },
31
+ { key: 'BUDGET_CRON_T2_USD', group: 'budgets', jsonPath: 'budgets.cronT2', default: 5.00 },
32
+ { key: 'BUDGET_CHAT_USD', group: 'budgets', jsonPath: 'budgets.chat', default: 5.00 },
33
+ // Heartbeat
34
+ { key: 'HEARTBEAT_INTERVAL_MINUTES', group: 'heartbeat', jsonPath: 'heartbeat.intervalMinutes', default: 30 },
35
+ { key: 'HEARTBEAT_ACTIVE_START', group: 'heartbeat', jsonPath: 'heartbeat.activeStart', default: 8 },
36
+ { key: 'HEARTBEAT_ACTIVE_END', group: 'heartbeat', jsonPath: 'heartbeat.activeEnd', default: 22 },
37
+ // Unleashed
38
+ { key: 'UNLEASHED_PHASE_TURNS', group: 'unleashed', jsonPath: 'unleashed.phaseTurns', default: 75 },
39
+ { key: 'UNLEASHED_DEFAULT_MAX_HOURS', group: 'unleashed', jsonPath: 'unleashed.defaultMaxHours', default: 6 },
40
+ { key: 'UNLEASHED_MAX_PHASES', group: 'unleashed', jsonPath: 'unleashed.maxPhases', default: 50 },
41
+ // Advisor
42
+ { key: 'CLEMENTINE_ADVISOR_RULES_LOADER', group: 'advisor', default: 'off' },
43
+ { key: 'CLEMENTINE_ALLOW_SOURCE_EDITS', group: 'advisor', default: 'false' },
44
+ // Webhook
45
+ { key: 'WEBHOOK_ENABLED', group: 'channels', default: 'false' },
46
+ { key: 'WEBHOOK_PORT', group: 'channels', default: 8420 },
47
+ { key: 'WEBHOOK_BIND', group: 'channels', default: '127.0.0.1' },
48
+ // Security
49
+ { key: 'ALLOW_ALL_USERS', group: 'security', default: 'false' },
50
+ // Discord / Slack / Telegram (presence-only — values themselves come from secrets)
51
+ { key: 'DISCORD_OWNER_ID', group: 'channels', default: '0' },
52
+ { key: 'SLACK_OWNER_USER_ID', group: 'channels', default: '' },
53
+ { key: 'TELEGRAM_OWNER_ID', group: 'channels', default: '0' },
54
+ { key: 'WHATSAPP_OWNER_PHONE', group: 'channels', default: '' },
55
+ // Salesforce
56
+ { key: 'SF_INSTANCE_URL', group: 'channels', default: '' },
57
+ { key: 'SF_API_VERSION', group: 'channels', default: 'v62.0' },
58
+ ];
59
+ // ── Keychain-ref resolution ──────────────────────────────────────────
60
+ // Mirrors config.ts's lazy/cached resolver but kept independent so this
61
+ // module stays a pure utility (no shared mutable state with the daemon).
62
+ const KEYCHAIN_REF_PREFIX = 'keychain:'; // pragma: allowlist secret
63
+ const refCache = new Map();
64
+ function shellEscape(s) {
65
+ return `'${s.replace(/'/g, "'\\''")}'`;
66
+ }
67
+ function resolveRef(stub) {
68
+ if (refCache.has(stub)) {
69
+ const v = refCache.get(stub);
70
+ return v ?? undefined;
71
+ }
72
+ const account = stub.slice(KEYCHAIN_REF_PREFIX.length);
73
+ try {
74
+ const result = execSync(`security find-generic-password -s clementine-agent -a ${shellEscape(account)} -w`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
75
+ refCache.set(stub, result || null);
76
+ return result || undefined;
77
+ }
78
+ catch {
79
+ refCache.set(stub, null);
80
+ return undefined;
81
+ }
82
+ }
83
+ function readEnvFile(baseDir) {
84
+ const envPath = path.join(baseDir, '.env');
85
+ if (!existsSync(envPath))
86
+ return {};
87
+ const result = {};
88
+ for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed || trimmed.startsWith('#'))
91
+ continue;
92
+ const eqIndex = trimmed.indexOf('=');
93
+ if (eqIndex === -1)
94
+ continue;
95
+ const key = trimmed.slice(0, eqIndex);
96
+ let value = trimmed.slice(eqIndex + 1);
97
+ if ((value.startsWith('"') && value.endsWith('"')) ||
98
+ (value.startsWith("'") && value.endsWith("'"))) {
99
+ value = value.slice(1, -1);
100
+ }
101
+ result[key] = value;
102
+ }
103
+ return result;
104
+ }
105
+ function getJsonValue(json, dottedPath) {
106
+ const parts = dottedPath.split('.');
107
+ let cur = json;
108
+ for (const p of parts) {
109
+ if (cur && typeof cur === 'object' && p in cur) {
110
+ cur = cur[p];
111
+ }
112
+ else {
113
+ return undefined;
114
+ }
115
+ }
116
+ return cur;
117
+ }
118
+ /**
119
+ * Compute the effective config: every known key with its resolved value
120
+ * and the source it came from. Optionally include sensitive entries unmasked.
121
+ */
122
+ export function computeEffectiveConfig(baseDir) {
123
+ const envFromFile = readEnvFile(baseDir);
124
+ const json = loadClementineJson(baseDir);
125
+ const entries = [];
126
+ const envPath = path.join(baseDir, '.env');
127
+ const jsonPath = path.join(baseDir, 'clementine.json');
128
+ for (const spec of SPECS) {
129
+ // Resolution order matches config.ts: process.env > .env > json > default.
130
+ const fromProcessEnv = process.env[spec.key];
131
+ const fromEnvFile = envFromFile[spec.key];
132
+ const fromJson = spec.jsonPath ? getJsonValue(json, spec.jsonPath) : undefined;
133
+ let value;
134
+ let source;
135
+ let resolvedFrom;
136
+ let unresolvedRef;
137
+ // Helper: if a string source holds a keychain ref, resolve it. On
138
+ // failure, return undefined so we fall through to the next source.
139
+ const consume = (raw) => {
140
+ if (!raw)
141
+ return null;
142
+ if (raw.startsWith(KEYCHAIN_REF_PREFIX)) {
143
+ const r = resolveRef(raw);
144
+ if (r !== undefined)
145
+ return { value: r, resolved: true };
146
+ return { unresolvedRef: raw };
147
+ }
148
+ return { value: raw, resolved: false };
149
+ };
150
+ const procResult = consume(fromProcessEnv);
151
+ const envResult = consume(fromEnvFile);
152
+ if (procResult && 'value' in procResult) {
153
+ value = procResult.value;
154
+ source = 'process.env';
155
+ if (procResult.resolved)
156
+ resolvedFrom = 'keychain';
157
+ }
158
+ else if (envResult && 'value' in envResult) {
159
+ value = envResult.value;
160
+ source = '.env';
161
+ if (envResult.resolved)
162
+ resolvedFrom = 'keychain';
163
+ }
164
+ else if (fromJson !== undefined && fromJson !== '' && fromJson !== null) {
165
+ value = fromJson;
166
+ source = 'clementine.json';
167
+ }
168
+ else if (spec.systemDefault) {
169
+ value = spec.systemDefault();
170
+ source = 'system';
171
+ }
172
+ else {
173
+ value = spec.default ?? '';
174
+ source = 'default';
175
+ }
176
+ // If the higher-precedence source held an unresolvable ref, surface that
177
+ // explicitly so users know the daemon is silently using the fallback.
178
+ if (procResult && 'unresolvedRef' in procResult && source !== 'process.env') {
179
+ unresolvedRef = procResult.unresolvedRef;
180
+ }
181
+ else if (envResult && 'unresolvedRef' in envResult && source !== '.env') {
182
+ unresolvedRef = envResult.unresolvedRef;
183
+ }
184
+ // Track which other sources had values, to surface "this is overriding X."
185
+ const shadowedBy = [];
186
+ if (source !== 'process.env' && fromProcessEnv)
187
+ shadowedBy.push('process.env');
188
+ if (source !== '.env' && fromEnvFile && fromEnvFile.length > 0)
189
+ shadowedBy.push('.env');
190
+ if (source !== 'clementine.json' && fromJson !== undefined && fromJson !== '')
191
+ shadowedBy.push('clementine.json');
192
+ entries.push({
193
+ key: spec.key,
194
+ value,
195
+ source,
196
+ shadowedBy: shadowedBy.length > 0 ? shadowedBy : undefined,
197
+ group: spec.group,
198
+ ...(resolvedFrom ? { resolvedFrom } : {}),
199
+ ...(unresolvedRef ? { unresolvedRef } : {}),
200
+ });
201
+ }
202
+ return {
203
+ baseDir,
204
+ hasEnvFile: existsSync(envPath),
205
+ hasJsonFile: existsSync(jsonPath),
206
+ entries,
207
+ };
208
+ }
209
+ /** Return the SPECS array — exported so tests can iterate keys without re-importing. */
210
+ export function listKnownConfigKeys() {
211
+ return SPECS.map(s => s.key);
212
+ }
213
+ //# sourceMappingURL=effective-config.js.map