@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.
Files changed (63) hide show
  1. package/bin/construct +2 -1
  2. package/bin/construct-postinstall.mjs +27 -2
  3. package/config/tag-vocabulary.json +264 -0
  4. package/lib/config/schema.mjs +1 -1
  5. package/lib/doctor/diagnosis.mjs +20 -0
  6. package/lib/doctor/index.mjs +5 -0
  7. package/lib/embed/worker.mjs +5 -0
  8. package/lib/env-config.mjs +10 -2
  9. package/lib/export-validate.mjs +34 -2
  10. package/lib/host/readiness.mjs +109 -0
  11. package/lib/host-disposition.mjs +10 -1
  12. package/lib/install/stage-project.mjs +41 -4
  13. package/lib/intake/prepare.mjs +2 -0
  14. package/lib/mcp/destructive-approval.mjs +57 -0
  15. package/lib/mcp/server.mjs +208 -34
  16. package/lib/mcp/tool-rate-limit.mjs +47 -0
  17. package/lib/mcp/tool-safety.mjs +94 -0
  18. package/lib/mcp/tool-surface-parity.mjs +60 -0
  19. package/lib/mcp/tools/orchestration-run.mjs +45 -7
  20. package/lib/mcp/tools/project.mjs +25 -8
  21. package/lib/mcp/tools/storage.mjs +9 -1
  22. package/lib/mcp/tools/web-search-governance.mjs +96 -0
  23. package/lib/mcp/tools/web-search.mjs +5 -76
  24. package/lib/mcp-platform-config.mjs +27 -18
  25. package/lib/oracle/daemon-entry.mjs +6 -0
  26. package/lib/orchestration/runtime.mjs +81 -42
  27. package/lib/orchestration/web-capability.mjs +59 -0
  28. package/lib/orchestration/worker.mjs +263 -19
  29. package/lib/output-quality.mjs +61 -2
  30. package/lib/path-policy.mjs +56 -0
  31. package/lib/providers/secret-audit-wiring.mjs +35 -18
  32. package/lib/providers/secret-resolver.mjs +28 -13
  33. package/lib/registry/catalog.mjs +6 -0
  34. package/lib/registry/loader.mjs +7 -1
  35. package/lib/registry/validate.mjs +3 -3
  36. package/lib/sandbox.mjs +1 -1
  37. package/lib/service-manager.mjs +59 -9
  38. package/lib/storage/admin.mjs +7 -2
  39. package/package.json +6 -1
  40. package/registry/agent-manifest.json +117 -0
  41. package/registry/capabilities.json +1880 -0
  42. package/schemas/brand-voice.schema.json +24 -0
  43. package/schemas/capability-registry.schema.json +72 -0
  44. package/schemas/certification-run.schema.json +130 -0
  45. package/schemas/demo-recording.schema.json +46 -0
  46. package/schemas/eval-dataset.schema.json +79 -0
  47. package/schemas/execution-capability-profile.schema.json +46 -0
  48. package/schemas/execution-policy.schema.json +114 -0
  49. package/schemas/improvement-proposal.schema.json +65 -0
  50. package/schemas/mcp-tool-output.schema.json +61 -0
  51. package/schemas/platform-capabilities.schema.json +83 -0
  52. package/schemas/project-config.schema.json +227 -0
  53. package/schemas/project-demo.schema.json +60 -0
  54. package/schemas/provider-behavior-matrix.schema.json +91 -0
  55. package/schemas/scope.schema.json +197 -0
  56. package/schemas/specialist-trace.schema.json +107 -0
  57. package/schemas/team.schema.json +99 -0
  58. package/schemas/unified-registry.schema.json +548 -0
  59. package/scripts/sync-specialists.mjs +52 -25
  60. package/specialists/org/specialists/cx-researcher.json +1 -0
  61. package/specialists/prompts/cx-researcher.md +9 -8
  62. package/vendor/pandoc-ext/README.md +3 -0
  63. 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
- // Local/stdio MCP env is materialized as resolved values rather than host env-ref
65
- // forms. The SECRET_REF syntaxes above are verified for the remote `headers` field
66
- // only; whether Claude / VS Code / OpenCode expand a `${VAR}`-style reference inside
67
- // a stdio `env` block is unconfirmed, so the value-to-reference flip is deferred. The
68
- // secret is still kept off disk by the child inheriting resolved env from the parent
69
- // (config.env -> loadConstructEnv -> process.env); `stripUnresolvedValues` guarantees
70
- // no unresolved template or op:// reference is persisted here.
71
-
72
- function buildLocalEnvironment(mcpDef, resolvedValues) {
73
- return stripUnresolvedValues(resolveTemplateObject(mcpDef.env ?? {}, resolvedValues));
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 backend = resolveWorkerBackend({ explicit: workerBackend, run, config });
268
- const chainOfThought = resolveChainOfThought({ run, config });
269
- run.workerBackend = backend;
270
- run.chainOfThought = chainOfThought;
271
- run.status = 'running';
272
- run.updatedAt = new Date().toISOString();
273
- await store.saveRun(run);
274
- emitRunEvent(runId, { type: 'running', status: 'running', workerBackend: backend });
275
-
276
- let anyFailed = false;
277
- let cancelled = false;
278
- for (const task of run.tasks) {
279
- if (isCancelRequested(runId)) { cancelled = true; break; }
280
- task.status = 'running';
281
- task.startedAt = new Date().toISOString();
282
- emitTraceEvent({ rootDir: cwd, env, traceId: run.traceId, spanId: newSpanId(), eventType: 'worker.started', role: task.role, taskId: task.id, metadata: { runId, workerBackend: backend } });
283
- emitRunEvent(runId, { type: 'task', taskId: task.id, role: task.role, status: 'running' });
284
-
285
- let taskReasoning = '';
286
- if (backend === PROVIDER) {
287
- const res = await executeTaskViaProvider(task, run, env, fetchImpl, chainOfThought);
288
- if (!res.ok) anyFailed = true;
289
- taskReasoning = res.reasoning;
290
- } else {
291
- prepareTaskInline(task);
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
- // telemetry_only records reasoning to the trace without ever surfacing it to
295
- // a display; surface keeps it off the trace (it rides on task.reasoning).
296
- const completedMeta = { runId, status: task.status };
297
- if (chainOfThought === 'telemetry_only' && taskReasoning) {
298
- completedMeta.reasoning = taskReasoning;
299
- completedMeta.reasoningChars = taskReasoning.length;
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
+ }