@geraldmaron/construct 1.0.21 → 1.0.23

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.
Files changed (117) hide show
  1. package/README.md +23 -5
  2. package/bin/construct +191 -13
  3. package/bin/construct-postinstall.mjs +25 -15
  4. package/lib/acp/server.mjs +113 -0
  5. package/lib/agent-instructions/inject.mjs +94 -0
  6. package/lib/auto-docs.mjs +10 -2
  7. package/lib/cli-commands.mjs +43 -15
  8. package/lib/comment-lint.mjs +115 -0
  9. package/lib/completions.mjs +7 -1
  10. package/lib/config/schema.mjs +4 -0
  11. package/lib/decisions/enforced-baseline.json +2 -0
  12. package/lib/docs-verify.mjs +15 -18
  13. package/lib/doctor/cli.mjs +8 -1
  14. package/lib/document-export.mjs +124 -0
  15. package/lib/embed/daemon.mjs +1 -1
  16. package/lib/embed/inbox.mjs +25 -7
  17. package/lib/features.mjs +11 -11
  18. package/lib/git-hooks-path.mjs +61 -0
  19. package/lib/home-namespace.mjs +60 -0
  20. package/lib/hooks/ci-status-check.mjs +62 -40
  21. package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
  22. package/lib/hooks/pre-push-gate.mjs +15 -6
  23. package/lib/hooks/stop-notify.mjs +32 -17
  24. package/lib/hooks/stop-typecheck.mjs +7 -2
  25. package/lib/host-capabilities.mjs +24 -8
  26. package/lib/host-disposition.mjs +76 -0
  27. package/lib/ingest/provider-extract.mjs +1 -1
  28. package/lib/init-docs.mjs +1 -1
  29. package/lib/init-unified.mjs +320 -219
  30. package/lib/init-update.mjs +4 -84
  31. package/lib/init.mjs +9 -25
  32. package/lib/install/stage-project.mjs +8 -2
  33. package/lib/intent-classifier.mjs +1 -1
  34. package/lib/knowledge/search.mjs +52 -3
  35. package/lib/mcp/server.mjs +57 -14
  36. package/lib/mcp/tools/memory.mjs +2 -2
  37. package/lib/mcp/tools/orchestration-run.mjs +125 -0
  38. package/lib/model-registry.mjs +40 -33
  39. package/lib/observation-store.mjs +6 -2
  40. package/lib/opencode-config.mjs +1 -1
  41. package/lib/orchestration/events.mjs +66 -0
  42. package/lib/orchestration/runtime.mjs +70 -9
  43. package/lib/orchestration/worker.mjs +69 -21
  44. package/lib/orchestration-policy.mjs +27 -3
  45. package/lib/parity.mjs +46 -24
  46. package/lib/policy/unified-gates.mjs +96 -0
  47. package/lib/project-init-shared.mjs +0 -173
  48. package/lib/reconcile/adapter-prune.mjs +105 -0
  49. package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
  50. package/lib/reconcile/gitignore-coverage.mjs +88 -0
  51. package/lib/reconcile/index.mjs +171 -0
  52. package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
  53. package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
  54. package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
  55. package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
  56. package/lib/reconcile/postgres-namespace.mjs +102 -0
  57. package/lib/runtime/uv-bootstrap.mjs +27 -3
  58. package/lib/schema-infer.mjs +16 -2
  59. package/lib/server/csrf.mjs +14 -2
  60. package/lib/server/index.mjs +95 -0
  61. package/lib/service-manager.mjs +39 -14
  62. package/lib/setup-prompts.mjs +2 -1
  63. package/lib/setup.mjs +165 -141
  64. package/lib/storage/backend.mjs +14 -0
  65. package/lib/storage/unified-storage.mjs +550 -0
  66. package/lib/template-registry.mjs +73 -0
  67. package/lib/term-format.mjs +75 -0
  68. package/lib/uninstall/uninstall.mjs +180 -7
  69. package/package.json +2 -2
  70. package/personas/construct.md +7 -8
  71. package/platforms/claude/settings.template.json +30 -4
  72. package/platforms/opencode/config.template.json +2 -2
  73. package/rules/common/neurodivergent-output.md +66 -0
  74. package/rules/common/tool-invisibility.md +37 -0
  75. package/scripts/sync-specialists.mjs +381 -95
  76. package/skills/operating/orchestration-reference.md +2 -16
  77. package/specialists/policy-inventory.json +14 -0
  78. package/specialists/prompts/cx-accessibility.md +2 -6
  79. package/specialists/prompts/cx-ai-engineer.md +0 -4
  80. package/specialists/prompts/cx-architect.md +3 -5
  81. package/specialists/prompts/cx-business-strategist.md +0 -5
  82. package/specialists/prompts/cx-data-analyst.md +0 -4
  83. package/specialists/prompts/cx-data-engineer.md +0 -4
  84. package/specialists/prompts/cx-debugger.md +2 -6
  85. package/specialists/prompts/cx-designer.md +0 -8
  86. package/specialists/prompts/cx-devil-advocate.md +2 -2
  87. package/specialists/prompts/cx-docs-keeper.md +0 -13
  88. package/specialists/prompts/cx-engineer.md +0 -13
  89. package/specialists/prompts/cx-evaluator.md +2 -2
  90. package/specialists/prompts/cx-explorer.md +4 -5
  91. package/specialists/prompts/cx-legal-compliance.md +4 -5
  92. package/specialists/prompts/cx-operations.md +0 -5
  93. package/specialists/prompts/cx-orchestrator.md +0 -4
  94. package/specialists/prompts/cx-platform-engineer.md +0 -8
  95. package/specialists/prompts/cx-product-manager.md +0 -8
  96. package/specialists/prompts/cx-qa.md +2 -11
  97. package/specialists/prompts/cx-rd-lead.md +0 -5
  98. package/specialists/prompts/cx-release-manager.md +0 -8
  99. package/specialists/prompts/cx-researcher.md +5 -29
  100. package/specialists/prompts/cx-reviewer.md +2 -6
  101. package/specialists/prompts/cx-security.md +2 -11
  102. package/specialists/prompts/cx-sre.md +0 -15
  103. package/specialists/prompts/cx-test-automation.md +0 -4
  104. package/specialists/prompts/cx-trace-reviewer.md +2 -2
  105. package/specialists/prompts/cx-ux-researcher.md +0 -4
  106. package/specialists/registry.json +29 -29
  107. package/templates/distribution/run.mjs +36 -7
  108. package/templates/docs/accessibility-audit.md +56 -0
  109. package/templates/docs/architecture-review.md +59 -0
  110. package/templates/docs/code-review-report.md +46 -0
  111. package/templates/docs/construct_guide.md +14 -14
  112. package/templates/docs/debug-investigation.md +53 -0
  113. package/templates/docs/qa-report.md +48 -0
  114. package/templates/docs/security-audit-report.md +48 -0
  115. package/templates/docs/task-packet.md +49 -0
  116. package/templates/docs/verdict.md +40 -0
  117. package/lib/server/static/index.html +0 -1
@@ -20,17 +20,14 @@ import path from 'node:path';
20
20
  * 1. Environment variables (CX_MODEL_*): Emergency overrides
21
21
  * 2. User config (~/.construct/config.env): Personal defaults
22
22
  * 3. Registry (specialists/registry.json): Project recommendations
23
- * 4. Built-in defaults: Last resort fallbacks
24
- *
25
- * Primary resolution path. All code must use this function.
23
+ *
24
+ * Construct ships with NO implicit model defaults (ADR-0027,
25
+ * tests/model-router-no-defaults.test.mjs). When none of the three sources
26
+ * resolves a tier, that tier returns null with source 'not configured'.
27
+ * Callers must surface a clear remediation hint rather than silently
28
+ * substituting a vendor-specific default.
26
29
  */
27
30
 
28
- const BUILTIN_DEFAULTS = {
29
- reasoning: 'anthropic/claude-3-5-sonnet',
30
- standard: 'anthropic/claude-3-haiku',
31
- fast: 'anthropic/claude-3-haiku',
32
- };
33
-
34
31
  const ENV_PREFIX = 'CX_MODEL';
35
32
 
36
33
  // ---------------------------------------------------------------------------
@@ -86,28 +83,30 @@ export function resolveModelTiers(options = {}) {
86
83
  }
87
84
  }
88
85
 
89
- // Built-in default
90
- result[tier] = BUILTIN_DEFAULTS[tier];
91
- sources[tier] = 'default';
86
+ // No source resolved this tier — leave null + 'not configured' so callers
87
+ // can surface a clear remediation hint. Aligns with the wired path
88
+ // (lib/model-router.mjs:resolveTierAssignments) and the no-defaults
89
+ // contract locked in by tests/model-router-no-defaults.test.mjs.
90
+
91
+ result[tier] = null;
92
+ sources[tier] = 'not configured';
92
93
  }
93
-
94
- // Validation
94
+
95
95
  const configured = Object.values(result).filter(Boolean).length;
96
-
96
+
97
97
  if (strict && configured < 3) {
98
98
  const missing = Object.entries(result)
99
99
  .filter(([, v]) => !v)
100
100
  .map(([k]) => k);
101
101
  errors.push(`Missing configuration for tiers: ${missing.join(', ')}`);
102
102
  }
103
-
103
+
104
104
  return {
105
105
  models: result,
106
106
  sources,
107
107
  configured,
108
108
  complete: configured === 3,
109
109
  errors: errors.length > 0 ? errors : null,
110
- defaultsUsed: Object.values(sources).filter(s => s === 'default').length,
111
110
  };
112
111
  }
113
112
 
@@ -117,7 +116,7 @@ export function resolveModelTiers(options = {}) {
117
116
 
118
117
  export function getModelForTier(tier, options = {}) {
119
118
  const resolved = resolveModelTiers(options);
120
- return resolved.models[tier] || BUILTIN_DEFAULTS[tier];
119
+ return resolved.models[tier];
121
120
  }
122
121
 
123
122
  export function getModelSource(tier, options = {}) {
@@ -131,12 +130,15 @@ export function getModelSource(tier, options = {}) {
131
130
 
132
131
  export function validateModelTiers(options = {}) {
133
132
  const resolved = resolveModelTiers({ ...options, strict: true });
134
-
133
+ const unconfigured = Object.entries(resolved.sources)
134
+ .filter(([, source]) => source === 'not configured')
135
+ .map(([tier]) => tier);
136
+
135
137
  return {
136
138
  valid: resolved.complete && !resolved.errors,
137
139
  errors: resolved.errors,
138
- warnings: resolved.defaultsUsed > 0
139
- ? [`Using ${resolved.defaultsUsed} built-in default(s). Consider configuring custom models.`]
140
+ warnings: unconfigured.length > 0
141
+ ? [`Unconfigured tier${unconfigured.length === 1 ? '' : 's'}: ${unconfigured.join(', ')}. Run 'construct models --apply' or set CX_MODEL_<TIER>.`]
140
142
  : [],
141
143
  resolution: resolved,
142
144
  };
@@ -144,25 +146,30 @@ export function validateModelTiers(options = {}) {
144
146
 
145
147
  export function formatModelStatus(options = {}) {
146
148
  const resolved = resolveModelTiers(options);
147
-
149
+
148
150
  let output = 'Model Configuration:\n\n';
149
-
151
+
150
152
  for (const tier of ['reasoning', 'standard', 'fast']) {
151
153
  const model = resolved.models[tier];
152
154
  const source = resolved.sources[tier];
153
- const icon = source === 'default' ? '⚠' : '✓';
154
-
155
- output += `${icon} ${tier.padEnd(10)} ${model}\n`;
155
+ const icon = source === 'not configured' ? '⚠' : '✓';
156
+ const display = model ?? '(not configured)';
157
+
158
+ output += `${icon} ${tier.padEnd(10)} ${display}\n`;
156
159
  output += ` Source: ${source}\n\n`;
157
160
  }
158
-
159
- if (resolved.defaultsUsed > 0) {
160
- output += '\nNote: Using built-in defaults. To customize:\n';
161
- output += ' export CX_MODEL_REASONING=your-model\n';
162
- output += ' export CX_MODEL_STANDARD=your-model\n';
163
- output += ' export CX_MODEL_FAST=your-model\n';
161
+
162
+ const unconfigured = Object.entries(resolved.sources)
163
+ .filter(([, source]) => source === 'not configured')
164
+ .map(([tier]) => tier);
165
+ if (unconfigured.length > 0) {
166
+ output += `\nUnconfigured: ${unconfigured.join(', ')}. To configure:\n`;
167
+ output += ' construct models --apply (interactive, picks from available providers)\n';
168
+ output += ' export CX_MODEL_REASONING=... (env override)\n';
169
+ output += ' export CX_MODEL_STANDARD=...\n';
170
+ output += ' export CX_MODEL_FAST=...\n';
164
171
  }
165
-
172
+
166
173
  return output;
167
174
  }
168
175
 
@@ -238,8 +238,12 @@ export async function addObservation(rootDir, {
238
238
  return record;
239
239
  }
240
240
  await client.close();
241
- } catch {
242
- // Fall through to local vector storage
241
+ } catch (err) {
242
+ // A reachable backend that still fails to store (dimension drift, auth, transient
243
+ // network) must not look identical to "no backend configured" — surface it, then
244
+ // preserve the observation in the local index so the write never silently vanishes.
245
+
246
+ process.stderr.write(`[observation-store] SQL store failed; kept observation in local index: ${err?.message || err}\n`);
243
247
  }
244
248
 
245
249
  // Fallback: local JSON vector index with hashing-bow-v1.
@@ -48,7 +48,7 @@ export function sanitizeOpenCodeConfig(config) {
48
48
 
49
49
  export function writeOpenCodeConfig(config, file = findOpenCodeConfigPath()) {
50
50
  // Treat the canonical home config as the default target; any explicit path
51
- // outside the canonical home dir (e.g. project-scoped .opencode/config.json)
51
+ // outside the canonical home dir (e.g. project-scoped .opencode/opencode.json)
52
52
  // is honored as-is so per-project sync writes to the right tree.
53
53
 
54
54
  const canonical = getCanonicalOpenCodeConfigPath();
@@ -0,0 +1,66 @@
1
+ /**
2
+ * lib/orchestration/events.mjs — in-process run lifecycle event bus.
3
+ *
4
+ * The orchestration runtime persists runs to a store and appends durable JSONL
5
+ * traces, but has no live pub/sub a server can stream from. This is that
6
+ * primitive: a process-local EventEmitter keyed by runId so an HTTP SSE handler
7
+ * can subscribe to a single run's transitions (planned → running → per-task →
8
+ * completed) and forward them to a thin client. Events are lightweight status
9
+ * deltas — never task output or any credential — so the stream stays cheap and
10
+ * secret-free; clients fetch full run records (envelope-guarded) for outputs.
11
+ *
12
+ * Process-local by design: it streams runs executing in THIS process. Durable,
13
+ * cross-process history lives in the run store and the trace log.
14
+ */
15
+
16
+ import { EventEmitter } from 'node:events';
17
+
18
+ const bus = new EventEmitter();
19
+ bus.setMaxListeners(0);
20
+
21
+ function channel(runId) {
22
+ return `run:${runId}`;
23
+ }
24
+
25
+ /**
26
+ * Emit a run lifecycle event. Stamps runId + an ISO timestamp; callers pass a
27
+ * small status delta (type, status, taskId, role, executor, error).
28
+ *
29
+ * @param {string} runId
30
+ * @param {object} event
31
+ */
32
+ export function emitRunEvent(runId, event = {}) {
33
+ if (!runId) return;
34
+ bus.emit(channel(runId), { runId, at: new Date().toISOString(), ...event });
35
+ }
36
+
37
+ /**
38
+ * Subscribe to one run's events.
39
+ *
40
+ * @param {string} runId
41
+ * @param {(event:object)=>void} handler
42
+ * @returns {() => void} unsubscribe
43
+ */
44
+ export function onRunEvent(runId, handler) {
45
+ const ch = channel(runId);
46
+ bus.on(ch, handler);
47
+ return () => bus.off(ch, handler);
48
+ }
49
+
50
+ // Cooperative, between-task cancellation. executeRun checks this before each
51
+ // task and stops cleanly if set — an honest soft cancel (it cannot abort a
52
+ // model call already in flight), not a forced kill.
53
+
54
+ const cancelled = new Set();
55
+
56
+ export function requestCancel(runId) {
57
+ if (runId) cancelled.add(runId);
58
+ }
59
+
60
+ export function isCancelRequested(runId) {
61
+ return cancelled.has(runId);
62
+ }
63
+
64
+ export function clearCancel(runId) {
65
+ cancelled.delete(runId);
66
+ }
@@ -35,12 +35,16 @@ import { routeRequest } from '../orchestration-policy.mjs';
35
35
  import { resolveExecution } from '../embedded-contract/execution.mjs';
36
36
  import { detectHostCapabilities } from '../host-capabilities.mjs';
37
37
  import { loadProjectConfig } from '../config/project-config.mjs';
38
+ import { CHAIN_OF_THOUGHT_MODES } from '../config/schema.mjs';
38
39
  import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
39
40
  import { resolveRunStore } from './store.mjs';
40
41
  import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
42
+ import { emitRunEvent, isCancelRequested, clearCancel } from './events.mjs';
41
43
 
42
44
  export const WORKER_BACKENDS = WORKER_BACKEND_SET;
43
45
  export const DEFAULT_WORKER_BACKEND = INLINE;
46
+ export const CHAIN_OF_THOUGHT = CHAIN_OF_THOUGHT_MODES;
47
+ export const DEFAULT_CHAIN_OF_THOUGHT = 'hidden';
44
48
 
45
49
  const RUNTIME_SEMANTICS = 'The inline worker backend owns planning, sequencing, handoff state, persistence, and observability, and prepares each specialist task for a downstream executor. It does not perform specialist LLM reasoning itself; tasks reaching status "prepared" are ready for a worker backend or host to execute. The provider worker backend executes specialist reasoning via the configured model and records real output as task.output.';
46
50
 
@@ -97,6 +101,7 @@ function buildTasks(route) {
97
101
  status: 'queued',
98
102
  executor: null,
99
103
  output: null,
104
+ reasoning: null,
100
105
  error: null,
101
106
  startedAt: null,
102
107
  finishedAt: null,
@@ -112,6 +117,16 @@ function resolveWorkerBackend({ explicit, run, config }) {
112
117
  return WORKER_BACKENDS.includes(candidate) ? candidate : DEFAULT_WORKER_BACKEND;
113
118
  }
114
119
 
120
+ // Chain-of-thought disclosure is a deployment concern (ADR-0030): an explicit
121
+ // option wins, then the run's recorded mode, then config
122
+ // (orchestration.chainOfThought), then the hidden default. An unknown value
123
+ // falls back to hidden — specialist reasoning is never surfaced by guess.
124
+
125
+ function resolveChainOfThought({ explicit, run, config }) {
126
+ const candidate = explicit || run?.chainOfThought || config?.orchestration?.chainOfThought || DEFAULT_CHAIN_OF_THOUGHT;
127
+ return CHAIN_OF_THOUGHT.includes(candidate) ? candidate : DEFAULT_CHAIN_OF_THOUGHT;
128
+ }
129
+
115
130
  function loadConfig(cwd, env) {
116
131
  try {
117
132
  return loadProjectConfig(cwd, env).config || {};
@@ -147,6 +162,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
147
162
  const runId = newRunId();
148
163
  const now = new Date().toISOString();
149
164
  const workerBackend = resolveWorkerBackend({ config });
165
+ const chainOfThought = resolveChainOfThought({ config });
150
166
 
151
167
  // Construct owns a specialist task sequence only when the contract resolves to
152
168
  // orchestrated execution; prompt-only and host-direct own none, and the run
@@ -162,6 +178,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
162
178
  request: { summary: truncate(text, 200), workflowType, requestedStrategy },
163
179
  hostRole: resolveHostRole(host),
164
180
  workerBackend,
181
+ chainOfThought,
165
182
  execution: pickExecution(execData),
166
183
  plan: {
167
184
  intent: route.intent,
@@ -182,6 +199,7 @@ export async function planRun(request = {}, { env = process.env, cwd = process.c
182
199
  rootDir: cwd, env, traceId, spanId: newSpanId(), eventType: 'task_graph.created',
183
200
  metadata: { runId, executionMode: run.execution.executionMode, specialists: run.plan.specialists, workerBackend: run.workerBackend },
184
201
  });
202
+ emitRunEvent(runId, { type: 'planned', status: 'planned', executionMode: run.execution.executionMode, taskCount: tasks.length });
185
203
  return run;
186
204
  }
187
205
 
@@ -198,25 +216,29 @@ function prepareTaskInline(task) {
198
216
  // task is recorded (status `failed`, task.error) and does not abort the run, so
199
217
  // one specialist failure cannot lose the work of the others.
200
218
 
201
- async function executeTaskViaProvider(task, run, env, fetchImpl) {
219
+ async function executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought) {
202
220
  try {
203
221
  const result = await runTaskViaProvider({
204
222
  task, run,
205
223
  model: run.execution?.selectedModel,
206
224
  provider: run.execution?.selectedProvider,
207
- env, fetchImpl,
225
+ env, fetchImpl, chainOfThought,
208
226
  });
209
227
  task.output = result.output;
210
228
  task.executor = `provider:${result.provider}:${result.model}`;
211
229
  task.status = 'done';
212
230
  task.finishedAt = new Date().toISOString();
213
- return true;
231
+
232
+ // `surface` attaches reasoning to the task so every display surface renders
233
+ // it; `telemetry_only` keeps it off the task (recorded only in the trace).
234
+ if (chainOfThought === 'surface' && result.reasoning) task.reasoning = result.reasoning;
235
+ return { ok: true, reasoning: result.reasoning || '' };
214
236
  } catch (err) {
215
237
  task.executor = `provider:error`;
216
238
  task.error = { code: err.code || 'PROVIDER_EXECUTION_FAILED', message: err.message };
217
239
  task.status = 'failed';
218
240
  task.finishedAt = new Date().toISOString();
219
- return false;
241
+ return { ok: false, reasoning: '' };
220
242
  }
221
243
  }
222
244
 
@@ -240,33 +262,51 @@ export async function executeRun(cwd, runId, { env = process.env, workerBackend
240
262
  }
241
263
 
242
264
  const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
265
+ const chainOfThought = resolveChainOfThought({ run, config });
243
266
  run.workerBackend = backend;
267
+ run.chainOfThought = chainOfThought;
244
268
  run.status = 'running';
245
269
  run.updatedAt = new Date().toISOString();
246
270
  await store.saveRun(run);
271
+ emitRunEvent(runId, { type: 'running', status: 'running', workerBackend: backend });
247
272
 
248
273
  let anyFailed = false;
274
+ let cancelled = false;
249
275
  for (const task of run.tasks) {
276
+ if (isCancelRequested(runId)) { cancelled = true; break; }
250
277
  task.status = 'running';
251
278
  task.startedAt = new Date().toISOString();
252
279
  emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
280
+ emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: 'running' });
253
281
 
282
+ let taskReasoning = '';
254
283
  if (backend === PROVIDER) {
255
- const ok = await executeTaskViaProvider(task, run, env, fetchImpl);
256
- if (!ok) anyFailed = true;
284
+ const res = await executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought);
285
+ if (!res.ok) anyFailed = true;
286
+ taskReasoning = res.reasoning;
257
287
  } else {
258
288
  prepareTaskInline(task);
259
289
  }
260
290
 
261
- emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: { runId, status: task.status } });
291
+ // telemetry_only records reasoning to the trace without ever surfacing it to
292
+ // a display; surface keeps it off the trace (it rides on task.reasoning).
293
+ const completedMeta = { runId, status: task.status };
294
+ if (chainOfThought === 'telemetry_only' && taskReasoning) {
295
+ completedMeta.reasoning = taskReasoning;
296
+ completedMeta.reasoningChars = taskReasoning.length;
297
+ }
298
+ emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: completedMeta });
299
+ emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: task.status, executor: task.executor, ...(task.reasoning ? { reasoning: task.reasoning } : {}), ...(task.error ? { error: task.error } : {}) });
262
300
  run.updatedAt = new Date().toISOString();
263
301
  await store.saveRun(run);
264
302
  }
265
303
 
266
- run.status = anyFailed ? 'completed-with-failures' : 'completed';
304
+ run.status = cancelled ? 'cancelled' : (anyFailed ? 'completed-with-failures' : 'completed');
267
305
  run.updatedAt = new Date().toISOString();
268
306
  await store.saveRun(run);
307
+ clearCancel(runId);
269
308
  emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
309
+ emitRunEvent(runId, { type: 'completed', status: run.status });
270
310
  return run;
271
311
  }
272
312
 
@@ -278,6 +318,26 @@ export async function runOrchestration(request = {}, { env = process.env, cwd =
278
318
  return executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl });
279
319
  }
280
320
 
321
+ /**
322
+ * Start a run for a thin client: plan + persist synchronously, then execute in
323
+ * the BACKGROUND (not awaited) so the caller gets a runId immediately and tracks
324
+ * progress via the event stream / run store. Execution failures are caught and
325
+ * surfaced as a terminal `error` run event rather than an unhandled rejection.
326
+ *
327
+ * @param {object} request
328
+ * @param {object} [opts] { env, cwd, workerBackend, fetchImpl }
329
+ * @returns {Promise<object>} the planned run (status `planned`)
330
+ */
331
+ export async function startRun(request = {}, { env = process.env, cwd = process.cwd(), workerBackend = null, fetchImpl } = {}) {
332
+ const planned = await planRun(request, { env, cwd });
333
+ Promise.resolve()
334
+ .then(() => executeRun(cwd, planned.runId, { env, workerBackend, fetchImpl }))
335
+ .catch((err) => {
336
+ emitRunEvent(planned.runId, { type: 'error', status: 'error', error: { code: err.code || 'RUN_FAILED', message: err.message } });
337
+ });
338
+ return planned;
339
+ }
340
+
281
341
  /**
282
342
  * The structured metadata a host adapter consumes for runtime-backed integration.
283
343
  */
@@ -292,12 +352,13 @@ export function hostAdapterMetadata(run) {
292
352
  executionMode: e.executionMode,
293
353
  constructCapabilitiesActive: e.constructCapabilitiesActive,
294
354
  workerBackend: run.workerBackend,
355
+ chainOfThought: run.chainOfThought ?? null,
295
356
  hostRole: run.hostRole,
296
357
  degraded: e.degraded,
297
358
  degradationReason: e.degradationReason,
298
359
  selectedProvider: e.selectedProvider,
299
360
  selectedModel: e.selectedModel,
300
- tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor })),
361
+ tasks: (run.tasks || []).map((t) => ({ id: t.id, role: t.role, status: t.status, executor: t.executor, reasoning: t.reasoning ?? null })),
301
362
  warnings: run.warnings || [],
302
363
  semantics: run.semantics,
303
364
  executionSemantics: run.executionSemantics,
@@ -32,6 +32,12 @@ export const WORKER_BACKEND_SET = [INLINE, PROVIDER];
32
32
 
33
33
  const MAX_OUTPUT_TOKENS = 2048;
34
34
 
35
+ // Extended-thinking budget requested only when a caller opts into reasoning
36
+ // capture (chainOfThought !== 'hidden'). Anthropic requires budget_tokens < the
37
+ // turn's max_tokens, so the budget is added on top of the output ceiling.
38
+
39
+ const REASONING_BUDGET_TOKENS = 1024;
40
+
35
41
  const HERE = dirname(fileURLToPath(import.meta.url));
36
42
  const PROMPTS_DIR = join(HERE, '..', '..', 'specialists', 'prompts');
37
43
 
@@ -46,6 +52,20 @@ function isAnthropic(provider, model) {
46
52
  return /anthropic|claude/i.test(provider || '') || /claude/i.test(model || '');
47
53
  }
48
54
 
55
+ // Extended-thinking shape is model-specific. Opus 4.8 / 4.7 REJECT the legacy
56
+ // `{type:'enabled', budget_tokens}` form with a 400 and require adaptive
57
+ // thinking; Opus 4.6 and Sonnet 4.6 recommend adaptive too. Only genuinely
58
+ // older models (Sonnet 4.5 and earlier) still take the budget form. On adaptive
59
+ // models `display:'summarized'` is required, or returned thinking blocks carry
60
+ // empty text. (Source: claude-api skill — extended/adaptive thinking; ADR-0030.)
61
+
62
+ function anthropicThinkingConfig(model) {
63
+ const adaptive = /(opus-4-(?:[6-9]|\d\d)|sonnet-4-(?:[6-9]|\d\d))/i.test(model || '');
64
+ return adaptive
65
+ ? { type: 'adaptive', display: 'summarized' }
66
+ : { type: 'enabled', budget_tokens: REASONING_BUDGET_TOKENS };
67
+ }
68
+
49
69
  // Key resolution mirrors provider-extract: env first, then the two cheap dotenv
50
70
  // files, and only when the caller did not inject an explicit env. A caller that
51
71
  // passes its own env object (embedded callers, tests) is authoritative and
@@ -93,39 +113,62 @@ async function bodyExcerpt(res) {
93
113
  }
94
114
  }
95
115
 
96
- async function callAnthropic({ model, apiKey, system, user, fetchImpl }) {
116
+ // OpenRouter normalizes reasoning into a plaintext `reasoning` string and/or a
117
+ // structured `reasoning_details` array (text / summary / encrypted entries). We
118
+ // read the plaintext first, then assemble any readable detail entries; encrypted
119
+ // carriers have no displayable text and are skipped.
120
+
121
+ function extractOpenRouterReasoning(message) {
122
+ if (typeof message.reasoning === 'string' && message.reasoning.trim()) return message.reasoning.trim();
123
+ const details = Array.isArray(message.reasoning_details) ? message.reasoning_details : [];
124
+ return details.map((d) => d?.text || d?.summary || '').filter(Boolean).join('\n').trim();
125
+ }
126
+
127
+ async function callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning }) {
128
+ const thinking = reasoning ? anthropicThinkingConfig(model) : null;
129
+ const body = {
130
+ model: model.replace(/^anthropic\//, ''),
131
+ max_tokens: thinking ? MAX_OUTPUT_TOKENS + REASONING_BUDGET_TOKENS : MAX_OUTPUT_TOKENS,
132
+ system,
133
+ messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
134
+ };
135
+ if (thinking) body.thinking = thinking;
97
136
  const res = await fetchImpl('https://api.anthropic.com/v1/messages', {
98
137
  method: 'POST',
99
138
  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
- }),
139
+ body: JSON.stringify(body),
106
140
  });
107
141
  if (!res.ok) {
108
142
  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
143
  }
110
144
  const data = await res.json();
111
- return (data.content || []).filter((b) => b && b.type === 'text').map((b) => b.text).join('');
145
+ const blocks = data.content || [];
146
+ const output = blocks.filter((b) => b && b.type === 'text').map((b) => b.text).join('');
147
+ const thought = blocks.filter((b) => b && b.type === 'thinking').map((b) => b.thinking || '').join('').trim();
148
+ return { output, reasoning: thought };
112
149
  }
113
150
 
114
- async function callOpenRouter({ model, apiKey, system, user, fetchImpl }) {
151
+ async function callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning }) {
152
+ const body = {
153
+ model: model.replace(/^openrouter\//, ''),
154
+ max_tokens: MAX_OUTPUT_TOKENS,
155
+ messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
156
+ };
157
+ // Enable reasoning when wanted; explicitly exclude it otherwise — some models
158
+ // (e.g. Gemini) return reasoning by default, which would leak in hidden mode.
159
+ // An empty `{}` is a no-op on OpenRouter; `{enabled:true}` is the on switch.
160
+ body.reasoning = reasoning ? { enabled: true } : { exclude: true };
115
161
  const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
116
162
  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
- }),
163
+ headers: { Authorization: `Bearer ${apiKey}`, 'content-type': 'application/json', 'HTTP-Referer': 'https://github.com/geraldmaron/construct' },
164
+ body: JSON.stringify(body),
123
165
  });
124
166
  if (!res.ok) {
125
167
  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
168
  }
127
169
  const data = await res.json();
128
- return data.choices?.[0]?.message?.content || '';
170
+ const message = data.choices?.[0]?.message || {};
171
+ return { output: message.content || '', reasoning: reasoning ? extractOpenRouterReasoning(message) : '' };
129
172
  }
130
173
 
131
174
  /**
@@ -138,9 +181,12 @@ async function callOpenRouter({ model, apiKey, system, user, fetchImpl }) {
138
181
  * @param {string} [opts.provider] resolved provider id (selects the API)
139
182
  * @param {Record<string,string>} [opts.env]
140
183
  * @param {Function} [opts.fetchImpl] injectable fetch (for tests)
141
- * @returns {Promise<{output:string, model:string, provider:string, characters:number}>}
184
+ * @param {string} [opts.chainOfThought] reasoning disclosure mode: `hidden` (default,
185
+ * no reasoning requested) | `surface` | `telemetry_only` (both request and
186
+ * return the model's reasoning for the caller to display or record)
187
+ * @returns {Promise<{output:string, reasoning:string, model:string, provider:string, characters:number}>}
142
188
  */
143
- export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch } = {}) {
189
+ export async function runTaskViaProvider({ task, run, model, provider = null, env = process.env, fetchImpl = globalThis.fetch, chainOfThought = 'hidden' } = {}) {
144
190
  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
191
  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
192
 
@@ -153,13 +199,15 @@ export async function runTaskViaProvider({ task, run, model, provider = null, en
153
199
 
154
200
  const system = loadPersona(task?.role);
155
201
  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 });
202
+ const wantsReasoning = chainOfThought !== 'hidden';
203
+ const { output, reasoning } = anthropic
204
+ ? await callAnthropic({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning })
205
+ : await callOpenRouter({ model, apiKey, system, user, fetchImpl, reasoning: wantsReasoning });
159
206
 
160
207
  const text = output || '';
161
208
  return {
162
209
  output: text,
210
+ reasoning: wantsReasoning ? (reasoning || '') : '',
163
211
  model,
164
212
  provider: anthropic ? 'anthropic' : 'openrouter',
165
213
  characters: text.length,
@@ -370,9 +370,9 @@ export function formatOverlaySelection(roleFlavors) {
370
370
  export function detectRiskFlags(request = '') {
371
371
  const text = String(request).toLowerCase();
372
372
  return {
373
- architecture: containsAny(text, ['architecture', 'interface contract', 'api contract', 'dependency', 'module boundary', 'data model', 'indexing', 'retrieval design']),
374
- security: containsAny(text, ['security', 'permission', 'secret', 'privacy', 'payment', 'authentication', 'authorization']),
375
- dataIntegrity: containsAny(text, ['migration', 'data', 'sync', 'consistency', 'state']),
373
+ architecture: containsAny(text, ['architecture', 'interface contract', 'api contract', 'dependency', 'module boundary', 'data model', 'indexing', 'retrieval design', 'terraform', 'infrastructure', 'iac', 'provisioning', 'blast radius', 'rollout', 'deployment strategy']),
374
+ security: containsAny(text, ['security', 'permission', 'secret', 'privacy', 'payment', 'authentication', 'authorization', 'credential', 'oidc', 'iam', 'access token']),
375
+ dataIntegrity: containsAny(text, ['migration', 'data', 'sync', 'consistency', 'state', 'tfstate', 'drift']),
376
376
  ui: containsAny(text, ['ui', 'ux', 'design system', 'screen', 'layout', 'visual', 'onboarding']) && !containsAny(text, ['requirements']),
377
377
  docs: containsAny(text, ['docs', 'readme', 'runbook', 'adr']),
378
378
  ai: containsAny(text, ['llm', ' agent', 'prompt', 'rag', 'model behavior', 'retrieval', 'embedding', 'vector']),
@@ -594,6 +594,30 @@ export function buildDispatchPlan({ track, intent, specialists = [] }) {
594
594
  return `Plan: ${phases.join(' → ')}.`;
595
595
  }
596
596
 
597
+ // Format active flavor overlays into a one-line trace, e.g.
598
+ // "Overlays: architect=platform (matched kubernetes, infra), engineer=ai (matched llm, agent)"
599
+
600
+ export function formatOverlayTrace(roleFlavors = {}, request = '') {
601
+ const text = String(request).toLowerCase();
602
+ const active = Object.entries(roleFlavors).filter(([, flavor]) => flavor);
603
+ if (active.length === 0) return '';
604
+ const FLAVOR_KEYWORDS = {
605
+ architect: ['agent', 'rag', 'retrieval', 'embedding', 'integration', 'webhook', 'warehouse', 'schema', 'sso', 'rbac', 'platform', 'kubernetes', 'infra'],
606
+ productManager: ['platform', 'api', 'enterprise', 'ai product', 'agent', 'growth', 'activation'],
607
+ qa: ['agent', 'prompt', 'api', 'sdk', 'pipeline', 'etl', 'ui', 'ux', 'accessibility'],
608
+ security: ['prompt injection', 'privacy', 'pii', 'dependency', 'cloud', 'iam', 'auth', 'xss', 'csrf'],
609
+ dataAnalyst: ['experiment', 'a/b', 'telemetry', 'metric', 'funnel'],
610
+ dataEngineer: ['vector', 'embedding', 'warehouse', 'pipeline', 'etl', 'pgvector'],
611
+ engineer: ['llm', 'agent', 'rag', 'kubernetes', 'k8s', 'terraform', 'infra', 'docker', 'pipeline', 'etl', 'data model'],
612
+ };
613
+ const parts = active.map(([role, flavor]) => {
614
+ const kws = (FLAVOR_KEYWORDS[role] || []).filter((kw) => text.includes(kw));
615
+ const matched = kws.slice(0, 3).join(', ');
616
+ return matched ? `${role}=${flavor} (matched ${matched})` : `${role}=${flavor}`;
617
+ });
618
+ return `Overlays: ${parts.join(', ')}`;
619
+ }
620
+
597
621
  export function routeRequest(options = {}) {
598
622
  const intent = classifyIntent(options.request);
599
623
  const riskFlags = detectRiskFlags(options.request);