@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.
- package/README.md +2 -0
- package/bin/construct +79 -0
- package/lib/cli-commands.mjs +24 -1
- package/lib/config/schema.mjs +49 -1
- package/lib/decisions/enforced-baseline.json +3 -0
- package/lib/document-ingest.mjs +80 -6
- package/lib/embedded-contract/capability.mjs +3 -3
- package/lib/embedded-contract/contract-version.mjs +1 -1
- package/lib/embedded-contract/execution.mjs +184 -0
- package/lib/embedded-contract/index.mjs +16 -0
- package/lib/embedded-contract/ingest.mjs +61 -7
- package/lib/embedded-contract/triage.mjs +24 -2
- package/lib/embedded-contract/workflow-invoke.mjs +21 -0
- package/lib/hooks/_lib/output-mode.mjs +101 -0
- package/lib/hooks/session-start.mjs +13 -1
- package/lib/ingest/provider-extract.mjs +200 -0
- package/lib/ingest/strategy.mjs +127 -0
- package/lib/mcp/server.mjs +72 -4
- package/lib/mcp/tools/embedded-contract.mjs +17 -1
- package/lib/orchestration/run-store-postgres.mjs +85 -0
- package/lib/orchestration/run-store-sqlite.mjs +122 -0
- package/lib/orchestration/run-store.mjs +82 -0
- package/lib/orchestration/runtime.mjs +317 -0
- package/lib/orchestration/store.mjs +102 -0
- package/lib/orchestration/worker.mjs +167 -0
- package/lib/parity.mjs +35 -3
- package/package.json +2 -1
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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"
|