@geraldmaron/construct 1.0.19 → 1.0.21

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,317 @@
1
+ /**
2
+ * lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime.
3
+ *
4
+ * The product goal: hosts are front doors, Construct is the system. A
5
+ * host that lacks native multi-agent execution should still reach equivalent
6
+ * Construct outcomes through a Construct-owned runtime rather than depending on
7
+ * whichever editor exposes the strongest teammate mechanics. This module intakes
8
+ * a request, plans/decomposes it into a sequenced specialist chain (reusing the
9
+ * orchestration-policy planner), resolves the execution-capability contract
10
+ * (reusing resolveExecution), persists a durable run + task lifecycle through a
11
+ * pluggable run store (filesystem default, sqlite Mode-B, postgres Mode-C), and
12
+ * emits lifecycle traces.
13
+ *
14
+ * Worker backends (ADR-0020, ADR-0021):
15
+ * - `inline` (default): OWNS planning, sequencing, handoff state, persistence,
16
+ * and observability, and PREPARES each specialist task for a downstream
17
+ * executor. It does NOT itself perform specialist LLM reasoning. Tasks reach
18
+ * status `prepared`; the prepare-only disclaimer applies.
19
+ * - `provider`: EXECUTES each specialist task by calling the configured
20
+ * provider/model with the specialist persona prompt. Tasks reach status
21
+ * `done` carrying real model output; the prepare-only disclaimer does NOT
22
+ * apply to provider-executed tasks. A failing task is recorded (status
23
+ * `failed`) and the run completes `completed-with-failures` rather than
24
+ * crashing.
25
+ *
26
+ * The run reports `workerBackend` and per-task `executor` so a host can never
27
+ * mistake a prepared task for executed specialist output. When the execution
28
+ * contract resolves to prompt-only or host-direct, Construct owns no specialist
29
+ * task sequence and the run records that explicitly.
30
+ */
31
+
32
+ import { randomBytes } from 'node:crypto';
33
+
34
+ import { routeRequest } from '../orchestration-policy.mjs';
35
+ import { resolveExecution } from '../embedded-contract/execution.mjs';
36
+ import { detectHostCapabilities } from '../host-capabilities.mjs';
37
+ import { loadProjectConfig } from '../config/project-config.mjs';
38
+ import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
39
+ import { resolveRunStore } from './store.mjs';
40
+ import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
41
+
42
+ export const WORKER_BACKENDS = WORKER_BACKEND_SET;
43
+ export const DEFAULT_WORKER_BACKEND = INLINE;
44
+
45
+ const RUNTIME_SEMANTICS = 'The inline worker backend owns planning, sequencing, handoff state, persistence, and observability, and prepares each specialist task for a downstream executor. It does not perform specialist LLM reasoning itself; tasks reaching status "prepared" are ready for a worker backend or host to execute. The provider worker backend executes specialist reasoning via the configured model and records real output as task.output.';
46
+
47
+ function newRunId() {
48
+ return `run-${randomBytes(6).toString('hex')}`;
49
+ }
50
+
51
+ function truncate(text, max) {
52
+ const s = String(text || '');
53
+ return s.length > max ? `${s.slice(0, max)}…` : s;
54
+ }
55
+
56
+ // hostRole reports how the calling host orchestrates, so an adapter can decide
57
+ // whether to delegate to Construct's runtime (its reason for being). A direct
58
+ // CLI call has no host; an unrecognized host name is reported as unknown rather
59
+ // than guessed.
60
+
61
+ function resolveHostRole(hostName) {
62
+ if (!hostName) return 'cli-direct';
63
+ const match = detectHostCapabilities().find((h) => h.host.toLowerCase() === String(hostName).toLowerCase());
64
+ return match ? match.orchestration : 'unknown';
65
+ }
66
+
67
+ function pickExecution(data) {
68
+ return {
69
+ executionMode: data.executionMode,
70
+ effectiveStrategy: data.effectiveStrategy,
71
+ requestedStrategy: data.requestedStrategy,
72
+ constructCapabilitiesActive: data.constructCapabilitiesActive,
73
+ degraded: data.degraded,
74
+ degradationReason: data.degradationReason,
75
+ selectedProvider: data.selectedProvider,
76
+ selectedModel: data.selectedModel,
77
+ resolutionSource: data.resolutionSource,
78
+ orchestrationPlanned: data.orchestrationPlanned,
79
+ orchestrationAvailable: data.orchestrationAvailable,
80
+ deploymentMode: data.deploymentMode,
81
+ };
82
+ }
83
+
84
+ function buildTasks(route) {
85
+ const reasons = route.dispatchReasons || {};
86
+ const handoffByProducer = new Map();
87
+ for (const edge of route.contractChain || []) {
88
+ const producer = edge.contract?.producer;
89
+ if (producer && !handoffByProducer.has(producer)) handoffByProducer.set(producer, edge.contract.id);
90
+ }
91
+ return (route.specialists || []).map((role, i) => ({
92
+ id: `t${i + 1}`,
93
+ seq: i,
94
+ role,
95
+ reason: reasons[role] || null,
96
+ handoffContract: handoffByProducer.get(role.replace(/^cx-/, '')) || null,
97
+ status: 'queued',
98
+ executor: null,
99
+ output: null,
100
+ error: null,
101
+ startedAt: null,
102
+ finishedAt: null,
103
+ }));
104
+ }
105
+
106
+ // The worker backend is a deployment concern: an explicit option wins, then the
107
+ // run's recorded backend, then config (orchestration.workerBackend), then the
108
+ // inline default. An unknown value falls back to inline rather than guessing.
109
+
110
+ function resolveWorkerBackend({ explicit, run, config }) {
111
+ const candidate = explicit || run?.workerBackend || config?.orchestration?.workerBackend || DEFAULT_WORKER_BACKEND;
112
+ return WORKER_BACKENDS.includes(candidate) ? candidate : DEFAULT_WORKER_BACKEND;
113
+ }
114
+
115
+ function loadConfig(cwd, env) {
116
+ try {
117
+ return loadProjectConfig(cwd, env).config || {};
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Plan a run: resolve the execution contract, decompose into a specialist chain,
125
+ * and persist a durable run record (status `planned`). Pure of model calls.
126
+ *
127
+ * @param {object} request
128
+ * @param {object} [opts] { env, cwd }
129
+ * @returns {Promise<object>} the persisted run
130
+ */
131
+ export async function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
132
+ const {
133
+ request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
134
+ host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
135
+ } = request;
136
+
137
+ const config = loadConfig(cwd, env);
138
+ const { store, warnings: storeWarnings } = resolveRunStore({ config, env, cwd });
139
+
140
+ const execData = resolveExecution(
141
+ { workflowType, requestedStrategy, useConstruct, host, hostModel, hostProvider },
142
+ { env, cwd },
143
+ );
144
+ const route = routeRequest({ request: text, fileCount, moduleCount });
145
+
146
+ const traceId = newTraceId();
147
+ const runId = newRunId();
148
+ const now = new Date().toISOString();
149
+ const workerBackend = resolveWorkerBackend({ config });
150
+
151
+ // Construct owns a specialist task sequence only when the contract resolves to
152
+ // orchestrated execution; prompt-only and host-direct own none, and the run
153
+ // records that rather than implying orchestration that did not happen.
154
+ const orchestrates = execData.effectiveStrategy === 'orchestrated';
155
+ const tasks = orchestrates ? buildTasks(route) : [];
156
+
157
+ const run = {
158
+ runId,
159
+ traceId,
160
+ createdAt: now,
161
+ updatedAt: now,
162
+ request: { summary: truncate(text, 200), workflowType, requestedStrategy },
163
+ hostRole: resolveHostRole(host),
164
+ workerBackend,
165
+ execution: pickExecution(execData),
166
+ plan: {
167
+ intent: route.intent,
168
+ track: route.track,
169
+ specialists: route.specialists || [],
170
+ dispatchPlan: route.dispatchPlan,
171
+ contractChain: (route.contractChain || []).map((e) => ({ id: e.contract?.id, producer: e.contract?.producer, consumer: e.contract?.consumer, stage: e.stage })),
172
+ },
173
+ tasks,
174
+ status: 'planned',
175
+ warnings: [...(execData.warnings || []), ...storeWarnings],
176
+ semantics: RUNTIME_SEMANTICS,
177
+ executionSemantics: execData.semantics,
178
+ };
179
+
180
+ await store.saveRun(run);
181
+ emitTraceEvent({
182
+ rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
183
+ metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
184
+ });
185
+ return run;
186
+ }
187
+
188
+ // The inline backend prepares one task: queued → running → prepared, no model
189
+ // reasoning. The boundary ADR-0020 makes explicit.
190
+
191
+ function prepareTaskInline(task) {
192
+ task.executor = 'inline:prepared';
193
+ task.status = 'prepared';
194
+ task.finishedAt = new Date().toISOString();
195
+ }
196
+
197
+ // The provider backend executes one task against the configured model. A failed
198
+ // task is recorded (status `failed`, task.error) and does not abort the run, so
199
+ // one specialist failure cannot lose the work of the others.
200
+
201
+ async function executeTaskViaProvider(task, run, env, fetchImpl) {
202
+ try {
203
+ const result = await runTaskViaProvider({
204
+ task, run,
205
+ model: run.execution?.selectedModel,
206
+ provider: run.execution?.selectedProvider,
207
+ env, fetchImpl,
208
+ });
209
+ task.output = result.output;
210
+ task.executor = `provider:${result.provider}:${result.model}`;
211
+ task.status = 'done';
212
+ task.finishedAt = new Date().toISOString();
213
+ return true;
214
+ } catch (err) {
215
+ task.executor = `provider:error`;
216
+ task.error = { code: err.code || 'PROVIDER_EXECUTION_FAILED', message: err.message };
217
+ task.status = 'failed';
218
+ task.finishedAt = new Date().toISOString();
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Execute a planned run through the chosen worker backend, persisting after each
225
+ * task transition so the run is resumable and observable.
226
+ *
227
+ * @param {string} cwd
228
+ * @param {string} runId
229
+ * @param {object} [opts] { env, workerBackend, fetchImpl }
230
+ * @returns {Promise<object>} the completed run
231
+ */
232
+ export async function executeRun(cwd, runId, { env = process.env, workerBackend = null, fetchImpl } = {}) {
233
+ const config = loadConfig(cwd, env);
234
+ const { store } = resolveRunStore({ config, env, cwd });
235
+ const run = await store.loadRun(runId);
236
+ if (!run) {
237
+ const err = new Error(`Orchestration run not found: ${runId}`);
238
+ err.code = 'RUN_NOT_FOUND';
239
+ throw err;
240
+ }
241
+
242
+ const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
243
+ run.workerBackend = backend;
244
+ run.status = 'running';
245
+ run.updatedAt = new Date().toISOString();
246
+ await store.saveRun(run);
247
+
248
+ let anyFailed = false;
249
+ for (const task of run.tasks) {
250
+ task.status = 'running';
251
+ task.startedAt = new Date().toISOString();
252
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
253
+
254
+ if (backend === PROVIDER) {
255
+ const ok = await executeTaskViaProvider(task, run, env, fetchImpl);
256
+ if (!ok) anyFailed = true;
257
+ } else {
258
+ prepareTaskInline(task);
259
+ }
260
+
261
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: task.status } });
262
+ run.updatedAt = new Date().toISOString();
263
+ await store.saveRun(run);
264
+ }
265
+
266
+ run.status = anyFailed ? 'completed-with-failures' : 'completed';
267
+ run.updatedAt = new Date().toISOString();
268
+ await store.saveRun(run);
269
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
270
+ return run;
271
+ }
272
+
273
+ /**
274
+ * Plan and execute in one call (the common `orchestrate run` path).
275
+ */
276
+ export async function runOrchestration(request = {}, { env = process.env, cwd = process.cwd(), workerBackend = null, fetchImpl } = {}) {
277
+ const planned = await planRun(request, { env, cwd });
278
+ return executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl });
279
+ }
280
+
281
+ /**
282
+ * The structured metadata a host adapter consumes for runtime-backed integration.
283
+ */
284
+ export function hostAdapterMetadata(run) {
285
+ const e = run.execution || {};
286
+ return {
287
+ runId: run.runId,
288
+ traceId: run.traceId,
289
+ status: run.status,
290
+ requestedStrategy: e.requestedStrategy,
291
+ effectiveStrategy: e.effectiveStrategy,
292
+ executionMode: e.executionMode,
293
+ constructCapabilitiesActive: e.constructCapabilitiesActive,
294
+ workerBackend: run.workerBackend,
295
+ hostRole: run.hostRole,
296
+ degraded: e.degraded,
297
+ degradationReason: e.degradationReason,
298
+ selectedProvider: e.selectedProvider,
299
+ selectedModel: e.selectedModel,
300
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor })),
301
+ warnings: run.warnings || [],
302
+ semantics: run.semantics,
303
+ executionSemantics: run.executionSemantics,
304
+ };
305
+ }
306
+
307
+ export async function getRun(cwd, runId, { env = process.env } = {}) {
308
+ const config = loadConfig(cwd, env);
309
+ const { store } = resolveRunStore({ config, env, cwd });
310
+ return store.loadRun(runId);
311
+ }
312
+
313
+ export async function getRuns(cwd, { env = process.env, ...opts } = {}) {
314
+ const config = loadConfig(cwd, env);
315
+ const { store } = resolveRunStore({ config, env, cwd });
316
+ return store.listRuns(opts);
317
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * lib/orchestration/store.mjs — run-store resolver for the orchestration runtime.
3
+ *
4
+ * One interface — saveRun(run), loadRun(runId), listRuns(opts) — over three
5
+ * backends: filesystem (Mode-A default, zero dependency), sqlite (Mode-B,
6
+ * node:sqlite), and postgres (Mode-C, shared team/enterprise store). The runtime
7
+ * resolves a store through here instead of importing run-store functions
8
+ * directly, so the backend is a deployment concern rather than a code edit.
9
+ *
10
+ * Selection precedence: explicit config (`orchestration.store`) or the
11
+ * CONSTRUCT_ORCHESTRATION_STORE env override win; otherwise the deployment mode
12
+ * decides (solo→filesystem, team/enterprise→postgres). When the chosen backend
13
+ * is unavailable (sqlite on Node <22.5, or postgres with no DATABASE_URL / null
14
+ * sql client) the resolver falls back to filesystem and records a warning rather
15
+ * than failing — durability of the default tier is never sacrificed to an
16
+ * optional one. The returned store is always async-uniform so callers await it
17
+ * regardless of backend; the filesystem and sqlite backends resolve synchronously
18
+ * under the hood, so the default Mode-A run record is byte-for-byte unchanged.
19
+ */
20
+
21
+ import { getDeploymentMode } from '../deployment-mode.mjs';
22
+ import { createSqlClient } from '../storage/backend.mjs';
23
+ import { saveRun as fsSaveRun, loadRun as fsLoadRun, listRuns as fsListRuns } from './run-store.mjs';
24
+ import { sqliteAvailable, createSqliteRunStore } from './run-store-sqlite.mjs';
25
+ import { PostgresRunStore } from './run-store-postgres.mjs';
26
+
27
+ export const ORCHESTRATION_STORES = ['filesystem', 'sqlite', 'postgres'];
28
+ export const STORE_ENV_KEY = 'CONSTRUCT_ORCHESTRATION_STORE';
29
+
30
+ function filesystemStore(cwd) {
31
+ return {
32
+ kind: 'filesystem',
33
+ saveRun: (run) => fsSaveRun(cwd, run),
34
+ loadRun: (runId) => fsLoadRun(cwd, runId),
35
+ listRuns: (opts) => fsListRuns(cwd, opts),
36
+ };
37
+ }
38
+
39
+ function sqliteStore(cwd) {
40
+ const store = createSqliteRunStore({ cwd });
41
+ return {
42
+ kind: 'sqlite',
43
+ saveRun: (run) => store.saveRun(run),
44
+ loadRun: (runId) => store.loadRun(runId),
45
+ listRuns: (opts) => store.listRuns(opts),
46
+ };
47
+ }
48
+
49
+ function postgresStore({ sql, project }) {
50
+ const store = new PostgresRunStore({ sql, project });
51
+ let ensured = null;
52
+ const ready = () => {
53
+ if (!ensured) ensured = store.ensureSchema();
54
+ return ensured;
55
+ };
56
+ return {
57
+ kind: 'postgres',
58
+ saveRun: async (run) => { await ready(); return store.saveRun(run); },
59
+ loadRun: async (runId) => { await ready(); return store.loadRun(runId); },
60
+ listRuns: async (opts) => { await ready(); return store.listRuns(opts); },
61
+ };
62
+ }
63
+
64
+ function projectKey(config, cwd) {
65
+ return config?.deployment?.projectName || cwd || 'default';
66
+ }
67
+
68
+ function chooseBackend({ config, env, cwd }) {
69
+ const explicit = String(env?.[STORE_ENV_KEY] || config?.orchestration?.store || '').trim().toLowerCase();
70
+ if (ORCHESTRATION_STORES.includes(explicit)) return explicit;
71
+ const mode = getDeploymentMode(env, { cwd });
72
+ return mode === 'solo' ? 'filesystem' : 'postgres';
73
+ }
74
+
75
+ /**
76
+ * Resolve a run store for the runtime.
77
+ *
78
+ * @param {object} opts
79
+ * @param {object} [opts.config] loaded project config (reads orchestration.store)
80
+ * @param {Record<string,string>} [opts.env]
81
+ * @param {string} [opts.cwd]
82
+ * @returns {{ store: {kind:string, saveRun:Function, loadRun:Function, listRuns:Function}, backend: string, warnings: string[] }}
83
+ */
84
+ export function resolveRunStore({ config = {}, env = process.env, cwd = process.cwd() } = {}) {
85
+ const warnings = [];
86
+ const requested = chooseBackend({ config, env, cwd });
87
+
88
+ if (requested === 'sqlite') {
89
+ if (sqliteAvailable()) return { store: sqliteStore(cwd), backend: 'sqlite', warnings };
90
+ warnings.push('orchestration.store "sqlite" requires Node >=22.5 (node:sqlite unavailable); falling back to filesystem.');
91
+ return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
92
+ }
93
+
94
+ if (requested === 'postgres') {
95
+ const sql = createSqlClient(env);
96
+ if (sql) return { store: postgresStore({ sql, project: projectKey(config, cwd) }), backend: 'postgres', warnings };
97
+ warnings.push('orchestration.store "postgres" requires a reachable DATABASE_URL; falling back to filesystem.');
98
+ return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
99
+ }
100
+
101
+ return { store: filesystemStore(cwd), backend: 'filesystem', warnings };
102
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * lib/orchestration/worker.mjs — provider-backed worker backend for the local
3
+ * orchestration runtime.
4
+ *
5
+ * The inline backend (runtime.mjs) PREPARES a specialist task and stops short of
6
+ * model reasoning (ADR-0020). The provider backend closes that gap: it EXECUTES
7
+ * one specialist task by calling the configured provider/model with the
8
+ * specialist's persona prompt as system context and the run's request as the
9
+ * user turn, returning the model's real output (ADR-0021). Because it genuinely
10
+ * runs the model, the prepare-only disclaimer does not apply to provider-executed
11
+ * tasks — runtime.mjs records `task.output` and `executor='provider:<provider>:<model>'`
12
+ * so a host can distinguish prepared from executed work.
13
+ *
14
+ * Provider selection mirrors lib/ingest/provider-extract.mjs: Anthropic
15
+ * `/v1/messages` for Claude-family models, OpenRouter `/v1/chat/completions`
16
+ * otherwise; key resolution reads env (ANTHROPIC_API_KEY / OPENROUTER_API_KEY)
17
+ * and augments with ambient dotenv only when the caller did not inject an
18
+ * explicit env (hermetic-when-explicit). `fetchImpl` is injectable so the path is
19
+ * exercised end to end against a mock provider without a live key. Failures
20
+ * surface as structured codes (PROVIDER_KEY_MISSING, PROVIDER_MODEL_UNRESOLVED,
21
+ * PROVIDER_EXECUTION_FAILED) rather than opaque HTTP errors.
22
+ */
23
+
24
+ import { readFileSync, existsSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ export const INLINE = 'inline';
30
+ export const PROVIDER = 'provider';
31
+ export const WORKER_BACKEND_SET = [INLINE, PROVIDER];
32
+
33
+ const MAX_OUTPUT_TOKENS = 2048;
34
+
35
+ const HERE = dirname(fileURLToPath(import.meta.url));
36
+ const PROMPTS_DIR = join(HERE, '..', '..', 'specialists', 'prompts');
37
+
38
+ function providerError(code, reason, remediation) {
39
+ const err = new Error(reason);
40
+ err.code = code;
41
+ err.remediation = remediation;
42
+ return err;
43
+ }
44
+
45
+ function isAnthropic(provider, model) {
46
+ return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
47
+ }
48
+
49
+ // Key resolution mirrors provider-extract: env first, then the two cheap dotenv
50
+ // files, and only when the caller did not inject an explicit env. A caller that
51
+ // passes its own env object (embedded callers, tests) is authoritative and
52
+ // hermetic — ambient discovery is suppressed so a developer's real key never
53
+ // bleeds into a test run.
54
+
55
+ function resolveKey(varName, env, allowAmbient) {
56
+ if (env[varName] && typeof env[varName] === 'string' && env[varName].length > 0) return env[varName];
57
+ if (!allowAmbient) return null;
58
+ for (const file of [join(homedir(), '.construct', 'config.env'), join(homedir(), '.env')]) {
59
+ try {
60
+ if (!existsSync(file)) continue;
61
+ const m = readFileSync(file, 'utf8').match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
62
+ if (m && m[1]) return m[1].trim();
63
+ } catch { /* unreadable dotenv is not authoritative */ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // The persona prompt is the specialist's system context. It lives in
69
+ // specialists/prompts/cx-<role>.md; absent that file, a minimal role-named system
70
+ // prompt keeps the backend functional rather than failing on a missing persona.
71
+
72
+ function loadPersona(role) {
73
+ const slug = String(role || '').replace(/^cx-/, '');
74
+ const file = join(PROMPTS_DIR, `cx-${slug}.md`);
75
+ try {
76
+ if (existsSync(file)) return readFileSync(file, 'utf8');
77
+ } catch { /* unreadable persona falls back to the minimal prompt */ }
78
+ return `You are the ${slug} specialist. Execute your part of the request within your role and return your result directly.`;
79
+ }
80
+
81
+ function buildUserPrompt({ task, run }) {
82
+ const summary = run?.request?.summary || '';
83
+ const reason = task?.reason ? `\n\nWhy you were dispatched: ${task.reason}` : '';
84
+ const handoff = task?.handoffContract ? `\nHandoff contract: ${task.handoffContract}` : '';
85
+ return `Request: ${summary}${reason}${handoff}\n\nDo your part of this request as the ${String(task?.role || '').replace(/^cx-/, '')} specialist. Return your result directly.`;
86
+ }
87
+
88
+ async function bodyExcerpt(res) {
89
+ try {
90
+ return (await res.text()).slice(0, 200);
91
+ } catch {
92
+ return '';
93
+ }
94
+ }
95
+
96
+ async function callAnthropic({ model, apiKey, system, user, fetchImpl }) {
97
+ const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
98
+ method: 'POST',
99
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
100
+ body: JSON.stringify({
101
+ model: model.replace(/^anthropic\//, ''),
102
+ max_tokens: MAX_OUTPUT_TOKENS,
103
+ system,
104
+ messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
105
+ }),
106
+ });
107
+ if (!res.ok) {
108
+ throw providerError('PROVIDER_EXECUTION_FAILED', `Anthropic specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and ANTHROPIC_API_KEY, or set orchestration.workerBackend to "inline".');
109
+ }
110
+ const data = await res.json();
111
+ return (data.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
112
+ }
113
+
114
+ async function callOpenRouter({ model, apiKey, system, user, fetchImpl }) {
115
+ const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
116
+ method: 'POST',
117
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/construct' },
118
+ body: JSON.stringify({
119
+ model: model.replace(/^openrouter\//, ''),
120
+ max_tokens: MAX_OUTPUT_TOKENS,
121
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
122
+ }),
123
+ });
124
+ if (!res.ok) {
125
+ throw providerError('PROVIDER_EXECUTION_FAILED', `OpenRouter specialist execution failed (HTTP ${res.status}): ${await bodyExcerpt(res)}`, 'Verify the model id and OPENROUTER_API_KEY, or set orchestration.workerBackend to "inline".');
126
+ }
127
+ const data = await res.json();
128
+ return data.choices?.[0]?.message?.content || '';
129
+ }
130
+
131
+ /**
132
+ * Execute one specialist task via the configured provider/model.
133
+ *
134
+ * @param {object} opts
135
+ * @param {object} opts.task the run task (carries role, reason, handoffContract)
136
+ * @param {object} opts.run the run (carries request summary)
137
+ * @param {string} opts.model resolved provider model id
138
+ * @param {string} [opts.provider] resolved provider id (selects the API)
139
+ * @param {Record<string,string>} [opts.env]
140
+ * @param {Function} [opts.fetchImpl] injectable fetch (for tests)
141
+ * @returns {Promise<{output:string, model:string, provider:string, characters:number}>}
142
+ */
143
+ export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch } = {}) {
144
+ if (!model) throw providerError('PROVIDER_MODEL_UNRESOLVED', 'Provider worker backend selected but no model resolved.', 'Configure the model tier registry so a model resolves, or set orchestration.workerBackend to "inline".');
145
+ if (typeof fetchImpl !== 'function') throw providerError('PROVIDER_NO_FETCH', 'No fetch implementation available for provider execution.', 'Run on a runtime with global fetch (Node 18+) or inject fetchImpl.');
146
+
147
+ const anthropic = isAnthropic(provider, model);
148
+ const keyVar = anthropic ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
149
+ const apiKey = resolveKey(keyVar, env, env === process.env);
150
+ if (!apiKey) {
151
+ throw providerError('PROVIDER_KEY_MISSING', `No API key for ${anthropic ? 'Anthropic' : 'OpenRouter'} specialist execution.`, `Set ${keyVar}, or set orchestration.workerBackend to "inline".`);
152
+ }
153
+
154
+ const system = loadPersona(task?.role);
155
+ const user = buildUserPrompt({ task, run });
156
+ const output = anthropic
157
+ ? await callAnthropic({ model, apiKey, system, user, fetchImpl })
158
+ : await callOpenRouter({ model, apiKey, system, user, fetchImpl });
159
+
160
+ const text = output || '';
161
+ return {
162
+ output: text,
163
+ model,
164
+ provider: anthropic ? 'anthropic' : 'openrouter',
165
+ characters: text.length,
166
+ };
167
+ }
package/lib/parity.mjs CHANGED
@@ -30,6 +30,38 @@ import { fileURLToPath } from 'node:url';
30
30
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
31
31
  const ROOT_DIR = path.resolve(MODULE_DIR, '..');
32
32
 
33
+ // VS Code and Cursor user settings are JSONC: line/block comments and trailing
34
+ // commas are valid and common. Strict JSON.parse rejects them, producing a
35
+ // false "unreadable" parity failure on a perfectly valid editor config. This
36
+ // strips comments string-aware (so `//` inside a "https://…" value survives)
37
+ // and drops trailing commas before parsing.
38
+
39
+ function parseJsonc(text) {
40
+ let out = '';
41
+ let inString = false;
42
+ let escaped = false;
43
+ for (let i = 0; i < text.length; i += 1) {
44
+ const ch = text[i];
45
+ const next = text[i + 1];
46
+ if (inString) {
47
+ out += ch;
48
+ if (escaped) escaped = false;
49
+ else if (ch === '\\') escaped = true;
50
+ else if (ch === '"') inString = false;
51
+ continue;
52
+ }
53
+ if (ch === '"') { inString = true; out += ch; continue; }
54
+ if (ch === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i += 1; out += '\n'; continue; }
55
+ if (ch === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1; i += 1; continue; }
56
+ out += ch;
57
+ }
58
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
59
+ }
60
+
61
+ function readJsoncFile(file) {
62
+ return parseJsonc(fs.readFileSync(file, 'utf8'));
63
+ }
64
+
33
65
  function loadRegistry(rootDir = ROOT_DIR) {
34
66
  const file = path.join(rootDir, 'specialists', 'registry.json');
35
67
  return JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -130,7 +162,7 @@ function checkOpenCode(registry, { homeDir = os.homedir() } = {}) {
130
162
  if (!fs.existsSync(file)) return { surface: 'opencode', kind: 'agents', status: 'absent', file };
131
163
  let config;
132
164
  try {
133
- config = JSON.parse(fs.readFileSync(file, 'utf8'));
165
+ config = readJsoncFile(file);
134
166
  } catch (err) {
135
167
  return { surface: 'opencode', kind: 'agents', status: 'unreadable', file, error: err.message };
136
168
  }
@@ -183,7 +215,7 @@ function checkVSCode(registry, { homeDir = os.homedir() } = {}) {
183
215
  const actualSet = new Set();
184
216
  for (const settingsPath of paths) {
185
217
  try {
186
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
218
+ const settings = readJsoncFile(settingsPath);
187
219
  for (const id of Object.keys(settings['github.copilot.mcpServers'] ?? {})) actualSet.add(id);
188
220
  } catch (err) {
189
221
  return { surface: 'vscode', kind: 'mcps', status: 'unreadable', file: settingsPath, error: err.message };
@@ -208,7 +240,7 @@ function checkCursor(registry, { homeDir = os.homedir() } = {}) {
208
240
  if (!fs.existsSync(file)) return { surface: 'cursor', kind: 'mcps', status: 'absent', file };
209
241
  let config;
210
242
  try {
211
- config = JSON.parse(fs.readFileSync(file, 'utf8'));
243
+ config = readJsoncFile(file);
212
244
  } catch (err) {
213
245
  return { surface: 'cursor', kind: 'mcps', status: 'unreadable', file, error: err.message };
214
246
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "type": "module",
5
+ "packageManager": "npm@11.5.1",
5
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
6
7
  "bin": {
7
8
  "construct": "bin/construct"