@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.
package/README.md CHANGED
@@ -159,9 +159,11 @@ The embed daemon writes its supervisor stdout log to `~/.cx/runtime/embed-daemon
159
159
  |---|---|
160
160
  | `construct capability` | Describe what this Construct install can do (embedded contract; read-only, secret-free) |
161
161
  | `construct claude:allow` | Manage Claude Code `permissions.allow` from the outside (auto-classifier blocks the agent from editing it) |
162
+ | `construct execution` | Resolve the execution-capability contract for an embedded workflow (orchestrated vs prompt-only; descriptive, not enforced) |
162
163
  | `construct hosts` | Show host support for Construct orchestration |
163
164
  | `construct mcp` | Manage MCP integrations |
164
165
  | `construct models` | Show or update model tier assignments |
166
+ | `construct orchestrate` | Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker) |
165
167
  | `construct plugin` | Manage external Construct plugin manifests |
166
168
 
167
169
  ### Integrations
package/bin/construct CHANGED
@@ -3335,6 +3335,83 @@ async function cmdValidate() {
3335
3335
  process.exit(1);
3336
3336
  }
3337
3337
 
3338
+ async function cmdExecution(args) {
3339
+ if (args[0] === 'resolve') {
3340
+ const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
3341
+ const { resolveExecution } = await import('../lib/embedded-contract/execution.mjs');
3342
+ const { wrapContractResult } = await import('../lib/embedded-contract/envelope.mjs');
3343
+ const request = {
3344
+ workflowType: flag('--workflow-type'),
3345
+ requestedStrategy: flag('--strategy'),
3346
+ useConstruct: !args.includes('--no-construct'),
3347
+ host: flag('--host'),
3348
+ hostModel: flag('--host-model'),
3349
+ hostProvider: flag('--host-provider'),
3350
+ requestedTier: flag('--tier'),
3351
+ capabilities: flag('--capabilities')?.split(',').map((s) => s.trim()).filter(Boolean),
3352
+ allowCrossProviderFallback: args.includes('--allow-cross-provider'),
3353
+ };
3354
+ const envelope = wrapContractResult(resolveExecution(request), { surface: 'cli' });
3355
+ println(JSON.stringify(envelope, null, 2));
3356
+ return;
3357
+ }
3358
+ println('Usage: construct execution resolve [--workflow-type T] [--strategy orchestrated|prompt-only|auto] [--host-model M] [--host-provider P] [--tier reasoning|standard|fast] [--no-construct] [--allow-cross-provider] [--json]');
3359
+ }
3360
+
3361
+ async function cmdOrchestrate(args) {
3362
+ const sub = args[0];
3363
+ const rest = args.slice(1);
3364
+ const flag = (name) => { const i = rest.indexOf(name); return i !== -1 ? rest[i + 1] : undefined; };
3365
+ const wantsJson = rest.includes('--json') || args.includes('--json');
3366
+
3367
+ if (sub === 'run') {
3368
+ const { runOrchestration, planRun, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3369
+ const positional = rest.filter((a, i) => !a.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--') && !['--no-construct', '--no-execute', '--json'].includes(rest[i - 1])));
3370
+ const text = flag('--text') || positional.join(' ').trim();
3371
+ if (!text) { errorln('Usage: construct orchestrate run "<request>" [--workflow-type T] [--strategy orchestrated|prompt-only|auto] [--host H] [--host-model M] [--no-construct] [--no-execute] [--json]'); process.exit(1); }
3372
+ const request = {
3373
+ request: text,
3374
+ workflowType: flag('--workflow-type'),
3375
+ requestedStrategy: flag('--strategy') || 'auto',
3376
+ useConstruct: !rest.includes('--no-construct'),
3377
+ host: flag('--host'),
3378
+ hostModel: flag('--host-model'),
3379
+ hostProvider: flag('--host-provider'),
3380
+ fileCount: Number(flag('--file-count') || 0),
3381
+ moduleCount: Number(flag('--module-count') || 0),
3382
+ };
3383
+ const run = rest.includes('--no-execute') ? planRun(request, {}) : runOrchestration(request, {});
3384
+ if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3385
+ println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode} · backend=${run.workerBackend} · hostRole=${run.hostRole}`);
3386
+ if (run.execution.degraded) println(` degraded: ${run.execution.degradationReason}`);
3387
+ if (run.tasks.length) println(` tasks: ${run.tasks.map((t) => `${t.role}(${t.status})`).join(' → ')}`);
3388
+ else println(' tasks: none (no Construct-owned specialist sequence for this execution mode)');
3389
+ return;
3390
+ }
3391
+
3392
+ if (sub === 'status') {
3393
+ const { getRun, getRuns, hostAdapterMetadata } = await import('../lib/orchestration/runtime.mjs');
3394
+ const runId = rest.find((a) => !a.startsWith('--'));
3395
+ if (runId) {
3396
+ const run = getRun(process.cwd(), runId);
3397
+ if (!run) { errorln(`Run not found: ${runId}`); process.exit(1); }
3398
+ if (wantsJson) { println(JSON.stringify(hostAdapterMetadata(run), null, 2)); return; }
3399
+ println(`Run ${run.runId} — ${run.status} · executionMode=${run.execution.executionMode}`);
3400
+ for (const t of run.tasks) println(` ${t.id} ${t.role} — ${t.status}`);
3401
+ return;
3402
+ }
3403
+ const runs = getRuns(process.cwd());
3404
+ if (wantsJson) { println(JSON.stringify(runs, null, 2)); return; }
3405
+ if (!runs.length) { println('No orchestration runs recorded.'); return; }
3406
+ for (const r of runs) println(` ${r.runId} — ${r.status} · ${r.executionMode || ''} · ${r.createdAt}`);
3407
+ return;
3408
+ }
3409
+
3410
+ println('Usage: construct orchestrate <run|status> [options]');
3411
+ println(' run "<request>" [--workflow-type T] [--strategy S] [--host H] [--host-model M] [--no-construct] [--no-execute] [--json]');
3412
+ println(' status [run-id] [--json]');
3413
+ }
3414
+
3338
3415
  async function cmdModels(args) {
3339
3416
  if (args[0] === 'resolve') {
3340
3417
  const flag = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : undefined; };
@@ -5040,6 +5117,8 @@ const handlers = new Map([
5040
5117
  ['docs:reconcile', cmdDocsReconcile],
5041
5118
  ['init:update', cmdInitUpdate],
5042
5119
  ['models', cmdModels],
5120
+ ['execution', cmdExecution],
5121
+ ['orchestrate', cmdOrchestrate],
5043
5122
  ['beads:stats', async (args) => {
5044
5123
  const { getContentionStats, getHumanStatus } = await import('../lib/beads-optimistic.mjs');
5045
5124
  const jsonOutput = args.includes('--json');
@@ -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> [--strict] [--legacy-extractor]',
253
+ usage: 'construct ingest <file> [--strategy=adapter|provider] [--strict] [--legacy-extractor]',
254
254
  },
255
255
  {
256
256
  name: 'infer',
@@ -323,6 +323,29 @@ export const CLI_COMMANDS = [
323
323
  { name: 'describe --json', desc: 'Emit versions, interfaces, roles, skills, workflows, schemas, models, policies, telemetry, plugins' },
324
324
  ],
325
325
  },
326
+ {
327
+ name: 'execution',
328
+ emoji: '🪢',
329
+ category: 'Models & Integrations',
330
+ core: false,
331
+ description: 'Resolve the execution-capability contract for an embedded workflow (orchestrated vs prompt-only; descriptive, not enforced)',
332
+ usage: 'construct execution resolve --json',
333
+ subcommands: [
334
+ { name: 'resolve --json', desc: 'Report executionMode, active Construct capabilities, and any degradation given host/strategy context' },
335
+ ],
336
+ },
337
+ {
338
+ name: 'orchestrate',
339
+ emoji: '🎼',
340
+ category: 'Models & Integrations',
341
+ core: false,
342
+ description: 'Construct-owned local orchestration runtime (Mode-A: single-process, filesystem-backed, no Docker)',
343
+ usage: 'construct orchestrate <run|status> [options]',
344
+ subcommands: [
345
+ { name: 'run "<request>" [--strategy S] [--host H] [--no-construct] [--no-execute] [--json]', desc: 'Plan and run a request through a Construct-owned specialist chain; returns host-adapter metadata' },
346
+ { name: 'status [run-id] [--json]', desc: 'Inspect a run, or list recent runs' },
347
+ ],
348
+ },
326
349
  {
327
350
  name: 'mcp',
328
351
  emoji: '🔌',
@@ -5,7 +5,7 @@
5
5
  * AJV/zod to keep Construct dependency-free at startup — the loader runs
6
6
  * before npm install completes in some bootstrap paths.
7
7
  *
8
- * Schema v1 covers: alias, deployment, providers, autoEmbed, telemetry.
8
+ * Schema v1 covers: alias, deployment, providers, autoEmbed, ingest, telemetry.
9
9
  * Tier model selection lives in `specialists/registry.json` only — this config
10
10
  * file is intentionally not a second edit surface for model assignments.
11
11
  * Later phases (6, 7b) extend it with `resources` and `costs` blocks
@@ -24,6 +24,14 @@ export const DEFAULT_PROFILE_ID = 'rnd';
24
24
 
25
25
  export const SURFACES = ['claude', 'opencode', 'codex', 'copilot', 'vscode', 'cursor'];
26
26
 
27
+ export const INGEST_STRATEGIES = ['adapter', 'provider'];
28
+ export const INGEST_FALLBACKS = ['none', 'provider', 'adapter'];
29
+
30
+ // SessionStart context routing. `auto` keeps the rich payload on stdout for
31
+ // interactive sessions and suppresses it (to a debug log) for non-interactive /
32
+ // SDK invocations, so a one-shot command's stdout stays reserved for its output.
33
+ export const HOOK_OUTPUT_MODES = ['auto', 'silent', 'stderr', 'stdout'];
34
+
27
35
  export const DEFAULT_PROJECT_CONFIG = Object.freeze({
28
36
  version: CONFIG_SCHEMA_VERSION,
29
37
  alias: 'Construct',
@@ -36,9 +44,16 @@ export const DEFAULT_PROJECT_CONFIG = Object.freeze({
36
44
  providers: Object.freeze({}),
37
45
  profile: DEFAULT_PROFILE_ID,
38
46
  autoEmbed: false,
47
+ ingest: Object.freeze({
48
+ strategy: 'adapter',
49
+ fallback: 'none',
50
+ }),
39
51
  telemetry: Object.freeze({
40
52
  enabled: true,
41
53
  }),
54
+ hooks: Object.freeze({
55
+ outputMode: 'auto',
56
+ }),
42
57
  roleSelection: Object.freeze({
43
58
  primary: null,
44
59
  secondary: null,
@@ -84,6 +99,14 @@ export const FIELD_RULES = {
84
99
  providers: { type: 'object', required: false },
85
100
  profile: { type: 'string', required: false, maxLength: 40 },
86
101
  autoEmbed: { type: 'boolean', required: false },
102
+ ingest: {
103
+ type: 'object',
104
+ required: false,
105
+ fields: {
106
+ strategy: { type: 'string', enum: INGEST_STRATEGIES },
107
+ fallback: { type: 'string', enum: INGEST_FALLBACKS },
108
+ },
109
+ },
87
110
  telemetry: {
88
111
  type: 'object',
89
112
  required: false,
@@ -91,6 +114,13 @@ export const FIELD_RULES = {
91
114
  enabled: { type: 'boolean' },
92
115
  },
93
116
  },
117
+ hooks: {
118
+ type: 'object',
119
+ required: false,
120
+ fields: {
121
+ outputMode: { type: 'string', enum: HOOK_OUTPUT_MODES },
122
+ },
123
+ },
94
124
  roleSelection: {
95
125
  type: 'object',
96
126
  required: false,
@@ -4,6 +4,8 @@
4
4
  "ADR-0016",
5
5
  "ADR-0017",
6
6
  "ADR-0018",
7
+ "ADR-0019",
8
+ "ADR-0020",
7
9
  "any-to-business-strategist",
8
10
  "any-to-explorer",
9
11
  "architect-to-data-engineer",
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Reuses the shared extraction backends from lib/document-extract.mjs, writes
5
5
  * markdown outputs into retrieval-friendly project paths, and can optionally
6
- * trigger storage sync for SQL/vector indexing.
6
+ * trigger storage sync for SQL/vector indexing. The extraction step honors a
7
+ * resolved ingest strategy (adapter | provider) with an explicit fallback policy
8
+ * and records the selected strategy/model in the returned `ingestion` block.
7
9
  */
8
10
  import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
9
11
  import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from 'node:path';
@@ -11,6 +13,9 @@ import { extractDocumentText, extractDocumentTextAsync, extractDocumentMetadata,
11
13
  import { syncFileStateToSql } from './storage/sync.mjs';
12
14
  import { stampFrontmatter } from './doc-stamp.mjs';
13
15
  import { KNOWLEDGE_ROOT, KNOWLEDGE_SUBDIRS } from './knowledge/layout.mjs';
16
+ import { loadProjectConfig } from './config/project-config.mjs';
17
+ import { resolveIngestStrategy, INGEST_STRATEGIES } from './ingest/strategy.mjs';
18
+ import { extractViaProvider } from './ingest/provider-extract.mjs';
14
19
 
15
20
  const DEFAULT_TARGET_DIR = '.cx/knowledge/internal';
16
21
 
@@ -121,6 +126,38 @@ function resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target }) {
121
126
  return join(resolvedDir, `${basename(sourcePath)}.md`);
122
127
  }
123
128
 
129
+ function extractViaAdapter(sourcePath, { highFidelity, maxChars }) {
130
+ return highFidelity
131
+ ? extractDocumentTextAsync(sourcePath, { maxChars })
132
+ : extractDocumentText(sourcePath, { maxChars });
133
+ }
134
+
135
+ async function extractWithStrategy(sourcePath, { strategy, fallback, model, provider, highFidelity, maxChars, env }) {
136
+ const primary = strategy === 'provider' ? 'provider' : 'adapter';
137
+ const run = (mode) => (mode === 'provider'
138
+ ? extractViaProvider({ filePath: sourcePath, model, provider, maxChars, env })
139
+ : extractViaAdapter(sourcePath, { highFidelity, maxChars }));
140
+
141
+ try {
142
+ const extracted = await run(primary);
143
+ return { extracted, strategyUsed: primary, fallbackApplied: null, error: null };
144
+ } catch (primaryErr) {
145
+ if (fallback === 'none' || fallback === primary) {
146
+ const err = new Error(`ingest ${primary} strategy failed and fallback is "${fallback}": ${primaryErr.message}`);
147
+ err.code = primaryErr.code || 'INGEST_STRATEGY_FAILED';
148
+ err.cause = primaryErr;
149
+ throw err;
150
+ }
151
+ const extracted = await run(fallback);
152
+ return {
153
+ extracted,
154
+ strategyUsed: fallback,
155
+ fallbackApplied: { from: primary, to: fallback, reason: primaryErr.message, code: primaryErr.code || 'INGEST_STRATEGY_FAILED' },
156
+ error: null,
157
+ };
158
+ }
159
+ }
160
+
124
161
  export async function ingestDocuments(inputPaths, {
125
162
  cwd = process.cwd(),
126
163
  outputPath = null,
@@ -129,6 +166,7 @@ export async function ingestDocuments(inputPaths, {
129
166
  sync = false,
130
167
  strict = false,
131
168
  highFidelity = true,
169
+ strategy = null,
132
170
  env = process.env,
133
171
  } = {}) {
134
172
  if (!Array.isArray(inputPaths) || inputPaths.length === 0) {
@@ -143,12 +181,24 @@ export async function ingestDocuments(inputPaths, {
143
181
  throw new Error('No supported document files found');
144
182
  }
145
183
 
184
+ const { config } = loadProjectConfig(cwd, env);
185
+ const resolvedStrategy = resolveIngestStrategy({ config, env, override: strategy });
186
+
146
187
  const results = [];
147
188
  let totalDrops = 0;
189
+ let fallbackApplied = null;
148
190
  for (const sourcePath of files) {
149
- const extracted = highFidelity
150
- ? await extractDocumentTextAsync(sourcePath, { maxChars: 200_000 })
151
- : extractDocumentText(sourcePath, { maxChars: 200_000 });
191
+ const routed = await extractWithStrategy(sourcePath, {
192
+ strategy: resolvedStrategy.strategy,
193
+ fallback: resolvedStrategy.fallback,
194
+ model: resolvedStrategy.model,
195
+ provider: resolvedStrategy.provider,
196
+ highFidelity,
197
+ maxChars: 200_000,
198
+ env,
199
+ });
200
+ const extracted = routed.extracted;
201
+ if (routed.fallbackApplied) fallbackApplied = routed.fallbackApplied;
152
202
  totalDrops += (extracted.droppedInfo ?? []).reduce((a, d) => a + (Number(d.count) || 1), 0);
153
203
  const metadata = extractDocumentMetadata(sourcePath);
154
204
  const targetPath = resolveOutputPath(sourcePath, { cwd, outputPath, outputDir, target });
@@ -213,6 +263,13 @@ export async function ingestDocuments(inputPaths, {
213
263
  droppedInfo: results.flatMap((r) => r.droppedInfo.map((d) => ({ ...d, sourcePath: r.sourcePath }))),
214
264
  droppedCount: totalDrops,
215
265
  highFidelity,
266
+ ingestion: {
267
+ strategy: resolvedStrategy.strategy,
268
+ fallback: resolvedStrategy.fallback,
269
+ model: resolvedStrategy.model,
270
+ provider: resolvedStrategy.provider,
271
+ fallbackApplied,
272
+ },
216
273
  };
217
274
  }
218
275
 
@@ -224,26 +281,34 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
224
281
  let sync = false;
225
282
  let strict = false;
226
283
  let highFidelity = true;
284
+ let strategy = null;
227
285
 
228
286
  for (const arg of argv) {
229
287
  if (arg.startsWith('--out=')) outputPath = arg.split('=').slice(1).join('=');
230
288
  else if (arg.startsWith('--out-dir=')) outputDir = arg.split('=').slice(1).join('=');
231
289
  else if (arg.startsWith('--target=')) target = arg.split('=').slice(1).join('=');
290
+ else if (arg.startsWith('--strategy=')) strategy = arg.split('=').slice(1).join('=');
291
+ else if (arg === '--strategy') strategy = '';
232
292
  else if (arg === '--sync') sync = true;
233
293
  else if (arg === '--strict') strict = true;
234
294
  else if (arg === '--legacy-extractor') highFidelity = false;
295
+ else if (strategy === '') strategy = arg;
235
296
  else inputs.push(arg);
236
297
  }
237
298
 
238
299
  if (inputs.length === 0) {
239
300
  throw new Error(
240
301
  `Usage: construct ingest <file-or-dir> [more paths] [--out=FILE] [--out-dir=DIR] ` +
241
- `[--target=sibling|knowledge/<subdir>] [--sync] [--strict] [--legacy-extractor]\n` +
302
+ `[--target=sibling|knowledge/<subdir>] [--strategy=adapter|provider] [--sync] [--strict] [--legacy-extractor]\n` +
303
+ ` --strategy: extraction strategy override (adapter = local extractors; provider = configured provider/model)\n` +
242
304
  ` --strict: exit non-zero if any extraction drops occur\n` +
243
305
  ` --legacy-extractor: use the pre-docling regex extractor (lower fidelity)\n` +
244
306
  ` knowledge subdirs: ${KNOWLEDGE_SUBDIRS.join(', ')}`,
245
307
  );
246
308
  }
309
+ if (strategy !== null && strategy !== '' && !INGEST_STRATEGIES.includes(strategy)) {
310
+ throw new Error(`Unsupported strategy: ${strategy}. Valid strategies: ${INGEST_STRATEGIES.join(', ')}`);
311
+ }
247
312
  const knowledgeTargets = KNOWLEDGE_SUBDIRS.map((s) => `knowledge/${s}`);
248
313
  if (!['sibling', ...knowledgeTargets].includes(target)) {
249
314
  throw new Error(
@@ -251,5 +316,5 @@ export async function runIngestCli(argv = process.argv.slice(2), { cwd = process
251
316
  );
252
317
  }
253
318
 
254
- return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, env });
319
+ return ingestDocuments(inputs, { cwd, outputPath, outputDir, target, sync, strict, highFidelity, strategy: strategy || null, env });
255
320
  }
@@ -32,9 +32,9 @@ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
32
32
 
33
33
  function buildInterfaces() {
34
34
  return [
35
- { surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
36
- { surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'triage_recommend', 'workflow_invoke'] },
37
- { surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'recommendPlan', 'invokeWorkflow'] },
35
+ { surface: 'cli', contractVersion: CONTRACT_VERSION, entrypoints: ['construct capability describe --json', 'construct models resolve --json', 'construct execution resolve --json', 'construct intake classify --json', 'construct graph recommend --json', 'construct workflow invoke --json'] },
36
+ { surface: 'mcp', contractVersion: CONTRACT_VERSION, tools: ['capability_describe', 'model_resolve', 'construct_execution_resolve', 'triage_recommend', 'workflow_invoke'] },
37
+ { surface: 'sdk', contractVersion: CONTRACT_VERSION, module: 'construct/embedded-contract', exports: ['describeCapabilities', 'resolveEmbeddedModel', 'resolveExecution', 'recommendPlan', 'invokeWorkflow'] },
38
38
  ];
39
39
  }
40
40
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { parseSemver, compareSemver } from '../version.mjs';
17
17
 
18
- export const CONTRACT_VERSION = '1.0.0';
18
+ export const CONTRACT_VERSION = '1.1.0';
19
19
 
20
20
  // A client built against an older minor of the same major still works, because
21
21
  // minor bumps are additive-only; a different major is incompatible either way.
@@ -0,0 +1,184 @@
1
+ /**
2
+ * lib/embedded-contract/execution.mjs — embedded execution-capability contract.
3
+ *
4
+ * An embedded host can request Construct orchestration but cannot otherwise tell
5
+ * whether a run actually engaged personas/skills/routing or degraded to a
6
+ * prompt-only envelope — and if it degraded, why. This contract answers that
7
+ * before or at workflow start: given a requested strategy and the host's model
8
+ * context, it derives an `executionMode`, the Construct capabilities that mode
9
+ * contributes, and an explicit degradation reason.
10
+ *
11
+ * Honesty boundary (ADR-0019): orchestration in the embedded layer is
12
+ * DESCRIPTIVE, not enforced. Construct returns a plan; the host runtime performs
13
+ * the reasoning. So this contract reports what Construct PLANNED and can RESOLVE
14
+ * a model for — never an observation that the host executed personas. Every
15
+ * response carries a `semantics` disclaimer to that effect, and
16
+ * `constructCapabilitiesActive` describes Construct's contribution, not proof of
17
+ * host execution. Claiming observed orchestration would violate the
18
+ * no-fabrication rule. A `same-family-fallback` (host model unavailable) and a
19
+ * `config-error` (no model) both surface as `degraded: true`.
20
+ *
21
+ * Pure and synchronous; reuses resolveEmbeddedModel (no second model surface)
22
+ * and returns a result object carrying `warnings`, lifted into the envelope by
23
+ * the calling surface.
24
+ */
25
+
26
+ import { getDeploymentMode } from '../deployment-mode.mjs';
27
+ import { resolveEmbeddedModel } from './model-resolve.mjs';
28
+ import { getWorkflowDef } from './workflow-defs.mjs';
29
+
30
+ export const EXECUTION_MODES = ['construct-orchestrated', 'construct-prompt-only', 'host-direct', 'same-family-fallback'];
31
+ export const CONSTRUCT_STRATEGIES = ['orchestrated', 'prompt-only', 'auto'];
32
+ export const CONSTRUCT_CAPABILITIES = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
33
+
34
+ export const EXECUTION_SEMANTICS = 'Reports Construct-planned capability and model-resolvability before/at workflow start; does not observe host execution. constructCapabilitiesActive describes what Construct contributes, not proof the host ran personas/skills.';
35
+
36
+ const ORCHESTRATED_CAPS = ['personas', 'skills', 'workflow-routing', 'prompt-envelope'];
37
+ const PROMPT_ONLY_CAPS = ['prompt-envelope'];
38
+
39
+ function normalizeStrategy(value) {
40
+ const lowered = typeof value === 'string' ? value.trim().toLowerCase() : '';
41
+ return CONSTRUCT_STRATEGIES.includes(lowered) ? lowered : null;
42
+ }
43
+
44
+ /**
45
+ * Resolve the execution-capability contract for an embedded workflow.
46
+ *
47
+ * @param {object} request
48
+ * @param {string} [request.workflowType] workflow whose orchestration plan is being weighed
49
+ * @param {string} [request.requestedStrategy] orchestrated | prompt-only | auto (default auto)
50
+ * @param {boolean} [request.useConstruct=true] false ⇒ host-direct (host runs without Construct)
51
+ * @param {string} [request.host]
52
+ * @param {string} [request.hostModel]
53
+ * @param {string} [request.hostProvider]
54
+ * @param {string} [request.requestedTier]
55
+ * @param {string[]} [request.capabilities]
56
+ * @param {boolean} [request.allowCrossProviderFallback=false]
57
+ * @param {object} [opts]
58
+ * @param {Record<string,string>} [opts.env]
59
+ * @param {string} [opts.cwd]
60
+ * @param {string} [opts.registryPath]
61
+ * @returns {object}
62
+ */
63
+ export function resolveExecution(request = {}, { env = process.env, cwd = process.cwd(), registryPath = null } = {}) {
64
+ const {
65
+ workflowType = null,
66
+ requestedStrategy: rawStrategy,
67
+ useConstruct = true,
68
+ host, hostModel, hostProvider, requestedTier, capabilities, allowCrossProviderFallback = false,
69
+ } = request;
70
+
71
+ const warnings = [];
72
+ const requestedStrategy = normalizeStrategy(rawStrategy) || 'auto';
73
+ if (rawStrategy != null && !normalizeStrategy(rawStrategy)) {
74
+ warnings.push(`Unknown requestedStrategy "${rawStrategy}"; defaulted to "auto".`);
75
+ }
76
+
77
+ const deploymentMode = getDeploymentMode(env, { cwd });
78
+
79
+ // Model resolution is the single source of provider/model truth; its warnings
80
+ // are namespaced so an embedder can tell model concerns from execution ones.
81
+ const modelResolution = resolveEmbeddedModel(
82
+ { workflowType, requestedTier, host, hostModel, hostProvider, capabilities, allowCrossProviderFallback },
83
+ { env, registryPath },
84
+ );
85
+ for (const w of modelResolution.warnings || []) warnings.push(`model-resolution: ${w}`);
86
+
87
+ const { resolutionSource, selectedModel, selectedProvider } = modelResolution;
88
+ const orchestrationAvailable = resolutionSource !== 'config-error';
89
+
90
+ // An explicit-but-unknown workflowType has no plan; absent a workflowType,
91
+ // Construct's generic router can still orchestrate, so a plan is available.
92
+ const knownWorkflow = workflowType ? Boolean(getWorkflowDef(workflowType)) : true;
93
+ if (workflowType && !knownWorkflow) {
94
+ warnings.push(`Unknown workflowType "${workflowType}"; no orchestration plan is available for it.`);
95
+ }
96
+ const orchestrationPlanned = knownWorkflow;
97
+
98
+ const base = {
99
+ requestedStrategy,
100
+ selectedProvider,
101
+ selectedModel,
102
+ resolutionSource,
103
+ orchestrationPlanned,
104
+ orchestrationAvailable,
105
+ deploymentMode,
106
+ modelResolution: stripWarnings(modelResolution),
107
+ semantics: EXECUTION_SEMANTICS,
108
+ warnings,
109
+ };
110
+
111
+ if (useConstruct === false) {
112
+ return {
113
+ ...base,
114
+ executionMode: 'host-direct',
115
+ effectiveStrategy: 'host-direct',
116
+ constructCapabilitiesActive: [],
117
+ degraded: false,
118
+ degradationReason: null,
119
+ };
120
+ }
121
+
122
+ if (requestedStrategy === 'prompt-only') {
123
+ return {
124
+ ...base,
125
+ executionMode: 'construct-prompt-only',
126
+ effectiveStrategy: 'prompt-only',
127
+ constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
128
+ degraded: false,
129
+ degradationReason: null,
130
+ };
131
+ }
132
+
133
+ // orchestrated or auto: deliver orchestration only when a plan exists AND a
134
+ // model resolves. A same-family fallback runs but did not honor the host's
135
+ // exact model, so it is reported as its own mode and as degraded.
136
+ const canOrchestrate = orchestrationPlanned && orchestrationAvailable;
137
+ if (canOrchestrate) {
138
+ if (resolutionSource === 'same-family-fallback') {
139
+ return {
140
+ ...base,
141
+ executionMode: 'same-family-fallback',
142
+ effectiveStrategy: 'orchestrated',
143
+ constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
144
+ degraded: true,
145
+ degradationReason: `Host model unavailable; resolved a same-family tier model — ${modelResolution.fallbackReason || 'same-family fallback applied'}.`,
146
+ };
147
+ }
148
+ return {
149
+ ...base,
150
+ executionMode: 'construct-orchestrated',
151
+ effectiveStrategy: 'orchestrated',
152
+ constructCapabilitiesActive: [...ORCHESTRATED_CAPS],
153
+ degraded: false,
154
+ degradationReason: null,
155
+ };
156
+ }
157
+
158
+ // Could not orchestrate. Degradation is asserted only when orchestration was
159
+ // explicitly requested, or when no model resolved at all (auto still needs a
160
+ // model to do anything useful).
161
+ let degraded = false;
162
+ let degradationReason = null;
163
+ if (!orchestrationAvailable) {
164
+ degraded = true;
165
+ degradationReason = `Model could not be resolved; orchestration requires a runnable model — ${modelResolution.error?.reason || 'no model available'}.`;
166
+ } else if (requestedStrategy === 'orchestrated') {
167
+ degraded = true;
168
+ degradationReason = `Orchestration was requested but no orchestration plan is available${workflowType ? ` for workflowType "${workflowType}"` : ''}.`;
169
+ }
170
+
171
+ return {
172
+ ...base,
173
+ executionMode: 'construct-prompt-only',
174
+ effectiveStrategy: 'prompt-only',
175
+ constructCapabilitiesActive: [...PROMPT_ONLY_CAPS],
176
+ degraded,
177
+ degradationReason,
178
+ };
179
+ }
180
+
181
+ function stripWarnings(result) {
182
+ const { warnings, ...rest } = result || {};
183
+ return rest;
184
+ }
@@ -12,9 +12,11 @@ import { resolveEmbeddedModel as resolveModelCore } from './model-resolve.mjs';
12
12
  import { recommendPlan as recommendPlanCore } from './triage.mjs';
13
13
  import { invokeWorkflow as invokeWorkflowCore } from './workflow-invoke.mjs';
14
14
  import { buildCapabilityContract } from './capability.mjs';
15
+ import { resolveExecution as resolveExecutionCore } from './execution.mjs';
15
16
 
16
17
  export { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION, isClientCompatible } from './contract-version.mjs';
17
18
  export { APPROVAL_MODES } from './audit.mjs';
19
+ export { EXECUTION_MODES, CONSTRUCT_STRATEGIES, CONSTRUCT_CAPABILITIES } from './execution.mjs';
18
20
 
19
21
  // File→text resolution for SDK callers: extract a path through the real
20
22
  // pipeline (docling/whisper/transcript), then pass { text, ingestion } to
@@ -69,3 +71,17 @@ export async function invokeWorkflow(request = {}, { env, cwd } = {}) {
69
71
  export function describeCapabilities({ env, cwd, rootDir } = {}) {
70
72
  return wrapContractResult(buildCapabilityContract({ env, rootDir }), { surface: 'sdk', env, cwd });
71
73
  }
74
+
75
+ /**
76
+ * Resolve the execution-capability contract for an embedded workflow: the
77
+ * executionMode, the contributed Construct capabilities, and any degradation
78
+ * with a reason. Returns a versioned envelope; the resolution is under `.data`.
79
+ * Read-only; performs no model call beyond model resolution.
80
+ *
81
+ * @param {object} request See execution.mjs resolveExecution.
82
+ * @param {object} [opts] { env, cwd, registryPath }
83
+ * @returns {object}
84
+ */
85
+ export function resolveExecution(request = {}, { env, cwd, registryPath } = {}) {
86
+ return wrapContractResult(resolveExecutionCore(request, { env, cwd, registryPath }), { surface: 'sdk', env, cwd });
87
+ }