@geraldmaron/construct 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@
10
10
  * 2. Otherwise, run `probeAll()` from lib/bootstrap/resources.mjs.
11
11
  * 3. If everything's healthy → silent, set BOOTSTRAP_CHECKED=1.
12
12
  * 4. If anything's missing → print the status table to stderr; on TTY,
13
- * prompt to run `construct setup` now. Always set BOOTSTRAP_CHECKED=1
13
+ * prompt to run `construct init` now. Always set BOOTSTRAP_CHECKED=1
14
14
  * so we don't re-prompt on subsequent commands.
15
15
  *
16
16
  * Skipped entirely for hook invocations (would corrupt hook output), for
@@ -71,14 +71,14 @@ export async function maybeFirstInvocationProbe({
71
71
  for (const probe of probes) stdout.write(formatProbeFn(probe) + '\n');
72
72
 
73
73
  if (missingRequired.length > 0) {
74
- stdout.write(`\n[construct] ${missingRequired.length} required resource(s) missing. Run \`construct setup\` to install.\n\n`);
74
+ stdout.write(`\n[construct] ${missingRequired.length} required resource(s) missing. Run \`construct init\` to install.\n\n`);
75
75
  } else {
76
- stdout.write(`\n[construct] ${missingOptional.length} optional resource(s) missing. Run \`construct setup\` to install (Postgres, telemetry, embedding model), or continue in degraded mode.\n\n`);
76
+ stdout.write(`\n[construct] ${missingOptional.length} optional resource(s) missing. Run \`construct init\` to install (Postgres, telemetry, embedding model), or continue in degraded mode.\n\n`);
77
77
  }
78
78
 
79
79
  if (stdin.isTTY && stdout.isTTY !== false) {
80
80
  const wants = await promptYesNo({
81
- question: 'Run `construct setup` now?',
81
+ question: 'Run `construct init` now?',
82
82
  defaultYes: missingRequired.length > 0,
83
83
  readlineModule,
84
84
  stdin,
@@ -73,7 +73,7 @@ export function createIntakeQueue(rootDir, env = process.env, opts = {}) {
73
73
  if (backend === 'postgres') {
74
74
  const sql = opts.sql ?? createSqlClient(env);
75
75
  if (!sql) {
76
- throw new Error('Postgres intake queue requires a configured DATABASE_URL (or sql client). Run `construct setup` or set CONSTRUCT_INTAKE_QUEUE_BACKEND=filesystem.');
76
+ throw new Error('Postgres intake queue requires a configured DATABASE_URL (or sql client). Run `construct init` or set CONSTRUCT_INTAKE_QUEUE_BACKEND=filesystem.');
77
77
  }
78
78
  const project = opts.project ?? resolveProject(rootDir, env);
79
79
  const tenantId = opts.tenantId ?? env?.[INTAKE_TENANT_ENV_KEY] ?? null;
@@ -222,7 +222,7 @@ function buildLabels(t) {
222
222
  // ── Jira ─────────────────────────────────────────────────────────────────
223
223
 
224
224
  /**
225
- * Create a Jira ticket from an intake packet.
225
+ * Create a Jira work item from an intake packet.
226
226
  *
227
227
  * @param {object} packet - Intake packet with triage, excerpt
228
228
  * @param {object} [opts]
@@ -276,8 +276,7 @@ export async function createJiraTicket(packet, { host, email, token, project, is
276
276
 
277
277
  const json = await res.json();
278
278
 
279
- // We don't have the browse URL directly from the create response,
280
- // so construct it from the host + issue key
279
+ // Browse URL is absent from the create response construct from host + issue key
281
280
  const issueKey = json.key;
282
281
  const browseUrl = `${resolvedHost.replace(/\/$/, '')}/browse/${issueKey}`;
283
282
 
@@ -474,8 +473,8 @@ async function updateConfluencePage(artifact, { host, email, token, space, fetch
474
473
  }
475
474
 
476
475
  /**
477
- * Minimal markdown to Confluence storage format conversion.
478
- * Handles the subset of markdown used by Construct artifacts.
476
+ * Converts the subset of markdown common to Construct artifacts into
477
+ * Confluence storage format.
479
478
  */
480
479
  function markdownToConfluenceStorage(md) {
481
480
  if (!md) return '';
@@ -155,6 +155,21 @@ function loadSnapshotChunks(rootDir) {
155
155
  return chunks;
156
156
  }
157
157
 
158
+ /**
159
+ * Load ingested knowledge files from .cx/knowledge/ subdirectories.
160
+ */
161
+ function loadKnowledgeChunks(rootDir) {
162
+ const knowledgeRoot = path.resolve(rootDir, '.cx/knowledge');
163
+ const subdirs = ['internal', 'external', 'decisions', 'how-tos', 'reference'];
164
+ const chunks = [];
165
+ for (const subdir of subdirs) {
166
+ const full = path.join(knowledgeRoot, subdir);
167
+ if (!fs.existsSync(full)) continue;
168
+ chunks.push(...loadMarkdownChunks(full, 'knowledge'));
169
+ }
170
+ return chunks;
171
+ }
172
+
158
173
  // ── Index builder ──────────────────────────────────────────────────────────
159
174
 
160
175
  /**
@@ -166,6 +181,7 @@ export function buildCorpus(rootDir = process.cwd()) {
166
181
  ...loadObservationChunks(rootDir),
167
182
  ...loadArtifactChunks(rootDir),
168
183
  ...loadSnapshotChunks(rootDir),
184
+ ...loadKnowledgeChunks(rootDir),
169
185
  ];
170
186
 
171
187
  // Embed each chunk
@@ -0,0 +1,231 @@
1
+ /**
2
+ * lib/model-cheapest-provider.mjs — cheapest configured provider selection.
3
+ *
4
+ * Evaluates all configured providers, looks up their per-tier model pricing,
5
+ * and returns the lowest-cost option. Local providers (Ollama, local) rank
6
+ * first since they are $0.
7
+ *
8
+ * Pricing data comes from getPricingForModels() in model-pricing.mjs, which
9
+ * has a 5-minute cache from the OpenRouter API. Static tables cover
10
+ * Anthropic/OpenAI first-party models.
11
+ *
12
+ * Usage:
13
+ * selectCheapestProvider('standard') // cheapest for one tier
14
+ * rankConfiguredProvidersByCost('fast') // full ranked list
15
+ * isCheapestProviderEnabled() // check opt-in preference
16
+ * formatCheapestProviderMessage(result) // user-facing output
17
+ */
18
+
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { getProviderModelCatalog, PROVIDER_FAMILY_TIERS } from './model-router.mjs';
23
+ import { getPricingForModels, formatPricingLabel } from './model-pricing.mjs';
24
+
25
+ const CHEAPEST_PREF_KEY = 'CHEAPEST_PROVIDER_ENABLED';
26
+ const CHEAPEST_CHECKED_KEY = 'CHEAPEST_PROVIDER_CHECKED';
27
+ const ENV_PATH = path.join(os.homedir(), '.construct', 'config.env');
28
+
29
+ /**
30
+ * Resolve the cheapest configured provider for a given tier.
31
+ *
32
+ * @param {string} tier - One of "reasoning", "standard", "fast"
33
+ * @param {object} [opts]
34
+ * @param {object} [opts.env] - Environment object (default: process.env)
35
+ * @param {string} [opts.envPath] - Path to config.env (default: ~/.construct/config.env)
36
+ * @param {Function} [opts.getPricingForModels] - Injectable pricing fetcher
37
+ * @returns {Promise<{
38
+ * providerId: string|null,
39
+ * providerLabel: string|null,
40
+ * modelId: string|null,
41
+ * inputPrice: number|null,
42
+ * outputPrice: number|null,
43
+ * isLocal: boolean,
44
+ * configuredProviders: string[],
45
+ * rankedList: Array<{providerId, providerLabel, modelId, inputPrice, outputPrice, totalPer1M, isLocal}>
46
+ * }>}
47
+ */
48
+ export async function selectCheapestProvider(tier, opts = {}) {
49
+ if (!['reasoning', 'standard', 'fast'].includes(tier)) {
50
+ return { providerId: null, providerLabel: null, modelId: null, configuredProviders: [], rankedList: [] };
51
+ }
52
+
53
+ const env = opts.env || process.env;
54
+ const catalog = getProviderModelCatalog({ env });
55
+ const configured = catalog.providers.filter((p) => p.configured);
56
+
57
+ if (configured.length === 0) {
58
+ return {
59
+ providerId: null, providerLabel: null, modelId: null,
60
+ inputPrice: null, outputPrice: null, isLocal: false,
61
+ configuredProviders: [], rankedList: [],
62
+ };
63
+ }
64
+
65
+ // Collect tier-specific model IDs from all configured providers
66
+ const modelIds = [];
67
+ for (const p of configured) {
68
+ const modelId = p.tiers[tier];
69
+ if (modelId) modelIds.push(modelId);
70
+ }
71
+
72
+ const needsRemote = modelIds.filter((id) => /^openrouter\//.test(id));
73
+ const hasRemote = needsRemote.length > 0;
74
+
75
+ // Fetch pricing (uses 5-min cache internally)
76
+ let pricingMap = {};
77
+ if (hasRemote) {
78
+ try {
79
+ pricingMap = await getPricingForModels(modelIds, {
80
+ getPricingForModels: opts.getPricingForModels,
81
+ });
82
+ } catch { /* pricing unavailable — fall back to nulls */ }
83
+ }
84
+
85
+ // Score each provider
86
+ const scored = configured.map((p) => {
87
+ const modelId = p.tiers[tier];
88
+ const pricing = hasRemote ? pricingMap[modelId] : null;
89
+ const inputPrice = pricing ? (Number(pricing.input) || 0) : 0;
90
+ const outputPrice = pricing ? (Number(pricing.output) || 0) : 0;
91
+ return {
92
+ providerId: p.id,
93
+ providerLabel: p.label,
94
+ modelId,
95
+ inputPrice,
96
+ outputPrice,
97
+ totalPer1M: inputPrice + outputPrice,
98
+ isLocal: p.local === true,
99
+ };
100
+ });
101
+
102
+ // Sort ascending by total cost (local/$0 first)
103
+ scored.sort((a, b) => a.totalPer1M - b.totalPer1M);
104
+
105
+ const cheapest = scored[0];
106
+ return {
107
+ providerId: cheapest.providerId,
108
+ providerLabel: cheapest.providerLabel,
109
+ modelId: cheapest.modelId,
110
+ inputPrice: cheapest.inputPrice,
111
+ outputPrice: cheapest.outputPrice,
112
+ isLocal: cheapest.isLocal,
113
+ configuredProviders: configured.map((p) => p.id),
114
+ rankedList: scored,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Get all configured providers ranked by cost for a tier.
120
+ *
121
+ * @param {string} tier
122
+ * @param {object} [opts]
123
+ * @returns {Promise<Array<{providerId, providerLabel, modelId, inputPrice, outputPrice, totalPer1M, isLocal}>>}
124
+ */
125
+ export async function rankConfiguredProvidersByCost(tier, opts = {}) {
126
+ const result = await selectCheapestProvider(tier, opts);
127
+ return result.rankedList;
128
+ }
129
+
130
+ /**
131
+ * Check if the user has opted into cheapest-provider selection.
132
+ *
133
+ * @param {string} [envPath] - Path to config.env (default: ~/.construct/config.env)
134
+ * @param {object} [opts.env] - Environment override
135
+ * @returns {boolean}
136
+ */
137
+ export function isCheapestProviderEnabled(envPath, opts = {}) {
138
+ const pathToUse = envPath || ENV_PATH;
139
+ const env = opts.env || process.env;
140
+
141
+ // Check env var first
142
+ if (env[CHEAPEST_PREF_KEY]) {
143
+ return env[CHEAPEST_PREF_KEY].toLowerCase() === 'yes';
144
+ }
145
+
146
+ // Check config.env file
147
+ try {
148
+ const content = fs.readFileSync(pathToUse, 'utf8');
149
+ const match = content.match(new RegExp(`^${CHEAPEST_PREF_KEY}=(.+)$`, 'm'));
150
+ if (match) {
151
+ return match[1].trim().toLowerCase() === 'yes';
152
+ }
153
+ } catch { /* file doesn't exist */ }
154
+
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * Persist the cheapest-provider opt-in preference.
160
+ *
161
+ * @param {string} envPath - Path to config.env
162
+ * @param {boolean} enabled
163
+ */
164
+ export function setCheapestProviderPreference(envPath, enabled) {
165
+ const pathToUse = envPath || ENV_PATH;
166
+ const existing = fs.existsSync(pathToUse) ? fs.readFileSync(pathToUse, 'utf8') : '';
167
+ const lines = existing.split('\n');
168
+ const keyLine = `${CHEAPEST_PREF_KEY}=${enabled ? 'yes' : 'no'}`;
169
+
170
+ // Replace existing key or append
171
+ const idx = lines.findIndex((l) => l.startsWith(`${CHEAPEST_PREF_KEY}=`));
172
+ if (idx >= 0) {
173
+ lines[idx] = keyLine;
174
+ } else {
175
+ lines.push(keyLine);
176
+ }
177
+
178
+ fs.mkdirSync(path.dirname(pathToUse), { recursive: true });
179
+ fs.writeFileSync(pathToUse, lines.join('\n'));
180
+ }
181
+
182
+ /**
183
+ * Format the cheapest provider selection into a user-facing message.
184
+ *
185
+ * @param {object} result - Output from selectCheapestProvider
186
+ * @param {object} [opts]
187
+ * @param {boolean} [opts.showRanking] - Include full ranked list
188
+ * @returns {string}
189
+ */
190
+ export function formatCheapestProviderMessage(result, opts = {}) {
191
+ const { providerId, providerLabel, modelId, inputPrice, outputPrice, isLocal, rankedList } = result;
192
+
193
+ if (!providerId) {
194
+ return 'No configured providers found. Set API keys or install Ollama to enable model selection.';
195
+ }
196
+
197
+ const label = formatPricingLabel({ input: inputPrice, output: outputPrice, source: isLocal ? 'local' : 'openrouter' });
198
+ let msg = `Cheapest provider for this tier:\n`;
199
+ msg += ` Provider: ${providerLabel}\n`;
200
+ msg += ` Model: ${modelId}\n`;
201
+ msg += ` Pricing: ${label}`;
202
+
203
+ if (opts.showRanking && rankedList && rankedList.length > 0) {
204
+ msg += `\n\nConfigured providers ranked by cost:\n`;
205
+ for (let i = 0; i < rankedList.length; i++) {
206
+ const r = rankedList[i];
207
+ const rLabel = formatPricingLabel({
208
+ input: r.inputPrice,
209
+ output: r.outputPrice,
210
+ source: r.isLocal ? 'local' : 'openrouter',
211
+ });
212
+ const marker = i === 0 ? '→' : ' ';
213
+ msg += ` ${marker} ${r.providerLabel.padEnd(28)} ${r.modelId.padEnd(40)} ${rLabel}\n`;
214
+ }
215
+ }
216
+
217
+ return msg;
218
+ }
219
+
220
+ /**
221
+ * Get all tier-specific cheapest selections.
222
+ *
223
+ * @param {object} [opts]
224
+ * @returns {Promise<{reasoning, standard, fast}>}
225
+ */
226
+ export async function selectCheapestForAllTiers(opts = {}) {
227
+ const reasoning = await selectCheapestProvider('reasoning', opts);
228
+ const standard = await selectCheapestProvider('standard', opts);
229
+ const fast = await selectCheapestProvider('fast', opts);
230
+ return { reasoning, standard, fast };
231
+ }
@@ -217,9 +217,8 @@ const PROVIDER_FAMILY_TIERS = [
217
217
  export { PROVIDER_FAMILY_TIERS };
218
218
 
219
219
  /**
220
- * Map provider family IDs to the env var(s) that indicate the provider
221
- * is configured (has credentials). Used by getProviderModelCatalog to
222
- * mark which providers are available.
220
+ * Maps provider family IDs to the env var(s) that confirm credentials are present.
221
+ * Consumed by getProviderModelCatalog to mark which providers are available.
223
222
  */
224
223
  const PROVIDER_ENV_MAP = {
225
224
  'anthropic': ['ANTHROPIC_API_KEY'],
@@ -254,14 +253,14 @@ function isProviderConfigured(familyId, env) {
254
253
  } catch { /* not available */ }
255
254
  }
256
255
 
257
- // 1. Check passed env (usually process.env)
256
+ // Check passed env (usually process.env)
258
257
  const anyInEnv = varNames.some(name => {
259
258
  const val = env?.[name];
260
259
  return val && typeof val === 'string' && val.length > 0;
261
260
  });
262
261
  if (anyInEnv) return true;
263
262
 
264
- // 2. Check construct config.env
263
+ // Check construct config.env
265
264
  try {
266
265
  const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
267
266
  const cfgPath = path.join(homeDir, '.construct', 'config.env');
@@ -276,7 +275,7 @@ function isProviderConfigured(familyId, env) {
276
275
  }
277
276
  } catch { /* skip */ }
278
277
 
279
- // 3. Special case: GitHub Copilot via gh CLI
278
+ // GitHub Copilot: detect via gh CLI auth status
280
279
  if (familyId === 'github-copilot') {
281
280
  try {
282
281
  const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 3000 });
@@ -284,7 +283,7 @@ function isProviderConfigured(familyId, env) {
284
283
  } catch { return false; }
285
284
  }
286
285
 
287
- // 4. Check ~/.env
286
+ // Check ~/.env
288
287
  try {
289
288
  const homeEnv = path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.env');
290
289
  if (fs.existsSync(homeEnv)) {
@@ -298,7 +297,7 @@ function isProviderConfigured(familyId, env) {
298
297
  }
299
298
  } catch { /* skip */ }
300
299
 
301
- // 5. Check shell rc files for op:// refs (1Password CLI integration)
300
+ // Check shell rc files for op:// refs (1Password CLI integration)
302
301
  try {
303
302
  const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
304
303
  const shellFiles = ['.zshrc', '.bashrc', '.bash_profile', '.profile'].map(f => path.join(homeDir, f));
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * The profile is written to `<cwd>/.cx/project-profile.json` and consumed by:
10
10
  * - `construct skills scope` — reports relevant vs irrelevant skills
11
- * - `construct setup` — runs detection on bootstrap
11
+ * - `construct init` — runs detection on bootstrap
12
12
  * - future per-host filter generators (.claude/settings.json filter,
13
13
  * .opencode/construct-skills.json sidecars, etc.)
14
14
  */
@@ -0,0 +1,133 @@
1
+ /**
2
+ * lib/roles/catalog.mjs — role listing and display helpers.
3
+ *
4
+ * Reads agents/registry.json to produce a list of available roles with
5
+ * optional department grouping and consolidated-role views. Consumed by the
6
+ * `roles:list` CLI command.
7
+ */
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const REGISTRY_PATH = join(__dirname, '..', '..', 'agents', 'registry.json');
15
+
16
+ let cached = null;
17
+
18
+ function loadRegistry() {
19
+ if (cached) return cached;
20
+ cached = JSON.parse(readFileSync(REGISTRY_PATH, 'utf8'));
21
+ return cached;
22
+ }
23
+
24
+ /**
25
+ * Return a list of role descriptors.
26
+ *
27
+ * @param {object} opts
28
+ * @param {boolean} opts.departments Group by department when true.
29
+ * @param {boolean} opts.consolidated Return consolidated 12-role view when true.
30
+ * @returns {Array<object>}
31
+ */
32
+ export function listRoles({ departments = false, consolidated = false } = {}) {
33
+ const registry = loadRegistry();
34
+
35
+ if (consolidated) {
36
+ const c = registry.consolidatedRoles || {};
37
+ return (c.roles || []).map((r) => ({
38
+ id: r.id,
39
+ absorbs: r.absorbs || [],
40
+ whenToUse: r.whenToUse || '',
41
+ }));
42
+ }
43
+
44
+ const agents = Array.isArray(registry.agents)
45
+ ? registry.agents
46
+ : Object.values(registry.agents || {});
47
+
48
+ if (!departments) {
49
+ return agents.map((a) => ({
50
+ id: a.name,
51
+ name: `cx-${a.name}`,
52
+ description: a.description || '',
53
+ internal: !!a.internal,
54
+ }));
55
+ }
56
+
57
+ // Group by department based on subDepartment membership in registry.departments
58
+ const depts = registry.departments || {};
59
+ const grouped = {};
60
+ for (const [deptId, dept] of Object.entries(depts)) {
61
+ grouped[deptId] = {
62
+ name: dept.name || deptId,
63
+ roles: [],
64
+ };
65
+ }
66
+ grouped._ungrouped = { name: 'Other', roles: [] };
67
+
68
+ for (const agent of agents) {
69
+ let placed = false;
70
+ for (const [deptId, dept] of Object.entries(depts)) {
71
+ const subs = dept.subDepartments || {};
72
+ for (const sub of Object.values(subs)) {
73
+ if ((sub.roles || []).includes(agent.name) || (sub.roles || []).includes(`cx-${agent.name}`)) {
74
+ grouped[deptId].roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
75
+ placed = true;
76
+ break;
77
+ }
78
+ }
79
+ if (placed) break;
80
+ }
81
+ if (!placed) {
82
+ grouped._ungrouped.roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
83
+ }
84
+ }
85
+
86
+ return Object.entries(grouped)
87
+ .filter(([, g]) => g.roles.length > 0)
88
+ .map(([id, g]) => ({ departmentId: id, departmentName: g.name, roles: g.roles }));
89
+ }
90
+
91
+ /**
92
+ * Format the role list as a human-readable string.
93
+ *
94
+ * @param {object} opts Same options as listRoles.
95
+ * @returns {string}
96
+ */
97
+ export function formatRoleList({ departments = false, consolidated = false } = {}) {
98
+ const roles = listRoles({ departments, consolidated });
99
+ const lines = [];
100
+
101
+ if (consolidated) {
102
+ lines.push('Consolidated Roles (12-role simplified structure)');
103
+ lines.push('=================================================');
104
+ for (const r of roles) {
105
+ lines.push(`\n${r.id}`);
106
+ lines.push(` Absorbs: ${r.absorbs.join(', ')}`);
107
+ lines.push(` Use when: ${r.whenToUse}`);
108
+ }
109
+ lines.push('');
110
+ return lines.join('\n');
111
+ }
112
+
113
+ if (departments) {
114
+ lines.push('Available Roles by Department');
115
+ lines.push('=============================');
116
+ for (const group of roles) {
117
+ lines.push(`\n${group.departmentName}`);
118
+ for (const role of group.roles) {
119
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
120
+ }
121
+ }
122
+ lines.push('');
123
+ return lines.join('\n');
124
+ }
125
+
126
+ lines.push('Available Roles');
127
+ lines.push('===============');
128
+ for (const role of roles) {
129
+ lines.push(` ${role.name.padEnd(30)} ${role.description}`);
130
+ }
131
+ lines.push('');
132
+ return lines.join('\n');
133
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * lib/roles/preference.mjs — per-user primary/secondary role preferences.
3
+ *
4
+ * Persists role preferences to ~/.construct/config.env under the keys
5
+ * CONSTRUCT_ROLE_PRIMARY and CONSTRUCT_ROLE_SECONDARY. Consumed by the
6
+ * `roles:set` CLI command and the orchestration layer.
7
+ */
8
+
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ import { getUserEnvPath, ensureUserConfigDir, writeEnvValues, parseEnvFile } from '../env-config.mjs';
13
+
14
+ const ENV_KEYS = {
15
+ primary: 'CONSTRUCT_ROLE_PRIMARY',
16
+ secondary: 'CONSTRUCT_ROLE_SECONDARY',
17
+ };
18
+
19
+ /**
20
+ * Read the stored role preference for the given slot.
21
+ *
22
+ * @param {'primary'|'secondary'} slot
23
+ * @returns {string|null} The stored role id, or null if unset.
24
+ */
25
+ export function getRolePreference(slot) {
26
+ const envKey = ENV_KEYS[slot];
27
+ if (!envKey) return null;
28
+
29
+ // Env var takes priority (allows per-session override)
30
+ if (process.env[envKey]) return process.env[envKey];
31
+
32
+ const envPath = getUserEnvPath(os.homedir());
33
+ const stored = parseEnvFile(envPath);
34
+ return stored[envKey] || null;
35
+ }
36
+
37
+ /**
38
+ * Persist a role preference for the given slot.
39
+ *
40
+ * @param {'primary'|'secondary'} slot
41
+ * @param {string} role Role id (e.g. "cx-engineer" or "engineer").
42
+ * @returns {{ slot: string, role: string }}
43
+ */
44
+ export function setRolePreference(slot, role) {
45
+ const envKey = ENV_KEYS[slot];
46
+ if (!envKey) throw new Error(`Unknown role slot "${slot}". Use "primary" or "secondary".`);
47
+
48
+ // Normalise: accept both "engineer" and "cx-engineer"
49
+ const normalised = role.startsWith('cx-') ? role : `cx-${role}`;
50
+
51
+ ensureUserConfigDir(os.homedir());
52
+ const envPath = getUserEnvPath(os.homedir());
53
+ writeEnvValues(envPath, { [envKey]: normalised });
54
+
55
+ // Also set in current process so the change is visible immediately
56
+ process.env[envKey] = normalised;
57
+
58
+ return { slot, role: normalised };
59
+ }
60
+
61
+ /**
62
+ * Clear a stored role preference.
63
+ *
64
+ * @param {'primary'|'secondary'} slot
65
+ */
66
+ export function clearRolePreference(slot) {
67
+ const envKey = ENV_KEYS[slot];
68
+ if (!envKey) return;
69
+
70
+ delete process.env[envKey];
71
+
72
+ const envPath = getUserEnvPath(os.homedir());
73
+ writeEnvValues(envPath, { [envKey]: '' });
74
+ }