@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
@@ -12,11 +12,12 @@
12
12
  * `$(op read 'op://...')` — it is resolved through the `op` CLI and cached for
13
13
  * the process lifetime so a single launch prompts at most once per reference.
14
14
  *
15
- * One divergence from loadConstructEnv is intentional and tracked separately: that
16
- * merge ranks the config files above process.env to defeat stale shell exports,
17
- * while this resolver honors a directly-passed env value first so a hermetic caller
18
- * can inject its own credentials. Unifying the process.env tier touches the live
19
- * LLM path and is deferred to a follow-up.
15
+ * The process.env tier follows one precedence ladder shared with loadConstructEnv:
16
+ * when file discovery is enabled (allowAmbient), the file tiers win over an ambient
17
+ * shell export so a stale export cannot shadow a managed config value, with the same
18
+ * carve-out that a materialized credential injected on the env beats a stored op://
19
+ * reference (preferInjectedCredential). A hermetic caller sets allowAmbient:false to
20
+ * suppress all file/rc discovery and use only the directly-injected env.
20
21
  *
21
22
  * resolveSecret performs the `op read` (and may surface a structured
22
23
  * SecretResolutionError); hasSecret only checks presence and never invokes the
@@ -32,6 +33,7 @@ import os from 'node:os';
32
33
  import { spawnSync } from 'node:child_process';
33
34
  import { discoverAlternateRawForVar } from './credential-sources.mjs';
34
35
  import { configDir } from '../config/xdg.mjs';
36
+ import { preferInjectedCredential } from '../env-config.mjs';
35
37
 
36
38
  // The 1Password desktop integration prompts for biometric unlock on the first
37
39
  // read after the vault locks, and that interactive round-trip routinely takes
@@ -181,9 +183,8 @@ function readFromOpEnvCatalog(varName, { env, home }) {
181
183
  }
182
184
 
183
185
  function rawCandidate(varName, { env, cwd, home }) {
184
- const direct = env?.[varName];
185
- if (typeof direct === 'string' && direct.length > 0) return { raw: direct, source: 'env' };
186
-
186
+ // Pure file/store discovery: the ambient env tier is ranked by the callers
187
+ // (resolveSecret / hasSecret) so one precedence ladder governs env-vs-file.
187
188
  // File tier matches loadConstructEnv: the project .env (closest to the work)
188
189
  // wins over the machine-wide XDG config.env, which wins over a generic ~/.env.
189
190
  const files = [
@@ -218,18 +219,32 @@ function resolveAndAudit(varName, raw, source, opts) {
218
219
 
219
220
  export function resolveSecret(varName, { env = process.env, cwd = process.cwd(), allowAmbient = true, opRead } = {}) {
220
221
  const direct = env?.[varName];
221
- if (typeof direct === 'string' && direct.length > 0) return resolveAndAudit(varName, direct, 'env', { opRead });
222
+ const hasDirect = typeof direct === 'string' && direct.length > 0;
223
+
224
+ // Hermetic mode: only the directly-injected env is consulted, no file/rc discovery,
225
+ // so a developer key never bleeds into an isolated run.
222
226
  if (!allowAmbient) {
227
+ if (hasDirect) return resolveAndAudit(varName, direct, 'env', { opRead });
223
228
  emitAudit({ event: 'secret.resolve', varName, source: null, isOpRef: false, ok: false });
224
229
  return null;
225
230
  }
231
+
226
232
  const home = os.homedir();
227
233
  const found = rawCandidate(varName, { env, cwd, home });
228
- if (!found) {
229
- emitAudit({ event: 'secret.resolve', varName, source: null, isOpRef: false, ok: false });
230
- return null;
234
+
235
+ // Precedence parity with loadConstructEnv: the file tiers win over an ambient shell
236
+ // export, except a materialized credential injected on the env beats a stored op://
237
+ // reference so a single `op run` auth suffices.
238
+ if (found) {
239
+ if (hasDirect && preferInjectedCredential(direct, found.raw)) {
240
+ return resolveAndAudit(varName, direct, 'env', { opRead });
241
+ }
242
+ return resolveAndAudit(varName, found.raw, found.source, { opRead });
231
243
  }
232
- return resolveAndAudit(varName, found.raw, found.source, { opRead });
244
+
245
+ if (hasDirect) return resolveAndAudit(varName, direct, 'env', { opRead });
246
+ emitAudit({ event: 'secret.resolve', varName, source: null, isOpRef: false, ok: false });
247
+ return null;
233
248
  }
234
249
 
235
250
  export function resolveFirstSecret(varNames, opts = {}) {
@@ -71,6 +71,11 @@ function buildCapabilityEdges(cap, { allScripts, pkgScriptsBody }) {
71
71
  return { cliCommands, npmScripts, workflows };
72
72
  }
73
73
 
74
+ function semanticDigest(cap) {
75
+ const { description = '', criticality = '', verification = {} } = cap;
76
+ return JSON.stringify({ description, criticality, verification });
77
+ }
78
+
74
79
  export function buildCatalogSnapshot({ rootDir = REPO_ROOT, now = new Date() } = {}) {
75
80
  const pkgPath = path.join(rootDir, 'package.json');
76
81
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
@@ -79,6 +84,7 @@ export function buildCatalogSnapshot({ rootDir = REPO_ROOT, now = new Date() } =
79
84
  const raw = loadCapabilityRegistry({ rootDir });
80
85
  const capabilities = (raw.capabilities ?? []).map((cap) => ({
81
86
  ...cap,
87
+ _semanticDigest: semanticDigest(cap),
82
88
  edges: buildCapabilityEdges(cap, { allScripts, pkgScriptsBody }),
83
89
  }));
84
90
 
@@ -18,6 +18,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
18
  const ROOT_DIR = path.resolve(__dirname, '..', '..');
19
19
 
20
20
  let _registry = null;
21
+ let _rootDir = null;
21
22
  let _orgMtime = null;
22
23
  let _legacyOverlayMtime = null;
23
24
 
@@ -62,7 +63,10 @@ export function loadRegistry(opts = {}) {
62
63
  legacyOverlay = null;
63
64
  }
64
65
 
65
- if (_registry && _orgMtime === orgMtime && _legacyOverlayMtime === legacyOverlayMtime) {
66
+ // The cache key must include rootDir: two roots can share an orgMtime/overlay mtime
67
+ // (tmpdir tests, coincident timestamps), and a cache keyed only on mtimes bleeds one
68
+ // root's registry into another — a specialist can then surface without its promptFile.
69
+ if (_registry && _rootDir === rootDir && _orgMtime === orgMtime && _legacyOverlayMtime === legacyOverlayMtime) {
66
70
  return _registry;
67
71
  }
68
72
 
@@ -79,6 +83,7 @@ export function loadRegistry(opts = {}) {
79
83
  }
80
84
 
81
85
  _registry = registry;
86
+ _rootDir = rootDir;
82
87
  _orgMtime = orgMtime;
83
88
  _legacyOverlayMtime = legacyOverlayMtime;
84
89
 
@@ -127,6 +132,7 @@ export function listSpecialists(teamId, opts = {}) {
127
132
 
128
133
  export function clearCache() {
129
134
  _registry = null;
135
+ _rootDir = null;
130
136
  _orgMtime = null;
131
137
  _legacyOverlayMtime = null;
132
138
  }
@@ -86,7 +86,7 @@ export function validateCapabilityRegistry({ rootDir = REPO_ROOT, now = Date.now
86
86
 
87
87
  const hasTest = ver.functional || ver.hostEmulation || ver.untestableRationale;
88
88
  if (!hasTest && (cap.criticality === 'P0' || cap.criticality === 'P1')) {
89
- warnings.push(`${cap.id}: P0/P1 capability has no functional test or untestableRationale`);
89
+ errors.push(`${cap.id}: P0/P1 capability has no functional test or untestableRationale`);
90
90
  }
91
91
 
92
92
  const surfaces = cap.surfaces ?? {};
@@ -105,10 +105,10 @@ export function validateCapabilityRegistry({ rootDir = REPO_ROOT, now = Date.now
105
105
  if (cap.lastValidated) {
106
106
  const age = now - Date.parse(cap.lastValidated);
107
107
  if (Number.isFinite(age) && age > STALE_MS) {
108
- warnings.push(`${cap.id}: lastValidated older than 90 days (${cap.lastValidated})`);
108
+ errors.push(`${cap.id}: lastValidated older than 90 days (${cap.lastValidated})`);
109
109
  }
110
110
  } else if (cap.criticality === 'P0') {
111
- warnings.push(`${cap.id}: P0 capability never validated (lastValidated null)`);
111
+ errors.push(`${cap.id}: P0 capability never validated (lastValidated null)`);
112
112
  }
113
113
 
114
114
  for (const dep of cap.dependencies?.skills ?? []) {
package/lib/sandbox.mjs CHANGED
@@ -55,7 +55,7 @@ export function createSandbox({ profile = null, seedContext = null } = {}) {
55
55
  fs.writeFileSync(path.join(root, '.cx', 'context.md'), ctx);
56
56
 
57
57
  const cfg = { version: 1, alias: 'Construct', deployment: { mode: 'solo' } };
58
- if (profile) cfg.profile = profile;
58
+ if (profile) cfg.scope = profile;
59
59
  fs.writeFileSync(path.join(root, 'construct.config.json'), JSON.stringify(cfg, null, 2) + '\n');
60
60
 
61
61
  spawnSync('git', ['init', '--quiet'], { cwd: root, stdio: 'ignore' });
@@ -391,7 +391,16 @@ export async function startServices({
391
391
  results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'reused' });
392
392
  } else {
393
393
  const cm = wrapServiceSpawn('cm', ['serve', '--port', String(ports.memory)], homeDir, { env: liveEnv });
394
- spawnDetachedFn(cm.command, cm.args, homeDir, 'cm.log');
394
+ const cmSpawn = spawnDetachedFn(cm.command, cm.args, homeDir, 'cm.log');
395
+ try {
396
+ if (cmSpawn?.child?.pid) {
397
+ writeJson(portOwnershipPath(ports.memory, homeDir), {
398
+ pid: cmSpawn.child.pid, command: `${cm.command} ${cm.args.join(' ')}`,
399
+ cwd: process.cwd(), marker: 'construct', constructManaged: true,
400
+ startedAt: new Date().toISOString(),
401
+ });
402
+ }
403
+ } catch { /* ownership record is advisory */ }
395
404
  results.push({ name: 'Memory (cm)', url: `http://127.0.0.1:${ports.memory}`, status: 'started' });
396
405
  }
397
406
  } else if (wants('memory')) {
@@ -404,7 +413,16 @@ export async function startServices({
404
413
  results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'reused' });
405
414
  } else {
406
415
  const opencode = wrapServiceSpawn('opencode', ['serve', '--port', String(ports.bridge)], homeDir, { env: liveEnv });
407
- spawnDetachedFn(opencode.command, opencode.args, homeDir, 'opencode.log');
416
+ const ocSpawn = spawnDetachedFn(opencode.command, opencode.args, homeDir, 'opencode.log');
417
+ try {
418
+ if (ocSpawn?.child?.pid) {
419
+ writeJson(portOwnershipPath(ports.bridge, homeDir), {
420
+ pid: ocSpawn.child.pid, command: `${opencode.command} ${opencode.args.join(' ')}`,
421
+ cwd: process.cwd(), marker: 'construct', constructManaged: true,
422
+ startedAt: new Date().toISOString(),
423
+ });
424
+ }
425
+ } catch { /* ownership record is advisory */ }
408
426
  results.push({ name: 'OpenCode', url: `http://127.0.0.1:${ports.bridge}`, status: 'started' });
409
427
  }
410
428
  }
@@ -420,7 +438,16 @@ export async function startServices({
420
438
  const proxyPath = path.join(INSTALL_ROOT, 'lib', 'bridges', 'copilot-proxy.mjs');
421
439
  if (fs.existsSync(proxyPath)) {
422
440
  const copilot = wrapServiceSpawn('node', [proxyPath, '--port', String(ports.copilotBridge)], homeDir, { env: liveEnv });
423
- spawnDetachedFn(copilot.command, copilot.args, homeDir, 'copilot-bridge.log');
441
+ const copilotSpawn = spawnDetachedFn(copilot.command, copilot.args, homeDir, 'copilot-bridge.log');
442
+ try {
443
+ if (copilotSpawn?.child?.pid) {
444
+ writeJson(portOwnershipPath(ports.copilotBridge, homeDir), {
445
+ pid: copilotSpawn.child.pid, command: `${copilot.command} ${copilot.args.join(' ')}`,
446
+ cwd: process.cwd(), marker: 'construct', constructManaged: true,
447
+ startedAt: new Date().toISOString(),
448
+ });
449
+ }
450
+ } catch { /* ownership record is advisory */ }
424
451
  results.push({ name: 'Copilot Bridge', url: `http://127.0.0.1:${ports.copilotBridge}`, status: 'started' });
425
452
  } else {
426
453
  results.push({ name: 'Copilot Bridge', status: 'unavailable', note: 'copilot-proxy binary missing in lib/bridges/' });
@@ -476,16 +503,39 @@ async function emitServiceFailures(results) {
476
503
  } catch { /* best effort */ }
477
504
  }
478
505
 
479
- function killPortOwners(port, spawnSyncFn = spawnSync) {
506
+ // Ownership record path for a port. startServices writes one when it spawns a service;
507
+ // killPortOwners reads it before sending SIGTERM so a foreign process that happens to
508
+ // occupy a configured port is never signalled.
509
+
510
+ function portOwnershipPath(port, homeDir) {
511
+ return path.join(runtimeStateDir(homeDir), `port-${port}.json`);
512
+ }
513
+
514
+ // Predicate: does a process record represent a Construct-owned service? A record is
515
+ // owned when it carries a Construct env/marker set at spawn time. A foreign process or
516
+ // a record from before the ownership contract was introduced is not owned.
517
+
518
+ export function isConstructOwnedPort(processRecord, { port } = {}) {
519
+ if (!processRecord || typeof processRecord !== 'object') return false;
520
+ return Boolean(processRecord.marker === 'construct' || processRecord.constructManaged === true);
521
+ }
522
+
523
+ function killPortOwners(port, spawnSyncFn = spawnSync, homeDir = os.homedir()) {
480
524
  if (!port || !Number.isInteger(port) || port <= 0) return false;
481
525
  try {
482
526
  const result = spawnSyncFn('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
483
527
  const pids = (result.stdout || '').trim().split(/\s+/).filter(Boolean).map(Number).filter((n) => n > 0);
484
528
  if (pids.length === 0) return false;
529
+
530
+ // Only signal a PID if we have a durable Construct ownership record for this port
531
+ // AND it names this exact PID. A foreign owner gets reported, not killed.
532
+ const ownerRecord = readJson(portOwnershipPath(port, homeDir));
533
+ let killed = false;
485
534
  for (const pid of pids) {
486
- try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
535
+ if (!ownerRecord || ownerRecord.pid !== pid || !isConstructOwnedPort(ownerRecord)) continue;
536
+ try { process.kill(pid, 'SIGTERM'); killed = true; } catch { /* already gone */ }
487
537
  }
488
- return true;
538
+ return killed;
489
539
  } catch {
490
540
  return false;
491
541
  }
@@ -507,15 +557,15 @@ export async function stopServices({
507
557
  const envPath = getUserEnvPath(homeDir);
508
558
  const envValues = parseEnvFile(envPath);
509
559
  const memoryPort = Number(envValues.MEMORY_PORT) || derivedMemoryPort();
510
- const cmKilled = killPortOwners(memoryPort, spawnSyncFn);
560
+ const cmKilled = killPortOwners(memoryPort, spawnSyncFn, homeDir);
511
561
  results.push({ name: 'Memory (cm)', status: cmKilled ? 'stopped' : 'not-running' });
512
562
 
513
563
  const bridgePort = Number(envValues.BRIDGE_PORT) || 5173;
514
- const openCodeKilled = killPortOwners(bridgePort, spawnSyncFn);
564
+ const openCodeKilled = killPortOwners(bridgePort, spawnSyncFn, homeDir);
515
565
  results.push({ name: 'OpenCode', status: openCodeKilled ? 'stopped' : 'not-running' });
516
566
 
517
567
  const copilotBridgePort = Number(envValues.COPILOT_BRIDGE_PORT) || 5174;
518
- const copilotBridgeKilled = killPortOwners(copilotBridgePort, spawnSyncFn);
568
+ const copilotBridgeKilled = killPortOwners(copilotBridgePort, spawnSyncFn, homeDir);
519
569
  results.push({ name: 'Copilot Bridge', status: copilotBridgeKilled ? 'stopped' : 'not-running' });
520
570
 
521
571
  const stopped = results.filter((r) => r.status === 'stopped' || r.status === 'cleaned').map((r) => r.name);
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { resolveWithinRoot } from '../path-policy.mjs';
6
7
 
7
8
  export function inferProjectName(cwd) {
8
9
  return path.basename(cwd) || 'construct';
@@ -54,8 +55,12 @@ export async function deleteIngestedArtifacts(rootDir, { files = [], confirm = f
54
55
  let deletedCount = 0;
55
56
  const internalDir = path.join(rootDir, '.cx', 'knowledge', 'internal');
56
57
 
57
- for (const f of files) {
58
- const p = path.join(internalDir, f);
58
+ // Resolve every model-supplied entry against the ingested root first; an escaping
59
+ // path throws before any deletion, so a ../ traversal can neither delete an out-of-root
60
+ // file nor partially delete the set.
61
+
62
+ const targets = files.map((f) => resolveWithinRoot(internalDir, f));
63
+ for (const p of targets) {
59
64
  if (fs.existsSync(p)) {
60
65
  fs.rmSync(p);
61
66
  deletedCount++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -26,6 +26,10 @@
26
26
  "platforms/**",
27
27
  "templates/**",
28
28
  "templates/distribution/**",
29
+ "schemas/**",
30
+ "registry/**",
31
+ "config/**",
32
+ "vendor/**",
29
33
  "scripts/sync-specialists.mjs",
30
34
  "README.md",
31
35
  ".env.example"
@@ -120,6 +124,7 @@
120
124
  "@eslint/js": "^9.39.4",
121
125
  "c8": "^11.0.0",
122
126
  "esbuild": "^0.28.1",
127
+ "postject": "1.0.0-alpha.6",
123
128
  "eslint": "^10.4.1",
124
129
  "globals": "^17.6.0",
125
130
  "ink-testing-library": "^4.0.0"
@@ -0,0 +1,117 @@
1
+ {
2
+ "$schema": "https://construct.dev/schemas/agent-manifest.v1.json",
3
+ "version": 1,
4
+ "kind": "agent-execution-manifest",
5
+ "description": "How an agent should reach Construct: MCP-first tool surface, with OpenCode as the primary human conversation surface. Source-of-truth values (core tool set, long-tail dispatcher) are verified against lib/mcp/server.mjs by tests/agent-manifest.test.mjs.",
6
+ "sources": {
7
+ "coreTools": "lib/mcp/server.mjs (CORE_TOOL_NAMES)",
8
+ "longTail": "lib/mcp/server.mjs (CONSTRUCT_CALL_TOOL)",
9
+ "surfaceTiers": "lib/registry/surface-map.mjs (SURFACE_TIERS, COMMAND_SURFACE)",
10
+ "capabilities": "registry/capabilities.json",
11
+ "credentials": "lib/mcp/server.mjs (config.env injection), lib/providers/secret-resolver.mjs"
12
+ },
13
+ "toolSurface": {
14
+ "model": "core-plus-long-tail",
15
+ "rationale": "ListTools front-loads a small core; everything else is reachable through the `call` gateway or discovered by intent via `find_tool`, so the schema stays small and small models stop hallucinating tool names. The long tail is reachable, just not front-loaded.",
16
+ "core": [
17
+ {
18
+ "name": "orchestration_policy",
19
+ "use": "Classify intent and get the specialist routing policy for a request."
20
+ },
21
+ {
22
+ "name": "orchestration_run",
23
+ "use": "Dispatch the routed specialist chain for a request after classifying it."
24
+ },
25
+ {
26
+ "name": "get_skill",
27
+ "use": "Fetch a role or domain skill playbook by id."
28
+ },
29
+ {
30
+ "name": "get_template",
31
+ "use": "Read a doc template (prd, adr, runbook, ...) by name before authoring an artifact."
32
+ },
33
+ {
34
+ "name": "search_skills",
35
+ "use": "Find relevant skills by query."
36
+ },
37
+ {
38
+ "name": "knowledge_search",
39
+ "use": "Search the project knowledge base."
40
+ },
41
+ {
42
+ "name": "memory_search",
43
+ "use": "Search durable cross-session memory."
44
+ },
45
+ {
46
+ "name": "project_context",
47
+ "use": "Read the active project context and working state."
48
+ },
49
+ {
50
+ "name": "summarize_diff",
51
+ "use": "Summarize a code diff for review or handoff."
52
+ },
53
+ {
54
+ "name": "find_tool",
55
+ "use": "Find tools by intent when you don't know the exact name; returns ranked tools with schemas."
56
+ },
57
+ {
58
+ "name": "author_artifact",
59
+ "use": "Materialize a typed artifact you drafted (PRD/ADR/RFC/…) and run the release gate."
60
+ },
61
+ {
62
+ "name": "document_export",
63
+ "use": "Convert a markdown file to PDF/DOCX/HTML/deck and write it to a path."
64
+ },
65
+ {
66
+ "name": "publish_run",
67
+ "use": "Run the publish pipeline to export an artifact (figures and demos optional)."
68
+ },
69
+ {
70
+ "name": "artifact_workflow",
71
+ "use": "Plan a manifest-backed artifact workflow; validate or export locally."
72
+ },
73
+ {
74
+ "name": "workflow_invoke",
75
+ "use": "Invoke an embedded Construct workflow (prd-draft, architecture-review, …)."
76
+ },
77
+ {
78
+ "name": "triage_recommend",
79
+ "use": "Recommend intake triage — type, owner, and routing — for a new signal."
80
+ },
81
+ {
82
+ "name": "orchestration_readiness",
83
+ "use": "Verify that the active MCP session has Construct orchestration tools attached; returns a typed failure reason and next step."
84
+ }
85
+ ],
86
+ "longTail": {
87
+ "tool": "call",
88
+ "use": "Invoke any non-core Construct tool by name: put the tool name in `tool` and its arguments in `args`. Call find_tool to discover the right tool by intent; the catalog of valid names is the `tool` enum.",
89
+ "rationale": "One dispatcher reuses the same handlers, so collapsing the surface costs no capability — every tool stays reachable by name. The manifest deliberately does not enumerate the full tool list; the live catalog is the `call` enum in lib/mcp/server.mjs, and find_tool ranks it by intent. The former gateway name `construct_call` is recovered tolerantly, so a stale name still resolves."
90
+ }
91
+ },
92
+ "humanEntry": {
93
+ "command": "opencode",
94
+ "subcommand": null,
95
+ "use": "A human uses OpenCode as the primary conversation surface after `construct sync` installs the Construct front-door agent, MCP wiring, and runtime plugin. The `construct` CLI remains setup/admin/headless infrastructure.",
96
+ "rationale": "Construct no longer ships a local conversation UI; OpenCode is primary and other public hosts use generated adapters or MCP.",
97
+ "surface": "opencode"
98
+ },
99
+ "credentials": {
100
+ "policy": "mcp-mediated",
101
+ "guidance": "Agents never resolve secrets themselves. The Construct MCP server applies config.env to its process environment at startup, so provider credentials are present for every tool call. The shared secret resolver walks the trusted sources (process env, config.env, ~/.env, project .env, shell rc) and resolves any 1Password reference internally.",
102
+ "doNot": "Do not instruct or run manual `op read` / `op://` lookups, and do not ask the user to paste keys. MCP tool outputs expose only env-key names and a configured boolean, never credential values.",
103
+ "source": "lib/mcp/server.mjs config.env injection; lib/providers/secret-resolver.mjs; lib/providers/credential-bootstrap.mjs"
104
+ },
105
+ "mcpGaps": {
106
+ "note": "True gaps only: capabilities that declare MCP unsupported. Flag variants of an MCP-backed capability and retired commands are not gaps.",
107
+ "gaps": [
108
+ {
109
+ "id": "oracle.meta-review",
110
+ "name": "Oracle Meta-Review",
111
+ "mcp": "unsupported",
112
+ "cliFallback": "construct oracle review",
113
+ "reason": "Declares surfaces.mcp.supported=false in registry/capabilities.json; reach it via the CLI --json twin."
114
+ }
115
+ ]
116
+ }
117
+ }