@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.
- package/.env.example +1 -1
- package/bin/construct +49 -11
- package/bin/construct-postinstall.mjs +1 -1
- package/lib/bootstrap/resources.mjs +2 -2
- package/lib/document-ingest.mjs +6 -5
- package/lib/embed/cli.mjs +16 -3
- package/lib/embed/customer-profiles.mjs +1 -1
- package/lib/embed/daemon.mjs +49 -42
- package/lib/embed/inbox.mjs +30 -0
- package/lib/embed/recommendation-store.mjs +1 -1
- package/lib/evaluator-optimizer.mjs +0 -2
- package/lib/features.mjs +11 -0
- package/lib/health-check.mjs +2 -4
- package/lib/init-unified.mjs +119 -3
- package/lib/install/first-invocation.mjs +4 -4
- package/lib/intake/queue.mjs +1 -1
- package/lib/integrations/intake-integrations.mjs +4 -5
- package/lib/knowledge/rag.mjs +16 -0
- package/lib/model-cheapest-provider.mjs +231 -0
- package/lib/model-router.mjs +7 -8
- package/lib/project-profile.mjs +1 -1
- package/lib/roles/catalog.mjs +133 -0
- package/lib/roles/preference.mjs +74 -0
- package/lib/server/index.mjs +33 -47
- package/lib/server/insights.mjs +1 -1
- package/lib/service-manager.mjs +2 -2
- package/lib/services/telemetry-backend.mjs +1 -1
- package/lib/setup-prompts.mjs +2 -2
- package/lib/setup.mjs +47 -3
- package/lib/status.mjs +5 -3
- package/lib/storage/postgres-backup.mjs +1 -1
- package/lib/uninstall/uninstall.mjs +1 -1
- package/package.json +1 -1
- package/templates/docs/construct_guide.md +2 -2
- package/templates/homebrew/construct.rb +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
81
|
+
question: 'Run `construct init` now?',
|
|
82
82
|
defaultYes: missingRequired.length > 0,
|
|
83
83
|
readlineModule,
|
|
84
84
|
stdin,
|
package/lib/intake/queue.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
*
|
|
478
|
-
*
|
|
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 '';
|
package/lib/knowledge/rag.mjs
CHANGED
|
@@ -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
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -217,9 +217,8 @@ const PROVIDER_FAMILY_TIERS = [
|
|
|
217
217
|
export { PROVIDER_FAMILY_TIERS };
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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));
|
package/lib/project-profile.mjs
CHANGED
|
@@ -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
|
|
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
|
+
}
|