@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.
- package/README.md +23 -5
- package/bin/construct +191 -13
- package/bin/construct-postinstall.mjs +25 -15
- package/lib/acp/server.mjs +113 -0
- package/lib/agent-instructions/inject.mjs +94 -0
- package/lib/auto-docs.mjs +10 -2
- package/lib/cli-commands.mjs +43 -15
- package/lib/comment-lint.mjs +115 -0
- package/lib/completions.mjs +7 -1
- package/lib/config/schema.mjs +4 -0
- package/lib/decisions/enforced-baseline.json +2 -0
- package/lib/docs-verify.mjs +15 -18
- package/lib/doctor/cli.mjs +8 -1
- package/lib/document-export.mjs +124 -0
- package/lib/embed/daemon.mjs +1 -1
- package/lib/embed/inbox.mjs +25 -7
- package/lib/features.mjs +11 -11
- package/lib/git-hooks-path.mjs +61 -0
- package/lib/home-namespace.mjs +60 -0
- package/lib/hooks/ci-status-check.mjs +62 -40
- package/lib/hooks/orchestration-dispatch-guard.mjs +153 -0
- package/lib/hooks/pre-push-gate.mjs +15 -6
- package/lib/hooks/stop-notify.mjs +32 -17
- package/lib/hooks/stop-typecheck.mjs +7 -2
- package/lib/host-capabilities.mjs +24 -8
- package/lib/host-disposition.mjs +76 -0
- package/lib/ingest/provider-extract.mjs +1 -1
- package/lib/init-docs.mjs +1 -1
- package/lib/init-unified.mjs +320 -219
- package/lib/init-update.mjs +4 -84
- package/lib/init.mjs +9 -25
- package/lib/install/stage-project.mjs +8 -2
- package/lib/intent-classifier.mjs +1 -1
- package/lib/knowledge/search.mjs +52 -3
- package/lib/mcp/server.mjs +57 -14
- package/lib/mcp/tools/memory.mjs +2 -2
- package/lib/mcp/tools/orchestration-run.mjs +125 -0
- package/lib/model-registry.mjs +40 -33
- package/lib/observation-store.mjs +6 -2
- package/lib/opencode-config.mjs +1 -1
- package/lib/orchestration/events.mjs +66 -0
- package/lib/orchestration/runtime.mjs +70 -9
- package/lib/orchestration/worker.mjs +69 -21
- package/lib/orchestration-policy.mjs +27 -3
- package/lib/parity.mjs +46 -24
- package/lib/policy/unified-gates.mjs +96 -0
- package/lib/project-init-shared.mjs +0 -173
- package/lib/reconcile/adapter-prune.mjs +105 -0
- package/lib/reconcile/agent-instructions-rewrap.mjs +98 -0
- package/lib/reconcile/gitignore-coverage.mjs +88 -0
- package/lib/reconcile/index.mjs +171 -0
- package/lib/reconcile/legacy-doctrine-strip.mjs +139 -0
- package/lib/reconcile/legacy-guide-decommit.mjs +67 -0
- package/lib/reconcile/legacy-skills-cleanup.mjs +200 -0
- package/lib/reconcile/mcp-entry-reconcile.mjs +142 -0
- package/lib/reconcile/postgres-namespace.mjs +102 -0
- package/lib/runtime/uv-bootstrap.mjs +27 -3
- package/lib/schema-infer.mjs +16 -2
- package/lib/server/csrf.mjs +14 -2
- package/lib/server/index.mjs +95 -0
- package/lib/service-manager.mjs +39 -14
- package/lib/setup-prompts.mjs +2 -1
- package/lib/setup.mjs +165 -141
- package/lib/storage/backend.mjs +14 -0
- package/lib/storage/unified-storage.mjs +550 -0
- package/lib/template-registry.mjs +73 -0
- package/lib/term-format.mjs +75 -0
- package/lib/uninstall/uninstall.mjs +180 -7
- package/package.json +2 -2
- package/personas/construct.md +7 -8
- package/platforms/claude/settings.template.json +30 -4
- package/platforms/opencode/config.template.json +2 -2
- package/rules/common/neurodivergent-output.md +66 -0
- package/rules/common/tool-invisibility.md +37 -0
- package/scripts/sync-specialists.mjs +381 -95
- package/skills/operating/orchestration-reference.md +2 -16
- package/specialists/policy-inventory.json +14 -0
- package/specialists/prompts/cx-accessibility.md +2 -6
- package/specialists/prompts/cx-ai-engineer.md +0 -4
- package/specialists/prompts/cx-architect.md +3 -5
- package/specialists/prompts/cx-business-strategist.md +0 -5
- package/specialists/prompts/cx-data-analyst.md +0 -4
- package/specialists/prompts/cx-data-engineer.md +0 -4
- package/specialists/prompts/cx-debugger.md +2 -6
- package/specialists/prompts/cx-designer.md +0 -8
- package/specialists/prompts/cx-devil-advocate.md +2 -2
- package/specialists/prompts/cx-docs-keeper.md +0 -13
- package/specialists/prompts/cx-engineer.md +0 -13
- package/specialists/prompts/cx-evaluator.md +2 -2
- package/specialists/prompts/cx-explorer.md +4 -5
- package/specialists/prompts/cx-legal-compliance.md +4 -5
- package/specialists/prompts/cx-operations.md +0 -5
- package/specialists/prompts/cx-orchestrator.md +0 -4
- package/specialists/prompts/cx-platform-engineer.md +0 -8
- package/specialists/prompts/cx-product-manager.md +0 -8
- package/specialists/prompts/cx-qa.md +2 -11
- package/specialists/prompts/cx-rd-lead.md +0 -5
- package/specialists/prompts/cx-release-manager.md +0 -8
- package/specialists/prompts/cx-researcher.md +5 -29
- package/specialists/prompts/cx-reviewer.md +2 -6
- package/specialists/prompts/cx-security.md +2 -11
- package/specialists/prompts/cx-sre.md +0 -15
- package/specialists/prompts/cx-test-automation.md +0 -4
- package/specialists/prompts/cx-trace-reviewer.md +2 -2
- package/specialists/prompts/cx-ux-researcher.md +0 -4
- package/specialists/registry.json +29 -29
- package/templates/distribution/run.mjs +36 -7
- package/templates/docs/accessibility-audit.md +56 -0
- package/templates/docs/architecture-review.md +59 -0
- package/templates/docs/code-review-report.md +46 -0
- package/templates/docs/construct_guide.md +14 -14
- package/templates/docs/debug-investigation.md +53 -0
- package/templates/docs/qa-report.md +48 -0
- package/templates/docs/security-audit-report.md +48 -0
- package/templates/docs/task-packet.md +49 -0
- package/templates/docs/verdict.md +40 -0
- package/lib/server/static/index.html +0 -1
package/lib/model-registry.mjs
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
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]
|
|
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:
|
|
139
|
-
? [`
|
|
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 === '
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
output += '
|
|
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
|
-
//
|
|
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.
|
package/lib/opencode-config.mjs
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
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);
|