@geraldmaron/construct 1.0.20 → 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/bin/construct CHANGED
@@ -3380,7 +3380,7 @@ async function cmdOrchestrate(args) {
3380
3380
  fileCount: Number(flag('--file-count') || 0),
3381
3381
  moduleCount: Number(flag('--module-count') || 0),
3382
3382
  };
3383
- const run = rest.includes('--no-execute') ? planRun(request, {}) : runOrchestration(request, {});
3383
+ const run = rest.includes('--no-execute') ? await planRun(request, {}) : await runOrchestration(request, {});
3384
3384
  if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3385
3385
  println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode} · backend=${run.workerBackend} · hostRole=${run.hostRole}`);
3386
3386
  if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
@@ -3393,14 +3393,14 @@ async function cmdOrchestrate(args) {
3393
3393
  const { getRun, getRuns, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3394
3394
  const runId = rest.find((a) => !a.startsWith('--'));
3395
3395
  if (runId) {
3396
- const run = getRun(process.cwd(), runId);
3396
+ const run = await getRun(process.cwd(), runId);
3397
3397
  if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
3398
3398
  if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3399
3399
  println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
3400
3400
  for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
3401
3401
  return;
3402
3402
  }
3403
- const runs = getRuns(process.cwd());
3403
+ const runs = await getRuns(process.cwd());
3404
3404
  if (wantsJson) { println(JSON.stringify(runs, null, 2)); return; }
3405
3405
  if (!runs.length) { println('No orchestration runs recorded.'); return; }
3406
3406
  for (const r of runs) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
@@ -250,7 +250,7 @@ export const CLI_COMMANDS = [
250
250
  category: 'Work',
251
251
  core: false,
252
252
  description: 'Convert documents to indexed markdown',
253
- usage: 'construct ingest <file> [--strategy=adapter|provider] [--strict] [--legacy-extractor]',
253
+ usage: 'construct ingest <file> [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--strict] [--legacy-extractor]',
254
254
  },
255
255
  {
256
256
  name: 'infer',
@@ -26,6 +26,10 @@ export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cu
26
26
 
27
27
  export const INGEST_STRATEGIES = ['adapter', 'provider'];
28
28
  export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
29
+ export const INGEST_ORCHESTRATIONS = ['prompt-only', 'orchestrated'];
30
+
31
+ export const ORCHESTRATION_WORKER_BACKENDS = ['inline', 'provider'];
32
+ export const ORCHESTRATION_STORES = ['filesystem', 'sqlite', 'postgres'];
29
33
 
30
34
  // SessionStart context routing. `auto` keeps the rich payload on stdout for
31
35
  // interactive sessions and suppresses it (to a debug log) for non-interactive /
@@ -47,6 +51,11 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
47
51
  ingest: Object.freeze({
48
52
  strategy: 'adapter',
49
53
  fallback: 'none',
54
+ orchestration: 'prompt-only',
55
+ }),
56
+ orchestration: Object.freeze({
57
+ workerBackend: 'inline',
58
+ store: 'filesystem',
50
59
  }),
51
60
  telemetry: Object.freeze({
52
61
  enabled: true,
@@ -105,6 +114,15 @@ export const FIELD_RULES = {
105
114
  fields: {
106
115
  strategy: { type: 'string', enum: INGEST_STRATEGIES },
107
116
  fallback: { type: 'string', enum: INGEST_FALLBACKS },
117
+ orchestration: { type: 'string', enum: INGEST_ORCHESTRATIONS },
118
+ },
119
+ },
120
+ orchestration: {
121
+ type: 'object',
122
+ required: false,
123
+ fields: {
124
+ workerBackend: { type: 'string', enum: ORCHESTRATION_WORKER_BACKENDS },
125
+ store: { type: 'string', enum: ORCHESTRATION_STORES },
108
126
  },
109
127
  },
110
128
  telemetry: {
@@ -6,6 +6,7 @@
6
6
  "ADR-0018",
7
7
  "ADR-0019",
8
8
  "ADR-0020",
9
+ "ADR-0021",
9
10
  "any-to-business-strategist",
10
11
  "any-to-explorer",
11
12
  "architect-to-data-engineer",
@@ -14,7 +14,7 @@ import { syncFileStateToSql } from './storage/sync.mjs';
14
14
  import { stampFrontmatter } from './doc-stamp.mjs';
15
15
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
16
16
  import { loadProjectConfig } from './config/project-config.mjs';
17
- import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
17
+ import { resolveIngestStrategy, INGEST_STRATEGIES, INGEST_ORCHESTRATION_STRATEGIES } from './ingest/strategy.mjs';
18
18
  import { extractViaProvider } from './ingest/provider-extract.mjs';
19
19
 
20
20
  const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
@@ -167,6 +167,7 @@ export async function ingestDocuments(inputPaths, {
167
167
  strict = false,
168
168
  highFidelity = true,
169
169
  strategy = null,
170
+ orchestration = null,
170
171
  env = process.env,
171
172
  } = {}) {
172
173
  if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
@@ -182,7 +183,7 @@ export async function ingestDocuments(inputPaths, {
182
183
  }
183
184
 
184
185
  const { config } = loadProjectConfig(cwd, env);
185
- const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
186
+ const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy, orchestrationOverride: orchestration, cwd });
186
187
 
187
188
  const results = [];
188
189
  let totalDrops = 0;
@@ -266,9 +267,11 @@ export async function ingestDocuments(inputPaths, {
266
267
  ingestion: {
267
268
  strategy: resolvedStrategy.strategy,
268
269
  fallback: resolvedStrategy.fallback,
270
+ orchestration: resolvedStrategy.orchestration,
269
271
  model: resolvedStrategy.model,
270
272
  provider: resolvedStrategy.provider,
271
273
  fallbackApplied,
274
+ execution: resolvedStrategy.execution,
272
275
  },
273
276
  };
274
277
  }
@@ -282,6 +285,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
282
285
  let strict = false;
283
286
  let highFidelity = true;
284
287
  let strategy = null;
288
+ let orchestration = null;
285
289
 
286
290
  for (const arg of argv) {
287
291
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
@@ -289,6 +293,7 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
289
293
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
290
294
  else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
291
295
  else if (arg === '--strategy') strategy = '';
296
+ else if (arg.startsWith('--orchestration=')) orchestration = arg.split('=').slice(1).join('=');
292
297
  else if (arg === '--sync') sync = true;
293
298
  else if (arg === '--strict') strict = true;
294
299
  else if (arg === '--legacy-extractor') highFidelity = false;
@@ -299,8 +304,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
299
304
  if (inputs.length === 0) {
300
305
  throw new Error(
301
306
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
302
- `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
307
+ `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--orchestration=prompt-only|orchestrated] [--sync] [--strict] [--legacy-extractor]\n` +
303
308
  ` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
309
+ ` --orchestration: prompt-only (deterministic extraction) or orchestrated (engage the specialist chain)\n` +
304
310
  ` --strict: exit non-zero if any extraction drops occur\n` +
305
311
  ` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
306
312
  ` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
@@ -309,6 +315,9 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
309
315
  if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
310
316
  throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
311
317
  }
318
+ if (orchestration !== null && !INGEST_ORCHESTRATION_STRATEGIES.includes(orchestration)) {
319
+ throw new Error(`Unsupported orchestration: ${orchestration}. Valid values: ${INGEST_ORCHESTRATION_STRATEGIES.join(', ')}`);
320
+ }
312
321
  const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
313
322
  if (!['sibling', ...knowledgeTargets].includes(target)) {
314
323
  throw new Error(
@@ -316,5 +325,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
316
325
  );
317
326
  }
318
327
 
319
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
328
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, orchestration: orchestration || null, env });
320
329
  }
@@ -16,6 +16,7 @@ import { classifyRdIntake } from '../intake/classify.mjs';
16
16
  import { getDeploymentMode } from '../deployment-mode.mjs';
17
17
  import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
18
18
  import { workflowTypeForIntake } from './workflow-defs.mjs';
19
+ import { resolveExecution } from './execution.mjs';
19
20
 
20
21
  const CONFIDENCE_FLOOR = 0.6;
21
22
 
@@ -46,7 +47,7 @@ function buildRiskFactors(triage) {
46
47
  * @returns {object}
47
48
  */
48
49
  export function recommendPlan(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
49
- const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null } = request;
50
+ const { input = '', sourcePath = '', artifactType, availableRoles, profile = null, ingestion = null, host, hostModel, hostProvider, constructStrategy = 'auto' } = request;
50
51
  const warnings = [];
51
52
 
52
53
  // Surface extraction provenance and flag low-yield/truncated inputs so a
@@ -109,6 +110,26 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
109
110
  if (canExecute) nextStepOptions.push({ action: 'invoke-workflow', description: 'Invoke the recommended role chain as an embedded workflow.' });
110
111
  if (!canExecute) nextStepOptions.push({ action: 'clarify', description: 'Request more detail; classification confidence is low or unknown.' });
111
112
 
113
+ const suggestedWorkflowType = workflowTypeForIntake(triage.intakeType);
114
+
115
+ // Execution preview: forecast the executionMode an invocation of the suggested
116
+ // workflow would resolve to, but only when the host supplies context — absent
117
+ // it, forcing a model resolution on every triage call would be wasteful and
118
+ // the preview would be uninformative. null keeps the field parity-stable.
119
+ let execution = null;
120
+ if (suggestedWorkflowType && (hostModel || hostProvider || (constructStrategy && constructStrategy !== 'auto'))) {
121
+ const e = resolveExecution({ workflowType: suggestedWorkflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider }, { env, cwd });
122
+ execution = {
123
+ executionMode: e.executionMode,
124
+ effectiveStrategy: e.effectiveStrategy,
125
+ requestedStrategy: e.requestedStrategy,
126
+ constructCapabilitiesActive: e.constructCapabilitiesActive,
127
+ degraded: e.degraded,
128
+ degradationReason: e.degradationReason,
129
+ semantics: e.semantics,
130
+ };
131
+ }
132
+
112
133
  return {
113
134
  classification: { intakeType: triage.intakeType, rdStage: triage.rdStage },
114
135
  ingestion,
@@ -117,7 +138,8 @@ export function recommendPlan(request = {}, { env = process.env, cwd = process.c
117
138
  primaryOwner,
118
139
  recommendedAction: triage.recommendedAction,
119
140
  recommendedChain: chain,
120
- suggestedWorkflowType: workflowTypeForIntake(triage.intakeType),
141
+ suggestedWorkflowType,
142
+ execution,
121
143
  roleRationale: rationale,
122
144
  suggestedSkills,
123
145
  evidenceRequirements,
@@ -26,6 +26,7 @@ import { recordApprovalRequest } from '../roles/approval-surface.mjs';
26
26
  import { getWorkflowDef, WORKFLOW_TYPES } from './workflow-defs.mjs';
27
27
  import { roleMap, roleRationale, skillsForChain, contractFacts } from './role-facts.mjs';
28
28
  import { resolveEmbeddedModel } from './model-resolve.mjs';
29
+ import { resolveExecution } from './execution.mjs';
29
30
  import { resolveWriteGate, newTraceId, DEFAULT_APPROVAL_MODE } from './audit.mjs';
30
31
 
31
32
  const VALID_STRATEGIES = ['auto', 'explicit', 'constrained'];
@@ -91,6 +92,7 @@ function errorResult({ workflowId, traceId, approvalMode, code, message, warning
91
92
  * @param {string} [request.host]
92
93
  * @param {string} [request.hostModel]
93
94
  * @param {string} [request.hostProvider]
95
+ * @param {string} [request.constructStrategy] orchestrated | prompt-only | auto (execution mode)
94
96
  * @param {object} [opts] { env, cwd }
95
97
  * @returns {Promise<object>}
96
98
  */
@@ -98,6 +100,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
98
100
  const {
99
101
  workflowType, context = {}, roleStrategy = 'auto', requestedRoles,
100
102
  approvalMode, trace = true, host, hostModel, hostProvider, ingestion = null,
103
+ constructStrategy = 'auto',
101
104
  } = request;
102
105
  const warnings = [];
103
106
  if (ingestion) {
@@ -129,6 +132,23 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
129
132
  );
130
133
  for (const w of modelWarnings) warnings.push(`model-resolution: ${w}`);
131
134
 
135
+ // The execution-capability contract reports the PLANNED executionMode for this
136
+ // run (descriptive, not enforced — ADR-0019). Construct returns a plan; the
137
+ // host runtime executes it, so this never claims observed specialist execution.
138
+ const executionData = resolveExecution(
139
+ { workflowType, requestedStrategy: constructStrategy, host, hostModel, hostProvider, requestedTier: def.tier },
140
+ { env, cwd },
141
+ );
142
+ const execution = {
143
+ executionMode: executionData.executionMode,
144
+ effectiveStrategy: executionData.effectiveStrategy,
145
+ requestedStrategy: executionData.requestedStrategy,
146
+ constructCapabilitiesActive: executionData.constructCapabilitiesActive,
147
+ degraded: executionData.degraded,
148
+ degradationReason: executionData.degradationReason,
149
+ semantics: executionData.semantics,
150
+ };
151
+
132
152
  const primaryOwner = selectedRoles[0];
133
153
  const facts = contractFacts(primaryOwner);
134
154
  const skillsApplied = skillsForChain(selectedRoles, map);
@@ -204,6 +224,7 @@ export async function invokeWorkflow(request = {}, { env = process.env, cwd = pr
204
224
  roleRationale: rationale,
205
225
  skillsApplied,
206
226
  modelResolution,
227
+ execution,
207
228
  outputs,
208
229
  recommendations,
209
230
  evidence: { requirements: facts.evidenceRequirements, satisfied, missing: missingEvidence, traceId },
@@ -15,12 +15,21 @@
15
15
 
16
16
  import { resolveSetting } from '../config/project-config.mjs';
17
17
  import { resolveEmbeddedModel } from '../embedded-contract/model-resolve.mjs';
18
+ import { resolveExecution } from '../embedded-contract/execution.mjs';
18
19
 
19
20
  export const INGEST_STRATEGIES = ['adapter', 'provider'];
20
21
  export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
21
22
 
23
+ // Orchestration is a second, independent axis from extraction (adapter|provider):
24
+ // `prompt-only` runs ingest as a deterministic extraction pass; `orchestrated`
25
+ // engages the Construct specialist chain for the evidence-ingest workflow. Both
26
+ // axes coexist — extraction is HOW bytes become text, orchestration is WHETHER
27
+ // specialists process the result.
28
+ export const INGEST_ORCHESTRATION_STRATEGIES = ['prompt-only', 'orchestrated'];
29
+
22
30
  export const DEFAULT_INGEST_STRATEGY = 'adapter';
23
31
  export const DEFAULT_INGEST_FALLBACK = 'none';
32
+ export const DEFAULT_INGEST_ORCHESTRATION = 'prompt-only';
24
33
 
25
34
  function coerceEnum(value, allowed) {
26
35
  if (typeof value !== 'string') return null;
@@ -55,11 +64,13 @@ function resolveProviderModel({ env, registryPath }) {
55
64
  * @param {object} opts
56
65
  * @param {object} [opts.config] loaded project config object
57
66
  * @param {Record<string,string>} [opts.env]
58
- * @param {string} [opts.override] explicit strategy override (CLI flag)
67
+ * @param {string} [opts.override] explicit extraction strategy override (CLI flag)
68
+ * @param {string} [opts.orchestrationOverride] explicit orchestration override (CLI flag)
69
+ * @param {string} [opts.cwd]
59
70
  * @param {string} [opts.registryPath]
60
- * @returns {{strategy:string, fallback:string, model:(string|null), provider:(string|null), modelResolution:(object|null)}}
71
+ * @returns {{strategy:string, fallback:string, orchestration:string, model:(string|null), provider:(string|null), modelResolution:(object|null), execution:object}}
61
72
  */
62
- export function resolveIngestStrategy({ config = null, env = process.env, override = null, registryPath = null } = {}) {
73
+ export function resolveIngestStrategy({ config = null, env = process.env, override = null, orchestrationOverride = null, cwd = process.cwd(), registryPath = null } = {}) {
63
74
  const overrideStrategy = coerceEnum(override, INGEST_STRATEGIES);
64
75
 
65
76
  const fromSettings = resolveSetting({
@@ -82,14 +93,35 @@ export function resolveIngestStrategy({ config = null, env = process.env, overri
82
93
  });
83
94
  const fallback = coerceEnum(fallbackSetting.value, INGEST_FALLBACKS) || DEFAULT_INGEST_FALLBACK;
84
95
 
96
+ const orchestrationSetting = resolveSetting({
97
+ config,
98
+ jsonPath: 'ingest.orchestration',
99
+ env,
100
+ envKey: 'CONSTRUCT_INGEST_ORCHESTRATION',
101
+ defaultValue: DEFAULT_INGEST_ORCHESTRATION,
102
+ });
103
+ const orchestration = coerceEnum(orchestrationOverride, INGEST_ORCHESTRATION_STRATEGIES)
104
+ || coerceEnum(orchestrationSetting.value, INGEST_ORCHESTRATION_STRATEGIES)
105
+ || DEFAULT_INGEST_ORCHESTRATION;
106
+
85
107
  const needsProviderModel = strategy === 'provider' || fallback === 'provider';
86
108
  const modelResolution = needsProviderModel ? resolveProviderModel({ env, registryPath }) : null;
87
109
 
110
+ // The execution-capability contract is the single source of truth for whether
111
+ // the ingest workflow will actually orchestrate; reuse it rather than deriving
112
+ // a second answer here.
113
+ const { warnings, modelResolution: _drop, ...execution } = resolveExecution(
114
+ { workflowType: INGEST_WORKFLOW_TYPE, requestedStrategy: orchestration },
115
+ { env, cwd, registryPath },
116
+ );
117
+
88
118
  return {
89
119
  strategy,
90
120
  fallback,
121
+ orchestration,
91
122
  model: modelResolution?.model || null,
92
123
  provider: modelResolution?.provider || null,
93
124
  modelResolution,
125
+ execution,
94
126
  };
95
127
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * lib/orchestration/run-store-postgres.mjs — Postgres-backed run store (Mode-C).
3
+ *
4
+ * Mirrors lib/intake/postgres-queue.mjs: a class taking the tagged-template `sql`
5
+ * client from createSqlClient(env) (porsager/postgres style) and a project key.
6
+ * Orchestration runs persist to `construct_orchestration_runs`; triage-like
7
+ * columns (status, execution_mode) are flattened for filtering while the full run
8
+ * round-trips in `payload jsonb`. Multi-project isolation comes from the
9
+ * (run_id, project) composite primary key.
10
+ *
11
+ * Scope: the STORE is the deliverable. Container-worker spawning (Mode-C's
12
+ * optional worker topology) is out of scope (ADR-0021); a Postgres run store lets
13
+ * a team/enterprise deployment share durable runs across processes today.
14
+ */
15
+
16
+ export class PostgresRunStore {
17
+ constructor({ sql, project } = {}) {
18
+ if (!sql) throw new Error('PostgresRunStore: sql client is required');
19
+ if (!project) throw new Error('PostgresRunStore: project is required');
20
+ this.sql = sql;
21
+ this.project = project;
22
+ }
23
+
24
+ async ensureSchema() {
25
+ await this.sql`
26
+ CREATE TABLE IF NOT EXISTS construct_orchestration_runs (
27
+ run_id TEXT,
28
+ project TEXT,
29
+ created_at TEXT,
30
+ status TEXT,
31
+ execution_mode TEXT,
32
+ payload JSONB,
33
+ PRIMARY KEY (run_id, project)
34
+ )
35
+ `;
36
+ }
37
+
38
+ async saveRun(run) {
39
+ if (!run?.runId) throw new Error('saveRun: run.runId is required');
40
+ await this.sql`
41
+ INSERT INTO construct_orchestration_runs (
42
+ run_id, project, created_at, status, execution_mode, payload
43
+ ) VALUES (
44
+ ${run.runId}, ${this.project}, ${run.createdAt || null},
45
+ ${run.status || null}, ${run.execution?.executionMode || null},
46
+ ${this.sql.json(run)}
47
+ )
48
+ ON CONFLICT (run_id, project) DO UPDATE SET
49
+ created_at = EXCLUDED.created_at,
50
+ status = EXCLUDED.status,
51
+ execution_mode = EXCLUDED.execution_mode,
52
+ payload = EXCLUDED.payload
53
+ `;
54
+ return run;
55
+ }
56
+
57
+ async loadRun(runId) {
58
+ if (!runId) return null;
59
+ const rows = await this.sql`
60
+ SELECT payload FROM construct_orchestration_runs
61
+ WHERE run_id = ${runId} AND project = ${this.project}
62
+ LIMIT 1
63
+ `;
64
+ return rows[0]?.payload || null;
65
+ }
66
+
67
+ async listRuns({ limit = 20 } = {}) {
68
+ const rows = await this.sql`
69
+ SELECT payload FROM construct_orchestration_runs
70
+ WHERE project = ${this.project}
71
+ ORDER BY created_at DESC
72
+ LIMIT ${limit}
73
+ `;
74
+ return rows.map((row) => {
75
+ const run = row.payload || {};
76
+ return {
77
+ runId: run.runId,
78
+ status: run.status,
79
+ executionMode: run.execution?.executionMode || null,
80
+ createdAt: run.createdAt,
81
+ request: run.request?.summary || null,
82
+ };
83
+ });
84
+ }
85
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * lib/orchestration/run-store-sqlite.mjs — SQLite-backed run store (Mode-B).
3
+ *
4
+ * Same saveRun/loadRun/listRuns surface as the filesystem store (run-store.mjs),
5
+ * backed by a single `node:sqlite` database under
6
+ * `.cx/runtime/orchestration/runs.db`. A daemon-grade local store: durable,
7
+ * single-file, queryable, with no Postgres/Docker dependency.
8
+ *
9
+ * Node compatibility boundary: `node:sqlite` exists only on Node >=22.5, but the
10
+ * CI matrix also runs Node 20. So the import is LAZY (inside a function, never at
11
+ * module top level) and `sqliteAvailable()` reports false when it throws. The
12
+ * store factory throws a structured SQLITE_UNAVAILABLE error (with remediation)
13
+ * rather than crashing, so the resolver can fall back to filesystem. Nothing on
14
+ * the default test path statically imports node:sqlite.
15
+ */
16
+
17
+ import { mkdirSync } from 'node:fs';
18
+ import { isAbsolute, join, resolve } from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const RUNTIME_REL = join('.cx', 'runtime', 'orchestration');
23
+
24
+ let cachedDatabaseSync;
25
+
26
+ // The node:sqlite probe is cached and synchronous. createRequire above is a
27
+ // plain static import of node:module (always present); only node:sqlite itself
28
+ // is loaded lazily here, so Node 20 never touches the unavailable builtin.
29
+
30
+ function loadDatabaseSync() {
31
+ if (cachedDatabaseSync !== undefined) return cachedDatabaseSync;
32
+ try {
33
+ cachedDatabaseSync = require('node:sqlite').DatabaseSync;
34
+ } catch {
35
+ cachedDatabaseSync = null;
36
+ }
37
+ return cachedDatabaseSync;
38
+ }
39
+
40
+ export function sqliteAvailable() {
41
+ return loadDatabaseSync() != null;
42
+ }
43
+
44
+ function projectRoot(cwd) {
45
+ return isAbsolute(cwd) ? cwd : resolve(cwd);
46
+ }
47
+
48
+ function dbPath(cwd) {
49
+ const dir = join(projectRoot(cwd), RUNTIME_REL);
50
+ mkdirSync(dir, { recursive: true });
51
+ return join(dir, 'runs.db');
52
+ }
53
+
54
+ function openDb(cwd) {
55
+ const DatabaseSync = loadDatabaseSync();
56
+ if (!DatabaseSync) {
57
+ const err = new Error('SQLite run store requires Node >=22.5 (node:sqlite is unavailable on this runtime).');
58
+ err.code = 'SQLITE_UNAVAILABLE';
59
+ err.remediation = 'Upgrade to Node >=22.5, or set orchestration.store to "filesystem" / "postgres".';
60
+ throw err;
61
+ }
62
+ const db = new DatabaseSync(dbPath(cwd));
63
+ db.exec(`CREATE TABLE IF NOT EXISTS runs (
64
+ run_id TEXT PRIMARY KEY,
65
+ created_at TEXT,
66
+ status TEXT,
67
+ execution_mode TEXT,
68
+ json TEXT
69
+ )`);
70
+ return db;
71
+ }
72
+
73
+ /**
74
+ * Build a SQLite-backed run store bound to a project directory.
75
+ *
76
+ * @param {object} opts
77
+ * @param {string} opts.cwd
78
+ * @returns {{ saveRun(run):object, loadRun(runId):object|null, listRuns(opts):Array }}
79
+ */
80
+ export function createSqliteRunStore({ cwd = process.cwd() } = {}) {
81
+ const db = openDb(cwd);
82
+ return {
83
+ saveRun(run) {
84
+ const stmt = db.prepare(`INSERT INTO runs (run_id, created_at, status, execution_mode, json)
85
+ VALUES (?, ?, ?, ?, ?)
86
+ ON CONFLICT(run_id) DO UPDATE SET
87
+ created_at = excluded.created_at,
88
+ status = excluded.status,
89
+ execution_mode = excluded.execution_mode,
90
+ json = excluded.json`);
91
+ stmt.run(run.runId, run.createdAt || null, run.status || null, run.execution?.executionMode || null, JSON.stringify(run));
92
+ return run;
93
+ },
94
+ loadRun(runId) {
95
+ if (!runId) return null;
96
+ const row = db.prepare('SELECT json FROM runs WHERE run_id = ?').get(runId);
97
+ if (!row || !row.json) return null;
98
+ try {
99
+ return JSON.parse(row.json);
100
+ } catch {
101
+ return null;
102
+ }
103
+ },
104
+ listRuns({ limit = 20 } = {}) {
105
+ const rows = db.prepare('SELECT json FROM runs ORDER BY created_at DESC LIMIT ?').all(limit);
106
+ const out = [];
107
+ for (const row of rows) {
108
+ try {
109
+ const run = JSON.parse(row.json);
110
+ out.push({
111
+ runId: run.runId,
112
+ status: run.status,
113
+ executionMode: run.execution?.executionMode || null,
114
+ createdAt: run.createdAt,
115
+ request: run.request?.summary || null,
116
+ });
117
+ } catch { /* a corrupt row is skipped, not fatal to listing */ }
118
+ }
119
+ return out;
120
+ },
121
+ };
122
+ }
@@ -1,25 +1,32 @@
1
1
  /**
2
- * lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime (Mode-A).
2
+ * lib/orchestration/runtime.mjs — Construct-owned local orchestration runtime.
3
3
  *
4
4
  * The product goal: hosts are front doors, Construct is the system. A
5
5
  * host that lacks native multi-agent execution should still reach equivalent
6
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.
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
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.
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.
23
30
  */
24
31
 
25
32
  import { randomBytes } from 'node:crypto';
@@ -27,13 +34,15 @@ import { randomBytes } from 'node:crypto';
27
34
  import { routeRequest } from '../orchestration-policy.mjs';
28
35
  import { resolveExecution } from '../embedded-contract/execution.mjs';
29
36
  import { detectHostCapabilities } from '../host-capabilities.mjs';
37
+ import { loadProjectConfig } from '../config/project-config.mjs';
30
38
  import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
31
- import { saveRun, loadRun, listRuns } from './run-store.mjs';
39
+ import { resolveRunStore } from './store.mjs';
40
+ import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
32
41
 
33
- export const WORKER_BACKENDS = ['inline'];
34
- export const DEFAULT_WORKER_BACKEND = 'inline';
42
+ export const WORKER_BACKENDS = WORKER_BACKEND_SET;
43
+ export const DEFAULT_WORKER_BACKEND = INLINE;
35
44
 
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.';
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.';
37
46
 
38
47
  function newRunId() {
39
48
  return `run-${randomBytes(6).toString('hex')}`;
@@ -87,25 +96,47 @@ function buildTasks(route) {
87
96
  handoffContract: handoffByProducer.get(role.replace(/^cx-/, '')) || null,
88
97
  status: 'queued',
89
98
  executor: null,
99
+ output: null,
100
+ error: null,
90
101
  startedAt: null,
91
102
  finishedAt: null,
92
103
  }));
93
104
  }
94
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
+
95
123
  /**
96
124
  * Plan a run: resolve the execution contract, decompose into a specialist chain,
97
125
  * and persist a durable run record (status `planned`). Pure of model calls.
98
126
  *
99
127
  * @param {object} request
100
128
  * @param {object} [opts] { env, cwd }
101
- * @returns {object} the persisted run
129
+ * @returns {Promise<object>} the persisted run
102
130
  */
103
- export function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
131
+ export async function planRun(request = {}, { env = process.env, cwd = process.cwd() } = {}) {
104
132
  const {
105
133
  request: text = '', workflowType = null, requestedStrategy = 'auto', useConstruct = true,
106
134
  host = null, hostModel = null, hostProvider = null, fileCount = 0, moduleCount = 0,
107
135
  } = request;
108
136
 
137
+ const config = loadConfig(cwd, env);
138
+ const { store, warnings: storeWarnings } = resolveRunStore({ config, env, cwd });
139
+
109
140
  const execData = resolveExecution(
110
141
  { workflowType, requestedStrategy, useConstruct, host, hostModel, hostProvider },
111
142
  { env, cwd },
@@ -115,6 +146,7 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
115
146
  const traceId = newTraceId();
116
147
  const runId = newRunId();
117
148
  const now = new Date().toISOString();
149
+ const workerBackend = resolveWorkerBackend({ config });
118
150
 
119
151
  // Construct owns a specialist task sequence only when the contract resolves to
120
152
  // orchestrated execution; prompt-only and host-direct own none, and the run
@@ -129,7 +161,7 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
129
161
  updatedAt: now,
130
162
  request: { summary: truncate(text, 200), workflowType, requestedStrategy },
131
163
  hostRole: resolveHostRole(host),
132
- workerBackend: DEFAULT_WORKER_BACKEND,
164
+ workerBackend,
133
165
  execution: pickExecution(execData),
134
166
  plan: {
135
167
  intent: route.intent,
@@ -140,12 +172,12 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
140
172
  },
141
173
  tasks,
142
174
  status: 'planned',
143
- warnings: execData.warnings || [],
175
+ warnings: [...(execData.warnings || []), ...storeWarnings],
144
176
  semantics: RUNTIME_SEMANTICS,
145
177
  executionSemantics: execData.semantics,
146
178
  };
147
179
 
148
- saveRun(cwd, run);
180
+ await store.saveRun(run);
149
181
  emitTraceEvent({
150
182
  rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
151
183
  metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
@@ -153,46 +185,87 @@ export function planRun(request = {}, { env = process.env, cwd = process.cwd() }
153
185
  return run;
154
186
  }
155
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
+
156
223
  /**
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.
224
+ * Execute a planned run through the chosen worker backend, persisting after each
225
+ * task transition so the run is resumable and observable.
161
226
  *
162
227
  * @param {string} cwd
163
228
  * @param {string} runId
164
- * @param {object} [opts] { env }
165
- * @returns {object} the completed run
229
+ * @param {object} [opts] { env, workerBackend, fetchImpl }
230
+ * @returns {Promise<object>} the completed run
166
231
  */
167
- export function executeRun(cwd, runId, { env = process.env } = {}) {
168
- const run = loadRun(cwd, runId);
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);
169
236
  if (!run) {
170
237
  const err = new Error(`Orchestration run not found: ${runId}`);
171
238
  err.code = 'RUN_NOT_FOUND';
172
239
  throw err;
173
240
  }
174
241
 
242
+ const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
243
+ run.workerBackend = backend;
175
244
  run.status = 'running';
176
245
  run.updatedAt = new Date().toISOString();
177
- saveRun(cwd, run);
246
+ await store.saveRun(run);
178
247
 
248
+ let anyFailed = false;
179
249
  for (const task of run.tasks) {
180
250
  task.status = 'running';
181
251
  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 } });
252
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
183
253
 
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' } });
254
+ if (backend === PROVIDER) {
255
+ const ok = await executeTaskViaProvider(task, run, env, fetchImpl);
256
+ if (!ok) anyFailed = true;
257
+ } else {
258
+ prepareTaskInline(task);
259
+ }
188
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 } });
189
262
  run.updatedAt = new Date().toISOString();
190
- saveRun(cwd, run);
263
+ await store.saveRun(run);
191
264
  }
192
265
 
193
- run.status = 'completed';
266
+ run.status = anyFailed ? 'completed-with-failures' : 'completed';
194
267
  run.updatedAt = new Date().toISOString();
195
- saveRun(cwd, run);
268
+ await store.saveRun(run);
196
269
  emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
197
270
  return run;
198
271
  }
@@ -200,9 +273,9 @@ export function executeRun(cwd, runId, { env = process.env } = {}) {
200
273
  /**
201
274
  * Plan and execute in one call (the common `orchestrate run` path).
202
275
  */
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 });
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 });
206
279
  }
207
280
 
208
281
  /**
@@ -231,10 +304,14 @@ export function hostAdapterMetadata(run) {
231
304
  };
232
305
  }
233
306
 
234
- export function getRun(cwd, runId) {
235
- return loadRun(cwd, runId);
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);
236
311
  }
237
312
 
238
- export function getRuns(cwd, opts) {
239
- return listRuns(cwd, opts);
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);
240
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",