@geraldmaron/construct 1.0.19 → 1.0.20

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.
@@ -12,11 +12,12 @@ import { resolveEmbeddedModel as resolveModelCore } from '../../embedded-contrac
12
12
  import { recommendPlan as recommendPlanCore } from '../../embedded-contract/triage.mjs';
13
13
  import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/workflow-invoke.mjs';
14
14
  import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
15
+ import { resolveExecution as resolveExecutionCore } from '../../embedded-contract/execution.mjs';
15
16
  import { resolveInput } from '../../embedded-contract/ingest.mjs';
16
17
 
17
18
  async function ingestArgs(args) {
18
19
  if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
19
- const resolved = await resolveInput({ filePath: args.file_path });
20
+ const resolved = await resolveInput({ filePath: args.file_path, strategy: args.ingest_strategy });
20
21
  return { input: resolved.text, ingestion: resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion };
21
22
  }
22
23
 
@@ -75,3 +76,18 @@ export async function workflowInvoke(args = {}) {
75
76
  export function capabilityDescribe(args = {}) {
76
77
  return wrapContractResult(buildCapabilityContract({ rootDir: args.root_dir }), { surface: 'mcp' });
77
78
  }
79
+
80
+ export function executionResolve(args = {}) {
81
+ const request = {
82
+ workflowType: args.workflow_type,
83
+ requestedStrategy: args.requested_strategy,
84
+ useConstruct: args.use_construct !== false,
85
+ host: args.host,
86
+ hostModel: args.host_model,
87
+ hostProvider: args.host_provider,
88
+ requestedTier: args.requested_tier,
89
+ capabilities: csv(args.capabilities),
90
+ allowCrossProviderFallback: Boolean(args.allow_cross_provider_fallback),
91
+ };
92
+ return wrapContractResult(resolveExecutionCore(request), { surface: 'mcp' });
93
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * lib/orchestration/run-store.mjs — filesystem-backed persistence for the local
3
+ * orchestration runtime (Mode-A, zero-dependency).
4
+ *
5
+ * Orchestration runs and their task lifecycle live as JSON under the project's
6
+ * `.cx/runtime/orchestration/runs/`, so a run survives editor/host restarts and
7
+ * is inspectable without a daemon, a database, or Docker. Writes are atomic
8
+ * (temp file then rename) so a crash mid-write cannot leave a half-written run;
9
+ * reads tolerate a corrupt file by skipping it rather than throwing. This is the
10
+ * Mode-A backend; Mode-B/C (SQLite/Postgres) would implement the same load/save
11
+ * surface without changing callers.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync } from 'node:fs';
15
+ import { isAbsolute, join, resolve } from 'node:path';
16
+
17
+ const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
18
+
19
+ let writeCounter = 0;
20
+
21
+ function projectRoot(cwd) {
22
+ return isAbsolute(cwd) ? cwd : resolve(cwd);
23
+ }
24
+
25
+ export function runtimeDir(cwd = process.cwd()) {
26
+ return join(projectRoot(cwd), RUNTIME_REL);
27
+ }
28
+
29
+ function runsDir(cwd) {
30
+ return join(runtimeDir(cwd), 'runs');
31
+ }
32
+
33
+ // Atomic JSON write: a same-directory temp file then rename, which is atomic on
34
+ // POSIX filesystems. A per-process counter keeps concurrent writers from
35
+ // colliding on the temp name before the rename lands.
36
+
37
+ function atomicWriteJson(filePath, value) {
38
+ writeCounter = (writeCounter + 1) % 100000;
39
+ const tmp = `${filePath}.${process.pid}.${writeCounter}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
41
+ renameSync(tmp, filePath);
42
+ }
43
+
44
+ export function saveRun(cwd, run) {
45
+ const dir = runsDir(cwd);
46
+ mkdirSync(dir, { recursive: true });
47
+ atomicWriteJson(join(dir, `${run.runId}.json`), run);
48
+ return run;
49
+ }
50
+
51
+ export function loadRun(cwd, runId) {
52
+ if (!runId) return null;
53
+ const file = join(runsDir(cwd), `${runId}.json`);
54
+ if (!existsSync(file)) return null;
55
+ try {
56
+ return JSON.parse(readFileSync(file, 'utf8'));
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function listRuns(cwd, { limit = 20 } = {}) {
63
+ const dir = runsDir(cwd);
64
+ if (!existsSync(dir)) return [];
65
+ const runs = [];
66
+ for (const name of readdirSync(dir)) {
67
+ if (!name.endsWith('.json')) continue;
68
+ try {
69
+ runs.push(JSON.parse(readFileSync(join(dir, name), 'utf8')));
70
+ } catch {
71
+ /* a corrupt run file is skipped, not fatal to listing */
72
+ }
73
+ }
74
+ runs.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
75
+ return runs.slice(0, limit).map((run) => ({
76
+ runId: run.runId,
77
+ status: run.status,
78
+ executionMode: run.execution?.executionMode || null,
79
+ createdAt: run.createdAt,
80
+ request: run.request?.summary || null,
81
+ }));
82
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime (Mode-A).
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 is the
8
+ * Mode-A (zero-dependency, single-process, filesystem-backed) slice of that
9
+ * runtime: it intakes a request, plans/decomposes it into a sequenced specialist
10
+ * chain (reusing the orchestration-policy planner), resolves the execution-
11
+ * capability contract (reusing resolveExecution), persists a durable run + task
12
+ * lifecycle (run-store), and emits lifecycle traces.
13
+ *
14
+ * Honesty boundary (ADR-0020, extending ADR-0019): the Mode-A worker backend is
15
+ * `inline` — it OWNS planning, sequencing, handoff state, persistence, and
16
+ * observability, and PREPARES each specialist task (role, reason, handoff
17
+ * contract) for a downstream executor. It does NOT itself perform specialist LLM
18
+ * reasoning; a provider-backed worker backend is a separate, later backend. The
19
+ * run reports `workerBackend` and per-task `executor` so a host can never mistake
20
+ * a prepared task for executed specialist output. When the execution contract
21
+ * resolves to prompt-only or host-direct, Construct owns no specialist task
22
+ * sequence and the run records that explicitly rather than implying orchestration.
23
+ */
24
+
25
+ import { randomBytes } from 'node:crypto';
26
+
27
+ import { routeRequest } from '../orchestration-policy.mjs';
28
+ import { resolveExecution } from '../embedded-contract/execution.mjs';
29
+ import { detectHostCapabilities } from '../host-capabilities.mjs';
30
+ import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
31
+ import { saveRun, loadRun, listRuns } from './run-store.mjs';
32
+
33
+ export const WORKER_BACKENDS = ['inline'];
34
+ export const DEFAULT_WORKER_BACKEND = 'inline';
35
+
36
+ const RUNTIME_SEMANTICS = 'Mode-A inline 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.';
37
+
38
+ function newRunId() {
39
+ return `run-${randomBytes(6).toString('hex')}`;
40
+ }
41
+
42
+ function truncate(text, max) {
43
+ const s = String(text || '');
44
+ return s.length > max ? `${s.slice(0, max)}…` : s;
45
+ }
46
+
47
+ // hostRole reports how the calling host orchestrates, so an adapter can decide
48
+ // whether to delegate to Construct's runtime (its reason for being). A direct
49
+ // CLI call has no host; an unrecognized host name is reported as unknown rather
50
+ // than guessed.
51
+
52
+ function resolveHostRole(hostName) {
53
+ if (!hostName) return 'cli-direct';
54
+ const match = detectHostCapabilities().find((h) => h.host.toLowerCase() === String(hostName).toLowerCase());
55
+ return match ? match.orchestration : 'unknown';
56
+ }
57
+
58
+ function pickExecution(data) {
59
+ return {
60
+ executionMode: data.executionMode,
61
+ effectiveStrategy: data.effectiveStrategy,
62
+ requestedStrategy: data.requestedStrategy,
63
+ constructCapabilitiesActive: data.constructCapabilitiesActive,
64
+ degraded: data.degraded,
65
+ degradationReason: data.degradationReason,
66
+ selectedProvider: data.selectedProvider,
67
+ selectedModel: data.selectedModel,
68
+ resolutionSource: data.resolutionSource,
69
+ orchestrationPlanned: data.orchestrationPlanned,
70
+ orchestrationAvailable: data.orchestrationAvailable,
71
+ deploymentMode: data.deploymentMode,
72
+ };
73
+ }
74
+
75
+ function buildTasks(route) {
76
+ const reasons = route.dispatchReasons || {};
77
+ const handoffByProducer = new Map();
78
+ for (const edge of route.contractChain || []) {
79
+ const producer = edge.contract?.producer;
80
+ if (producer && !handoffByProducer.has(producer)) handoffByProducer.set(producer, edge.contract.id);
81
+ }
82
+ return (route.specialists || []).map((role, i) => ({
83
+ id: `t${i + 1}`,
84
+ seq: i,
85
+ role,
86
+ reason: reasons[role] || null,
87
+ handoffContract: handoffByProducer.get(role.replace(/^cx-/, '')) || null,
88
+ status: 'queued',
89
+ executor: null,
90
+ startedAt: null,
91
+ finishedAt: null,
92
+ }));
93
+ }
94
+
95
+ /**
96
+ * Plan a run: resolve the execution contract, decompose into a specialist chain,
97
+ * and persist a durable run record (status `planned`). Pure of model calls.
98
+ *
99
+ * @param {object} request
100
+ * @param {object} [opts] { env, cwd }
101
+ * @returns {object} the persisted run
102
+ */
103
+ export function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
104
+ const {
105
+ request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
106
+ host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
107
+ } = request;
108
+
109
+ const execData = resolveExecution(
110
+ { workflowType, requestedStrategy, useConstruct, host, hostModel, hostProvider },
111
+ { env, cwd },
112
+ );
113
+ const route = routeRequest({ request: text, fileCount, moduleCount });
114
+
115
+ const traceId = newTraceId();
116
+ const runId = newRunId();
117
+ const now = new Date().toISOString();
118
+
119
+ // Construct owns a specialist task sequence only when the contract resolves to
120
+ // orchestrated execution; prompt-only and host-direct own none, and the run
121
+ // records that rather than implying orchestration that did not happen.
122
+ const orchestrates = execData.effectiveStrategy === 'orchestrated';
123
+ const tasks = orchestrates ? buildTasks(route) : [];
124
+
125
+ const run = {
126
+ runId,
127
+ traceId,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ request: { summary: truncate(text, 200), workflowType, requestedStrategy },
131
+ hostRole: resolveHostRole(host),
132
+ workerBackend: DEFAULT_WORKER_BACKEND,
133
+ execution: pickExecution(execData),
134
+ plan: {
135
+ intent: route.intent,
136
+ track: route.track,
137
+ specialists: route.specialists || [],
138
+ dispatchPlan: route.dispatchPlan,
139
+ contractChain: (route.contractChain || []).map((e) => ({ id: e.contract?.id, producer: e.contract?.producer, consumer: e.contract?.consumer, stage: e.stage })),
140
+ },
141
+ tasks,
142
+ status: 'planned',
143
+ warnings: execData.warnings || [],
144
+ semantics: RUNTIME_SEMANTICS,
145
+ executionSemantics: execData.semantics,
146
+ };
147
+
148
+ saveRun(cwd, run);
149
+ emitTraceEvent({
150
+ rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
151
+ metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
152
+ });
153
+ return run;
154
+ }
155
+
156
+ /**
157
+ * Execute a planned run through the inline (Mode-A) worker backend: advance each
158
+ * task queued → running → prepared, persisting after each transition so the run
159
+ * is resumable and observable. The inline backend prepares tasks; it does not
160
+ * perform specialist LLM reasoning.
161
+ *
162
+ * @param {string} cwd
163
+ * @param {string} runId
164
+ * @param {object} [opts] { env }
165
+ * @returns {object} the completed run
166
+ */
167
+ export function executeRun(cwd, runId, { env = process.env } = {}) {
168
+ const run = loadRun(cwd, runId);
169
+ if (!run) {
170
+ const err = new Error(`Orchestration run not found: ${runId}`);
171
+ err.code = 'RUN_NOT_FOUND';
172
+ throw err;
173
+ }
174
+
175
+ run.status = 'running';
176
+ run.updatedAt = new Date().toISOString();
177
+ saveRun(cwd, run);
178
+
179
+ for (const task of run.tasks) {
180
+ task.status = 'running';
181
+ task.startedAt = new Date().toISOString();
182
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId } });
183
+
184
+ task.executor = 'inline:prepared';
185
+ task.status = 'prepared';
186
+ task.finishedAt = new Date().toISOString();
187
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: 'prepared' } });
188
+
189
+ run.updatedAt = new Date().toISOString();
190
+ saveRun(cwd, run);
191
+ }
192
+
193
+ run.status = 'completed';
194
+ run.updatedAt = new Date().toISOString();
195
+ saveRun(cwd, run);
196
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
197
+ return run;
198
+ }
199
+
200
+ /**
201
+ * Plan and execute in one call (the common `orchestrate run` path).
202
+ */
203
+ export function runOrchestration(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
204
+ const planned = planRun(request, { env, cwd });
205
+ return executeRun(cwd, planned.runId, { env });
206
+ }
207
+
208
+ /**
209
+ * The structured metadata a host adapter consumes for runtime-backed integration.
210
+ */
211
+ export function hostAdapterMetadata(run) {
212
+ const e = run.execution || {};
213
+ return {
214
+ runId: run.runId,
215
+ traceId: run.traceId,
216
+ status: run.status,
217
+ requestedStrategy: e.requestedStrategy,
218
+ effectiveStrategy: e.effectiveStrategy,
219
+ executionMode: e.executionMode,
220
+ constructCapabilitiesActive: e.constructCapabilitiesActive,
221
+ workerBackend: run.workerBackend,
222
+ hostRole: run.hostRole,
223
+ degraded: e.degraded,
224
+ degradationReason: e.degradationReason,
225
+ selectedProvider: e.selectedProvider,
226
+ selectedModel: e.selectedModel,
227
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor })),
228
+ warnings: run.warnings || [],
229
+ semantics: run.semantics,
230
+ executionSemantics: run.executionSemantics,
231
+ };
232
+ }
233
+
234
+ export function getRun(cwd, runId) {
235
+ return loadRun(cwd, runId);
236
+ }
237
+
238
+ export function getRuns(cwd, opts) {
239
+ return listRuns(cwd, opts);
240
+ }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
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"