@geraldmaron/construct 1.4.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/construct +2 -1
- package/bin/construct-postinstall.mjs +27 -2
- package/config/tag-vocabulary.json +264 -0
- package/lib/config/schema.mjs +1 -1
- package/lib/doctor/diagnosis.mjs +20 -0
- package/lib/doctor/index.mjs +5 -0
- package/lib/embed/worker.mjs +5 -0
- package/lib/env-config.mjs +10 -2
- package/lib/export-validate.mjs +34 -2
- package/lib/host/readiness.mjs +109 -0
- package/lib/host-disposition.mjs +10 -1
- package/lib/install/stage-project.mjs +41 -4
- package/lib/intake/prepare.mjs +2 -0
- package/lib/mcp/destructive-approval.mjs +57 -0
- package/lib/mcp/server.mjs +208 -34
- package/lib/mcp/tool-rate-limit.mjs +47 -0
- package/lib/mcp/tool-safety.mjs +94 -0
- package/lib/mcp/tool-surface-parity.mjs +60 -0
- package/lib/mcp/tools/orchestration-run.mjs +45 -7
- package/lib/mcp/tools/project.mjs +25 -8
- package/lib/mcp/tools/storage.mjs +9 -1
- package/lib/mcp/tools/web-search-governance.mjs +96 -0
- package/lib/mcp/tools/web-search.mjs +5 -76
- package/lib/mcp-platform-config.mjs +27 -18
- package/lib/oracle/daemon-entry.mjs +6 -0
- package/lib/orchestration/runtime.mjs +81 -42
- package/lib/orchestration/web-capability.mjs +59 -0
- package/lib/orchestration/worker.mjs +263 -19
- package/lib/output-quality.mjs +61 -2
- package/lib/path-policy.mjs +56 -0
- package/lib/providers/secret-audit-wiring.mjs +35 -18
- package/lib/providers/secret-resolver.mjs +28 -13
- package/lib/registry/catalog.mjs +6 -0
- package/lib/registry/loader.mjs +7 -1
- package/lib/registry/validate.mjs +3 -3
- package/lib/sandbox.mjs +1 -1
- package/lib/service-manager.mjs +59 -9
- package/lib/storage/admin.mjs +7 -2
- package/package.json +6 -1
- package/registry/agent-manifest.json +117 -0
- package/registry/capabilities.json +1880 -0
- package/schemas/brand-voice.schema.json +24 -0
- package/schemas/capability-registry.schema.json +72 -0
- package/schemas/certification-run.schema.json +130 -0
- package/schemas/demo-recording.schema.json +46 -0
- package/schemas/eval-dataset.schema.json +79 -0
- package/schemas/execution-capability-profile.schema.json +46 -0
- package/schemas/execution-policy.schema.json +114 -0
- package/schemas/improvement-proposal.schema.json +65 -0
- package/schemas/mcp-tool-output.schema.json +61 -0
- package/schemas/platform-capabilities.schema.json +83 -0
- package/schemas/project-config.schema.json +227 -0
- package/schemas/project-demo.schema.json +60 -0
- package/schemas/provider-behavior-matrix.schema.json +91 -0
- package/schemas/scope.schema.json +197 -0
- package/schemas/specialist-trace.schema.json +107 -0
- package/schemas/team.schema.json +99 -0
- package/schemas/unified-registry.schema.json +548 -0
- package/scripts/sync-specialists.mjs +52 -25
- package/specialists/org/specialists/cx-researcher.json +1 -0
- package/specialists/prompts/cx-researcher.md +9 -8
- package/vendor/pandoc-ext/README.md +3 -0
- package/vendor/pandoc-ext/diagram.lua +687 -0
|
@@ -37,12 +37,6 @@ function isUnwritableEnvValue(value) {
|
|
|
37
37
|
return typeof value !== "string" || value.includes("__") || value.startsWith("op://");
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function stripUnresolvedValues(input) {
|
|
41
|
-
return Object.fromEntries(
|
|
42
|
-
Object.entries(input).filter(([, value]) => !isUnwritableEnvValue(value)),
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
40
|
function resolveArgs(args, resolvedValues) {
|
|
47
41
|
return (args ?? []).map((arg) =>
|
|
48
42
|
typeof arg === "string" ? resolveTemplateString(arg, resolvedValues) : arg,
|
|
@@ -61,16 +55,31 @@ const SECRET_REF = {
|
|
|
61
55
|
opencode: (name) => `{env:${name}}`,
|
|
62
56
|
};
|
|
63
57
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// (config.env -> loadConstructEnv -> process.env)
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
58
|
+
// A stdio MCP env value that is a whole-value secret template (`__NAME__` as the
|
|
59
|
+
// entire string) is a credential carrier: emit the host's env-reference form so the
|
|
60
|
+
// live value never lands on disk, exactly as the remote `headers` path does. Claude
|
|
61
|
+
// Code (`${VAR}`), VS Code (`${env:VAR}`), and OpenCode (`{env:VAR}`) each expand
|
|
62
|
+
// their reference syntax inside a stdio env block, and the child still inherits the
|
|
63
|
+
// resolved env from the parent (config.env -> loadConstructEnv -> process.env) as a
|
|
64
|
+
// backstop. A composed value (a URL with an embedded port template, a plain literal)
|
|
65
|
+
// is not a secret and is materialized so the child receives the concrete string;
|
|
66
|
+
// op:// references and unresolved embedded templates are stripped, never persisted.
|
|
67
|
+
|
|
68
|
+
const WHOLE_SECRET_TEMPLATE = /^__([A-Z0-9_]+)__$/;
|
|
69
|
+
|
|
70
|
+
function buildLocalEnvironment(mcpDef, resolvedValues, secretRef) {
|
|
71
|
+
const source = mcpDef.env ?? {};
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, raw] of Object.entries(source)) {
|
|
74
|
+
const whole = typeof raw === "string" ? raw.match(WHOLE_SECRET_TEMPLATE) : null;
|
|
75
|
+
if (whole) {
|
|
76
|
+
out[key] = secretRef(whole[1]);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const materialized = typeof raw === "string" ? resolveTemplateString(raw, resolvedValues) : raw;
|
|
80
|
+
if (!isUnwritableEnvValue(materialized)) out[key] = materialized;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
function buildRemoteHeaders(mcpDef, secretRef) {
|
|
@@ -106,7 +115,7 @@ export function buildClaudeMcpEntry(id, mcpDef, resolvedValues = {}, { host = "c
|
|
|
106
115
|
};
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
const env = buildLocalEnvironment(mcpDef, values);
|
|
118
|
+
const env = buildLocalEnvironment(mcpDef, values, secretRef);
|
|
110
119
|
return {
|
|
111
120
|
command: mcpDef.command,
|
|
112
121
|
args: resolveArgs(mcpDef.args, values),
|
|
@@ -137,7 +146,7 @@ export function buildOpenCodeMcpEntry(id, mcpDef, resolvedValues = {}, { auth =
|
|
|
137
146
|
};
|
|
138
147
|
}
|
|
139
148
|
|
|
140
|
-
const environment = buildLocalEnvironment(mcpDef, runtimeValues);
|
|
149
|
+
const environment = buildLocalEnvironment(mcpDef, runtimeValues, SECRET_REF.opencode);
|
|
141
150
|
return {
|
|
142
151
|
id: openCodeId,
|
|
143
152
|
entry: {
|
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { runOracleDaemon } from './index.mjs';
|
|
9
9
|
import { defaultRootDir } from './actions.mjs';
|
|
10
|
+
import { enableSecretAuditTrail } from '../providers/secret-audit-wiring.mjs';
|
|
10
11
|
|
|
11
12
|
const rootDir = process.env.CONSTRUCT_ORACLE_ROOT || defaultRootDir();
|
|
12
13
|
const projectDir = process.env.CONSTRUCT_ORACLE_PROJECT || process.cwd();
|
|
13
14
|
|
|
15
|
+
// A long-lived daemon in its own process: wire the audit sink at the entry so any
|
|
16
|
+
// credential resolution the tick performs is recorded on the shared trail rather than
|
|
17
|
+
// escaping it, matching the CLI entrypoint's own wiring.
|
|
18
|
+
enableSecretAuditTrail();
|
|
19
|
+
|
|
14
20
|
await runOracleDaemon({ rootDir, projectDir });
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { writeFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
33
35
|
|
|
34
36
|
import { routeRequest } from '../orchestration-policy.mjs';
|
|
35
37
|
import { resolveExecution } from '../embedded-contract/execution.mjs';
|
|
@@ -39,6 +41,7 @@ import { CHAIN_OF_THOUGHT_MODES } from '../config/schema.mjs';
|
|
|
39
41
|
import { newTraceId, newSpanId, emitTraceEvent } from '../worker/trace.mjs';
|
|
40
42
|
import { resolveRunStore } from './store.mjs';
|
|
41
43
|
import { runTaskViaProvider, INLINE, PROVIDER, WORKER_BACKEND_SET } from './worker.mjs';
|
|
44
|
+
import { roleHoldsWebCapability } from './web-capability.mjs';
|
|
42
45
|
import { emitRunEvent, isCancelRequested, clearCancel } from './events.mjs';
|
|
43
46
|
|
|
44
47
|
export const WORKER_BACKENDS = WORKER_BACKEND_SET;
|
|
@@ -213,6 +216,9 @@ function prepareTaskInline(task) {
|
|
|
213
216
|
task.executor = 'inline:prepared';
|
|
214
217
|
task.status = 'prepared';
|
|
215
218
|
task.finishedAt = new Date().toISOString();
|
|
219
|
+
// A prepared web-capable task reached no web: mark it so a host never infers live
|
|
220
|
+
// web access from a task that only planned (ADR-0050).
|
|
221
|
+
if (roleHoldsWebCapability(task.role)) task.webCapability = 'prepare-only';
|
|
216
222
|
}
|
|
217
223
|
|
|
218
224
|
// The provider backend executes one task against the configured model. A failed
|
|
@@ -232,10 +238,19 @@ async function executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought)
|
|
|
232
238
|
task.status = 'done';
|
|
233
239
|
task.finishedAt = new Date().toISOString();
|
|
234
240
|
|
|
241
|
+
// Web-capable execution records which grant mode reached (or did not reach) the web
|
|
242
|
+
// and the F08-governed evidence gathered, so a host never has to infer it (ADR-0050).
|
|
243
|
+
if (result.webCapability) {
|
|
244
|
+
task.webCapability = result.webCapability;
|
|
245
|
+
task.webEvidence = result.webEvidence || [];
|
|
246
|
+
task.webCalls = result.webCalls || 0;
|
|
247
|
+
task.webSearchRequests = result.webSearchRequests || 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
235
250
|
// `surface` attaches reasoning to the task so every display surface renders
|
|
236
251
|
// it; `telemetry_only` keeps it off the task (recorded only in the trace).
|
|
237
252
|
if (chainOfThought === 'surface' && result.reasoning) task.reasoning = result.reasoning;
|
|
238
|
-
return { ok: true, reasoning: result.reasoning || '' };
|
|
253
|
+
return { ok: true, reasoning: result.reasoning || '', webCapability: result.webCapability || null };
|
|
239
254
|
} catch (err) {
|
|
240
255
|
task.executor = `provider:error`;
|
|
241
256
|
task.error = { code: err.code || 'PROVIDER_EXECUTION_FAILED', message: err.message };
|
|
@@ -264,53 +279,77 @@ export async function executeRun(cwd, runId, { env = process.env, workerBackend
|
|
|
264
279
|
throw err;
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
taskReasoning =
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
const runFilePath = join(cwd, '.cx', 'runtime', 'orchestration', 'runs', `${runId}.json`);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
|
|
286
|
+
const chainOfThought = resolveChainOfThought({ run, config });
|
|
287
|
+
run.workerBackend = backend;
|
|
288
|
+
run.chainOfThought = chainOfThought;
|
|
289
|
+
run.status = 'running';
|
|
290
|
+
run.updatedAt = new Date().toISOString();
|
|
291
|
+
await store.saveRun(run);
|
|
292
|
+
emitRunEvent(runId, { type: 'running', status: 'running', workerBackend: backend });
|
|
293
|
+
|
|
294
|
+
let anyFailed = false;
|
|
295
|
+
let cancelled = false;
|
|
296
|
+
let webUnavailable = false;
|
|
297
|
+
for (const task of run.tasks) {
|
|
298
|
+
if (isCancelRequested(runId)) { cancelled = true; break; }
|
|
299
|
+
task.status = 'running';
|
|
300
|
+
task.startedAt = new Date().toISOString();
|
|
301
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
|
|
302
|
+
emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: 'running' });
|
|
303
|
+
|
|
304
|
+
let taskReasoning = '';
|
|
305
|
+
if (backend === PROVIDER) {
|
|
306
|
+
const res = await executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought);
|
|
307
|
+
if (!res.ok) anyFailed = true;
|
|
308
|
+
if (res.webCapability === 'unavailable') webUnavailable = true;
|
|
309
|
+
taskReasoning = res.reasoning;
|
|
310
|
+
} else {
|
|
311
|
+
prepareTaskInline(task);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// telemetry_only records reasoning to the trace without ever surfacing it to
|
|
315
|
+
// a display; surface keeps it off the trace (it rides on task.reasoning).
|
|
316
|
+
const completedMeta = { runId, status: task.status };
|
|
317
|
+
if (chainOfThought === 'telemetry_only' && taskReasoning) {
|
|
318
|
+
completedMeta.reasoning = taskReasoning;
|
|
319
|
+
completedMeta.reasoningChars = taskReasoning.length;
|
|
320
|
+
}
|
|
321
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: completedMeta });
|
|
322
|
+
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 } : {}) });
|
|
323
|
+
run.updatedAt = new Date().toISOString();
|
|
324
|
+
await store.saveRun(run);
|
|
292
325
|
}
|
|
293
326
|
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
327
|
+
run.status = cancelled ? 'cancelled' : (anyFailed ? 'completed-with-failures' : 'completed');
|
|
328
|
+
// A web-capable task that reached no web path degrades the run so a host surfaces the
|
|
329
|
+
// capability gap rather than treating an insufficient-evidence answer as complete.
|
|
330
|
+
if (webUnavailable) {
|
|
331
|
+
run.degraded = true;
|
|
332
|
+
run.degradationReason = 'capability-unavailable';
|
|
300
333
|
}
|
|
301
|
-
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.completed', role: task.role, taskId: task.id, metadata: completedMeta });
|
|
302
|
-
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 } : {}) });
|
|
303
334
|
run.updatedAt = new Date().toISOString();
|
|
304
335
|
await store.saveRun(run);
|
|
336
|
+
clearCancel(runId);
|
|
337
|
+
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
|
|
338
|
+
emitRunEvent(runId, { type: 'completed', status: run.status });
|
|
339
|
+
return run;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
run.status = 'error';
|
|
342
|
+
run.error = { code: err.code || 'RUN_EXECUTE_FAILED', message: err.message };
|
|
343
|
+
run.updatedAt = new Date().toISOString();
|
|
344
|
+
// Direct (non-atomic) write to the existing run file: the file was created by
|
|
345
|
+
// planRun so the inode exists; overwriting an existing file succeeds even on a
|
|
346
|
+
// read-only directory (only creating new files requires write permission on the
|
|
347
|
+
// directory itself). This persists the terminal failure durably so
|
|
348
|
+
// getRun/getRuns surfaces the error status to doctor and orchestration_status.
|
|
349
|
+
try { writeFileSync(runFilePath, `${JSON.stringify(run, null, 2)}\n`); } catch { /* best-effort */ }
|
|
350
|
+
emitRunEvent(runId, { type: 'error', status: 'error', error: run.error });
|
|
351
|
+
throw err;
|
|
305
352
|
}
|
|
306
|
-
|
|
307
|
-
run.status = cancelled ? 'cancelled' : (anyFailed ? 'completed-with-failures' : 'completed');
|
|
308
|
-
run.updatedAt = new Date().toISOString();
|
|
309
|
-
await store.saveRun(run);
|
|
310
|
-
clearCancel(runId);
|
|
311
|
-
emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'lifecycle.completed', metadata: { runId, status: run.status, tasks: run.tasks.length } });
|
|
312
|
-
emitRunEvent(runId, { type: 'completed', status: run.status });
|
|
313
|
-
return run;
|
|
314
353
|
}
|
|
315
354
|
|
|
316
355
|
/**
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/web-capability.mjs — resolve a web-capable specialist's live-web grant (ADR-0050).
|
|
3
|
+
*
|
|
4
|
+
* roleHoldsWebCapability reads the specialist's own declared tools (claudeTools carrying
|
|
5
|
+
* WebSearch/WebFetch) so the web role is derived from the capability map, never a hardcoded
|
|
6
|
+
* string. resolveWebCapability returns a typed WebGrant in strict priority so the same
|
|
7
|
+
* contract governs the local worker, the remote service, and inline mode:
|
|
8
|
+
* - governed (WEB_SEARCH_URL set): the worker runs a client tool-use loop over
|
|
9
|
+
* Construct's own webSearch(), so F08 grading is preserved by construction.
|
|
10
|
+
* - provider-native (no WEB_SEARCH_URL): Anthropic web_search_20250305 or OpenRouter
|
|
11
|
+
* openrouter:web_search server tool; every citation is re-graded through
|
|
12
|
+
* governWebResults so trust:'untrusted' + Admiralty hold identically.
|
|
13
|
+
* - host-delegated (explicit opt-in): a tool-capable host executes and returns already-F08
|
|
14
|
+
* evidence, re-validated fail-closed on ingress.
|
|
15
|
+
* - unavailable (nothing resolves): a typed capability-unavailable that forces an honest
|
|
16
|
+
* refusal per rules/common/no-fabrication.md — never a fabricated citation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getSpecialist } from '../registry/loader.mjs';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_USES = 5;
|
|
22
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
23
|
+
|
|
24
|
+
// A specialist is web-execution-capable only when it explicitly declares liveWebAccess.
|
|
25
|
+
// claudeTools carries WebSearch/WebFetch on most specialists (the broad Claude-host grant),
|
|
26
|
+
// so it is NOT the signal here — the orchestrator routes live-web work to the one role that
|
|
27
|
+
// declares the capability (the researcher), keeping the worker web tool least-privilege.
|
|
28
|
+
export function roleHoldsWebCapability(role, opts = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const spec = getSpecialist(role, opts.rootDir ? { rootDir: opts.rootDir } : {});
|
|
31
|
+
return spec?.liveWebAccess === true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveWebCapability({ family, env = process.env } = {}) {
|
|
38
|
+
if (env.WEB_SEARCH_URL) {
|
|
39
|
+
return { mode: 'governed' };
|
|
40
|
+
}
|
|
41
|
+
if (family === 'anthropic') {
|
|
42
|
+
const maxUses = Number(env.CONSTRUCT_WORKER_WEB_MAX_USES) || DEFAULT_MAX_USES;
|
|
43
|
+
return { mode: 'provider-native', providerTool: 'anthropic', toolType: 'web_search_20250305', maxUses };
|
|
44
|
+
}
|
|
45
|
+
if (family === 'openrouter') {
|
|
46
|
+
const maxResults = Number(env.CONSTRUCT_WORKER_WEB_MAX_RESULTS) || DEFAULT_MAX_RESULTS;
|
|
47
|
+
return {
|
|
48
|
+
mode: 'provider-native',
|
|
49
|
+
providerTool: 'openrouter',
|
|
50
|
+
toolSpec: { type: 'openrouter:web_search', parameters: { engine: 'auto', max_results: maxResults } },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// openai / github-copilot have no native web tool wired here; delegate only when
|
|
54
|
+
// explicitly opted in, otherwise the honesty guard fires.
|
|
55
|
+
if (env.CONSTRUCT_ORCHESTRATION_WEB_DELEGATE === '1') {
|
|
56
|
+
return { mode: 'host-delegated' };
|
|
57
|
+
}
|
|
58
|
+
return { mode: 'unavailable', reason: 'capability-unavailable' };
|
|
59
|
+
}
|