@geraldmaron/construct 1.0.7 → 1.0.8

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 (147) hide show
  1. package/README.md +20 -17
  2. package/bin/construct +1199 -69
  3. package/commands/work/optimize-prompts.md +1 -1
  4. package/db/schema/007_tags.sql +30 -0
  5. package/db/schema/008_skill_usage.sql +24 -0
  6. package/db/schema/009_scheduler.sql +14 -0
  7. package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
  8. package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
  9. package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
  10. package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
  11. package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
  12. package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
  13. package/examples/seed-observations/decisions.md +1 -1
  14. package/lib/audit-skills.mjs +3 -3
  15. package/lib/auto-docs.mjs +2 -2
  16. package/lib/cli-commands.mjs +276 -122
  17. package/lib/comment-lint.mjs +5 -1
  18. package/lib/completions.mjs +1 -1
  19. package/lib/config/schema.mjs +15 -1
  20. package/lib/contracts/validate.mjs +65 -17
  21. package/lib/distill.mjs +22 -4
  22. package/lib/doc-stamp.mjs +48 -0
  23. package/lib/doctor/watchers/consistency.mjs +62 -12
  24. package/lib/document-extract.mjs +221 -28
  25. package/lib/document-ingest.mjs +2 -0
  26. package/lib/embed/inbox.mjs +48 -3
  27. package/lib/embed/role-framing.mjs +3 -3
  28. package/lib/env-config.mjs +9 -3
  29. package/lib/extractors/calendar.mjs +173 -0
  30. package/lib/extractors/shared/drop-info.mjs +22 -0
  31. package/lib/extractors/transcript.mjs +0 -0
  32. package/lib/gates-audit.mjs +8 -2
  33. package/lib/git-hooks/prepare-commit-msg +1 -1
  34. package/lib/headhunt.mjs +2 -2
  35. package/lib/hooks/agent-tracker.mjs +6 -11
  36. package/lib/hooks/guard-bash.mjs +35 -14
  37. package/lib/hooks/mcp-audit.mjs +12 -0
  38. package/lib/hooks/pre-compact.mjs +92 -5
  39. package/lib/hooks/pre-push-gate.mjs +41 -8
  40. package/lib/hooks/registry-sync.mjs +2 -2
  41. package/lib/hooks/stop-notify.mjs +7 -6
  42. package/lib/host-capabilities.mjs +10 -1
  43. package/lib/hygiene/scan.mjs +141 -0
  44. package/lib/init-unified.mjs +18 -0
  45. package/lib/install/stage-project.mjs +4 -4
  46. package/lib/intake/classify.mjs +222 -55
  47. package/lib/intake/filesystem-queue.mjs +25 -5
  48. package/lib/intake/postgres-queue.mjs +45 -3
  49. package/lib/intake/prepare.mjs +3 -0
  50. package/lib/intake/quarantine.mjs +205 -0
  51. package/lib/knowledge/postgres-search.mjs +132 -0
  52. package/lib/knowledge/search.mjs +14 -4
  53. package/lib/mcp/server.mjs +9 -3
  54. package/lib/mcp/tools/skills.mjs +32 -11
  55. package/lib/mcp/tools/workflow.mjs +1 -1
  56. package/lib/migrations/index.mjs +1 -1
  57. package/lib/model-registry.mjs +1 -1
  58. package/lib/opencode-runtime-plugin.mjs +1 -1
  59. package/lib/orchestration-policy.mjs +4 -4
  60. package/lib/overrides/resolver.mjs +3 -3
  61. package/lib/parity.mjs +4 -4
  62. package/lib/policy/engine.mjs +2 -2
  63. package/lib/profiles/lifecycle.mjs +1 -1
  64. package/lib/prompt-metadata.mjs +4 -4
  65. package/lib/providers/circuit-breaker.mjs +14 -0
  66. package/lib/providers/contract.mjs +67 -3
  67. package/lib/providers/creds.mjs +219 -0
  68. package/lib/providers/scaffold.mjs +107 -0
  69. package/lib/role-preload.mjs +5 -0
  70. package/lib/roles/catalog.mjs +1 -1
  71. package/lib/roles/manifest.mjs +2 -2
  72. package/lib/scheduler/index.mjs +112 -0
  73. package/lib/scheduler/solo.mjs +183 -0
  74. package/lib/server/index.mjs +7 -1
  75. package/lib/server/insights.mjs +14 -12
  76. package/lib/server/langfuse-login.mjs +58 -0
  77. package/lib/server/static/assets/index-ab25c707.js +1 -1
  78. package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
  79. package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
  80. package/lib/{agents → specialists}/postconditions.mjs +3 -3
  81. package/lib/{agents → specialists}/schema.mjs +6 -6
  82. package/lib/status.mjs +13 -11
  83. package/lib/storage/backup.mjs +2 -2
  84. package/lib/tags/lifecycle.mjs +278 -0
  85. package/lib/tags/vocabulary.mjs +140 -0
  86. package/lib/telemetry/otel-tracer.mjs +184 -0
  87. package/lib/telemetry/skill-calls.mjs +73 -12
  88. package/lib/uninstall/uninstall.mjs +1 -1
  89. package/lib/update.mjs +1 -1
  90. package/lib/validator.mjs +57 -56
  91. package/lib/workflows/instantiate.mjs +320 -0
  92. package/package.json +13 -3
  93. package/personas/construct.md +2 -2
  94. package/platforms/claude/CLAUDE.md +1 -1
  95. package/rules/common/no-fabrication.md +1 -1
  96. package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
  97. package/skills/ai/prompt-optimizer.md +3 -3
  98. package/skills/exploration/dependency-graph-reading.md +98 -0
  99. package/skills/exploration/tracer-bullet-method.md +71 -0
  100. package/skills/exploration/unknown-codebase-onboarding.md +91 -0
  101. package/skills/operating/change-management.md +91 -0
  102. package/skills/operating/incident-response.md +75 -0
  103. package/skills/operating/oncall-rotation.md +95 -0
  104. package/skills/operating/orchestration-reference.md +2 -2
  105. package/skills/strategy/competitive-landscape.md +75 -0
  106. package/skills/strategy/market-research-methods.md +87 -0
  107. package/skills/strategy/narrative-arc.md +77 -0
  108. package/skills/strategy/pricing-positioning.md +94 -0
  109. package/{agents → specialists}/contracts.schema.json +1 -1
  110. package/specialists/policy-inventory.json +160 -0
  111. package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
  112. package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
  113. package/{agents → specialists}/prompts/cx-architect.md +1 -1
  114. package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
  115. package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
  116. package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
  117. package/{agents → specialists}/prompts/cx-debugger.md +1 -1
  118. package/{agents → specialists}/prompts/cx-designer.md +1 -1
  119. package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
  120. package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
  121. package/{agents → specialists}/prompts/cx-engineer.md +2 -2
  122. package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
  123. package/{agents → specialists}/prompts/cx-explorer.md +1 -1
  124. package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
  125. package/{agents → specialists}/prompts/cx-operations.md +1 -1
  126. package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
  127. package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
  128. package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
  129. package/{agents → specialists}/prompts/cx-qa.md +1 -1
  130. package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
  131. package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
  132. package/{agents → specialists}/prompts/cx-researcher.md +1 -1
  133. package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
  134. package/{agents → specialists}/prompts/cx-security.md +1 -1
  135. package/{agents → specialists}/prompts/cx-sre.md +2 -2
  136. package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
  137. package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
  138. package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
  139. package/{agents → specialists}/registry.json +651 -423
  140. package/{agents → specialists}/role-manifests.json +8 -8
  141. package/templates/provider-scaffold/health.test.mjs +30 -0
  142. package/templates/provider-scaffold/index.mjs +48 -0
  143. package/templates/workflows/cross-team-handoff.yml +85 -0
  144. package/templates/workflows/engineering-onboarding.yml +77 -0
  145. package/templates/workflows/new-feature.yml +53 -0
  146. /package/{agents → specialists}/contracts.json +0 -0
  147. /package/{agents → specialists}/teams.json +0 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * lib/telemetry/otel-tracer.mjs — OTel API tracer factory with graceful no-op fallback.
3
+ *
4
+ * When @opentelemetry/api is installed and an SDK is configured, returns a real
5
+ * TracerProvider-backed tracer. When the SDK is absent (default solo install),
6
+ * returns a no-op tracer so call sites compile and run without change.
7
+ *
8
+ * GenAI attribute constants follow the stable OTel GenAI semantic conventions
9
+ * (gen_ai_latest_experimental opt-in, May 2026). Use GenAiAttrs.* at every
10
+ * LLM and embedding call site.
11
+ *
12
+ * W3C trace context propagation: inject() / extract() use the standard
13
+ * W3CTraceContextPropagator. MCP callers inject into params._meta;
14
+ * MCP handlers extract from params._meta to create child spans.
15
+ *
16
+ * Environment variables:
17
+ * OTEL_EXPORTER_OTLP_ENDPOINT — enables real SDK + OTLP HTTP export
18
+ * OTEL_SERVICE_NAME — overrides default 'construct'
19
+ * OTEL_SEMCONV_STABILITY_OPT_IN — 'gen_ai_latest_experimental' (default)
20
+ *
21
+ * Disable with CONSTRUCT_OTEL=off.
22
+ */
23
+
24
+ // GenAI semantic convention attribute names (stable + experimental opt-in).
25
+ export const GenAiAttrs = {
26
+ SYSTEM: 'gen_ai.system',
27
+ OPERATION_NAME: 'gen_ai.operation.name',
28
+ REQUEST_MODEL: 'gen_ai.request.model',
29
+ RESPONSE_MODEL: 'gen_ai.response.model',
30
+ USAGE_INPUT_TOKENS: 'gen_ai.usage.input_tokens',
31
+ USAGE_OUTPUT_TOKENS: 'gen_ai.usage.output_tokens',
32
+ RESPONSE_FINISH_REASONS: 'gen_ai.response.finish_reasons',
33
+ TOOL_NAME: 'gen_ai.tool.name',
34
+ TOOL_CALL_ID: 'gen_ai.tool.call.id',
35
+
36
+ // MCP-specific (SEP-414 / OTel MCP semconv)
37
+ MCP_METHOD: 'mcp.method.name',
38
+ MCP_TRANSPORT: 'mcp.transport',
39
+ };
40
+
41
+ // W3C propagation header names used in MCP params._meta.
42
+ export const TRACEPARENT_KEY = 'traceparent';
43
+ export const TRACESTATE_KEY = 'tracestate';
44
+
45
+ // Singleton holder — module-level so `getTracer` always returns the same instance.
46
+ let _tracer = null;
47
+ let _propagator = null;
48
+ let _sdkAvailable = null;
49
+
50
+ async function _tryLoadSdk() {
51
+ if (_sdkAvailable !== null) return _sdkAvailable;
52
+ try {
53
+ const api = await import('@opentelemetry/api');
54
+ _tracer = api.trace.getTracer(
55
+ process.env.OTEL_SERVICE_NAME || 'construct',
56
+ process.env.npm_package_version || '0.0.0',
57
+ );
58
+ _propagator = api.propagation;
59
+ _sdkAvailable = true;
60
+ } catch {
61
+ _sdkAvailable = false;
62
+ }
63
+ return _sdkAvailable;
64
+ }
65
+
66
+ // Synchronous no-op span — safe to call .setAttribute, .setStatus, .end, etc.
67
+ const _noopSpan = {
68
+ setAttribute: () => _noopSpan,
69
+ setAttributes: () => _noopSpan,
70
+ setStatus: () => _noopSpan,
71
+ addEvent: () => _noopSpan,
72
+ recordException: () => _noopSpan,
73
+ end: () => {},
74
+ isRecording: () => false,
75
+ spanContext: () => ({ traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 0 }),
76
+ };
77
+
78
+ const _noopTracer = {
79
+ startSpan: () => _noopSpan,
80
+ startActiveSpan: (_name, optsFnOrFn, ctxOrFn, fn) => {
81
+ const callback = fn || ctxOrFn || optsFnOrFn;
82
+ if (typeof callback === 'function') return callback(_noopSpan);
83
+ },
84
+ };
85
+
86
+ const _noopPropagator = {
87
+ inject: () => {},
88
+ extract: (_ctx) => _ctx,
89
+ fields: () => [],
90
+ };
91
+
92
+ /**
93
+ * Return the active OTel tracer (real or no-op). Call sites import this once.
94
+ * If CONSTRUCT_OTEL=off or SDK not installed, always returns the no-op tracer.
95
+ */
96
+ export async function getTracer() {
97
+ if (process.env.CONSTRUCT_OTEL === 'off') return _noopTracer;
98
+ if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return _noopTracer;
99
+ await _tryLoadSdk();
100
+ return _tracer || _noopTracer;
101
+ }
102
+
103
+ /** Synchronous version — returns no-op immediately if SDK not yet loaded. */
104
+ export function getTracerSync() {
105
+ return _tracer || _noopTracer;
106
+ }
107
+
108
+ /** Return the propagator for W3C trace context injection/extraction. */
109
+ export async function getPropagator() {
110
+ if (process.env.CONSTRUCT_OTEL === 'off') return _noopPropagator;
111
+ if (!process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return _noopPropagator;
112
+ await _tryLoadSdk();
113
+ return _propagator || _noopPropagator;
114
+ }
115
+
116
+ /**
117
+ * Inject W3C trace context into a metadata dict (used for MCP params._meta).
118
+ * Returns the same dict mutated in place.
119
+ */
120
+ export async function injectTraceContext(meta = {}) {
121
+ const propagator = await getPropagator();
122
+ propagator.inject({}, meta, { set: (carrier, key, val) => { carrier[key] = val; } });
123
+ return meta;
124
+ }
125
+
126
+ /**
127
+ * Extract W3C trace context from a metadata dict (used in MCP handlers).
128
+ * Returns an OTel Context object, or null when SDK is absent.
129
+ */
130
+ export async function extractTraceContext(meta = {}) {
131
+ if (_sdkAvailable === false) return null;
132
+ const propagator = await getPropagator();
133
+ let ctx = null;
134
+ try {
135
+ const api = await import('@opentelemetry/api');
136
+ ctx = propagator.extract(api.ROOT_CONTEXT, meta, {
137
+ get: (carrier, key) => carrier[key],
138
+ keys: (carrier) => Object.keys(carrier),
139
+ });
140
+ } catch { /* SDK not available */ }
141
+ return ctx;
142
+ }
143
+
144
+ /**
145
+ * Convenience wrapper: start a gen_ai.client span, run fn(span), end the span.
146
+ * Attributes passed at start; fn receives the span for dynamic attrs.
147
+ *
148
+ * @param {string} operationName e.g. 'chat', 'embeddings'
149
+ * @param {object} attrs Initial GenAiAttrs.*
150
+ * @param {function} fn (span) => result — may be async
151
+ * @param {object|null} [parentCtx] OTel Context from extractTraceContext (MCP handlers)
152
+ */
153
+ export async function withGenAiSpan(operationName, attrs, fn, parentCtx = null) {
154
+ const tracer = await getTracer();
155
+ const spanName = `gen_ai ${operationName}`;
156
+
157
+ const startOpts = {
158
+ kind: 1 /* SpanKind.CLIENT */,
159
+ attributes: {
160
+ [GenAiAttrs.OPERATION_NAME]: operationName,
161
+ ...attrs,
162
+ },
163
+ };
164
+ if (parentCtx) startOpts.parent = parentCtx;
165
+
166
+ let span = _noopSpan;
167
+ try {
168
+ span = tracer.startSpan ? tracer.startSpan(spanName, startOpts) : _noopSpan;
169
+ } catch { /* no-op path */ }
170
+
171
+ const t0 = Date.now();
172
+ try {
173
+ const result = await fn(span);
174
+ span.setAttribute('construct.duration_ms', Date.now() - t0);
175
+ span.setStatus({ code: 1 /* OK */ });
176
+ return result;
177
+ } catch (err) {
178
+ span.setStatus({ code: 2 /* ERROR */, message: err?.message });
179
+ span.recordException(err);
180
+ throw err;
181
+ } finally {
182
+ span.end();
183
+ }
184
+ }
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * lib/telemetry/skill-calls.mjs — measure which skill files actually get loaded.
3
3
  *
4
- * Appends one JSON line per skill load to ~/.cx/skill-calls.jsonl so a future
5
- * `construct skills:audit` can answer two questions: which skills have no
6
- * load events in a 30-day window (pruning candidates), and which skills
7
- * receive load events from many sources (hot paths). Without this signal
8
- * the 131-file skills tree grows without a pruning gate.
4
+ * Appends one JSON line per skill load to ~/.cx/skill-calls.jsonl so
5
+ * `construct skills usage/orphans/hot/correlate-quality` can answer:
6
+ * which skills have no load events in a 30-day window (pruning candidates),
7
+ * which skills are loaded from many agents (hot paths), and which skills
8
+ * correlate with high-quality sessions.
9
9
  *
10
- * Disable with `CONSTRUCT_SKILL_TELEMETRY=off` for users who don't want the
11
- * append-only log. Errors here are non-fatal telemetry must never break the
12
- * skill load it is observing.
10
+ * Extended payload (Workstream H): latencyMs, agentId, sessionId, tokensReturned
11
+ * are all optional and backward-compatible with earlier log files.
12
+ *
13
+ * Disable with `CONSTRUCT_SKILL_TELEMETRY=off`.
14
+ * Errors here are non-fatal — telemetry must never break the skill load.
13
15
  */
14
16
 
15
17
  import fs from 'node:fs';
@@ -40,6 +42,10 @@ export function logSkillCall(event, opts = {}) {
40
42
  skillId: event.skillId,
41
43
  source: event.source,
42
44
  ...(event.callerContext ? { callerContext: event.callerContext } : {}),
45
+ ...(event.latencyMs != null ? { latencyMs: event.latencyMs } : {}),
46
+ ...(event.agentId ? { agentId: event.agentId } : {}),
47
+ ...(event.sessionId ? { sessionId: event.sessionId } : {}),
48
+ ...(event.tokensReturned != null ? { tokensReturned: event.tokensReturned } : {}),
43
49
  };
44
50
 
45
51
  try {
@@ -53,26 +59,81 @@ export function logSkillCall(event, opts = {}) {
53
59
 
54
60
  /**
55
61
  * Read every event from a skill-calls log and roll it up into per-skill stats:
56
- * load count, distinct sources, most-recent timestamp.
62
+ * load count, distinct sources, most-recent timestamp, latency percentiles,
63
+ * distinct agents, distinct sessions.
57
64
  */
58
- export function summarizeSkillCalls({ logPath = DEFAULT_LOG_PATH } = {}) {
65
+ export function summarizeSkillCalls({ logPath = DEFAULT_LOG_PATH, since, agentId: filterAgent, source: filterSource } = {}) {
59
66
  if (!fs.existsSync(logPath)) return { totalEvents: 0, skills: {} };
60
67
  const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter(Boolean);
68
+ const sinceTs = since ? _parseSince(since) : null;
61
69
  const skills = {};
62
70
  for (const line of lines) {
63
71
  let entry;
64
72
  try { entry = JSON.parse(line); } catch { continue; }
65
73
  if (!entry?.skillId) continue;
66
- const slot = skills[entry.skillId] ||= { calls: 0, sources: new Set(), lastCalledAt: null };
74
+ if (sinceTs && entry.ts && entry.ts < sinceTs) continue;
75
+ if (filterAgent && entry.agentId !== filterAgent) continue;
76
+ if (filterSource && entry.source !== filterSource) continue;
77
+ const slot = skills[entry.skillId] ||= {
78
+ calls: 0,
79
+ sources: new Set(),
80
+ agents: new Set(),
81
+ sessions: new Set(),
82
+ latencies: [],
83
+ lastCalledAt: null,
84
+ };
67
85
  slot.calls += 1;
68
86
  if (entry.source) slot.sources.add(entry.source);
87
+ if (entry.agentId) slot.agents.add(entry.agentId);
88
+ if (entry.sessionId) slot.sessions.add(entry.sessionId);
89
+ if (entry.latencyMs != null) slot.latencies.push(entry.latencyMs);
69
90
  if (entry.ts && (!slot.lastCalledAt || entry.ts > slot.lastCalledAt)) {
70
91
  slot.lastCalledAt = entry.ts;
71
92
  }
72
93
  }
73
94
  const result = {};
74
95
  for (const [id, slot] of Object.entries(skills)) {
75
- result[id] = { calls: slot.calls, sources: [...slot.sources].sort(), lastCalledAt: slot.lastCalledAt };
96
+ result[id] = {
97
+ calls: slot.calls,
98
+ sources: [...slot.sources].sort(),
99
+ agents: [...slot.agents].sort(),
100
+ sessions: slot.sessions.size,
101
+ lastCalledAt: slot.lastCalledAt,
102
+ ...(slot.latencies.length ? _latencyStats(slot.latencies) : {}),
103
+ };
76
104
  }
77
105
  return { totalEvents: lines.length, skills: result };
78
106
  }
107
+
108
+ /**
109
+ * Return skills that have zero load events in the given window — pruning candidates.
110
+ */
111
+ export function findOrphanSkills({ logPath = DEFAULT_LOG_PATH, since = '30d', allSkillIds = [] } = {}) {
112
+ const { skills } = summarizeSkillCalls({ logPath, since });
113
+ const active = new Set(Object.keys(skills));
114
+ return allSkillIds.filter(id => !active.has(id));
115
+ }
116
+
117
+ /**
118
+ * Return the top-K hottest skills by call count in the window.
119
+ */
120
+ export function hotSkills({ logPath = DEFAULT_LOG_PATH, since = '30d', limit = 20 } = {}) {
121
+ const { skills } = summarizeSkillCalls({ logPath, since });
122
+ return Object.entries(skills)
123
+ .sort((a, b) => b[1].calls - a[1].calls)
124
+ .slice(0, limit)
125
+ .map(([id, stats]) => ({ skillId: id, ...stats }));
126
+ }
127
+
128
+ function _parseSince(since) {
129
+ const m = String(since).match(/^(\d+)(d|h|m)$/);
130
+ if (!m) return null;
131
+ const ms = parseInt(m[1], 10) * ({ d: 86400000, h: 3600000, m: 60000 }[m[2]]);
132
+ return new Date(Date.now() - ms).toISOString();
133
+ }
134
+
135
+ function _latencyStats(arr) {
136
+ const sorted = [...arr].sort((a, b) => a - b);
137
+ const p = (pct) => sorted[Math.floor((pct / 100) * (sorted.length - 1))];
138
+ return { p50LatencyMs: p(50), p95LatencyMs: p(95) };
139
+ }
@@ -400,7 +400,7 @@ function unmergeSettings(filePath) {
400
400
 
401
401
  function constructMcpKeys() {
402
402
  try {
403
- const registry = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'agents', 'registry.json'), 'utf8'));
403
+ const registry = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'specialists', 'registry.json'), 'utf8'));
404
404
  return Object.keys(registry.mcpServers || {});
405
405
  } catch { return []; }
406
406
  }
package/lib/update.mjs CHANGED
@@ -30,7 +30,7 @@ export function isConstructSourceRoot(dir) {
30
30
  return pkg.name === PACKAGE_NAME
31
31
  && pkg.bin?.construct === 'bin/construct'
32
32
  && fs.existsSync(path.join(dir, 'bin', 'construct'))
33
- && fs.existsSync(path.join(dir, 'scripts', 'sync-agents.mjs'))
33
+ && fs.existsSync(path.join(dir, 'scripts', 'sync-specialists.mjs'))
34
34
  && fs.existsSync(path.join(dir, 'lib', 'cli-commands.mjs'));
35
35
  }
36
36
 
package/lib/validator.mjs CHANGED
@@ -59,18 +59,18 @@ export function validateRegistry(reg, options = {}) {
59
59
  if (!reg.system) errors.push('registry: system is missing');
60
60
  if (!reg.prefix) errors.push('registry: prefix is missing');
61
61
 
62
- if (!Array.isArray(reg.agents) || reg.agents.length === 0) {
63
- errors.push('registry: agents must be a non-empty array');
62
+ if (!Array.isArray(reg.specialists) || reg.specialists.length === 0) {
63
+ errors.push('registry: specialists must be a non-empty array');
64
64
  }
65
- if (!Array.isArray(reg.personas) || reg.personas.length === 0) {
66
- errors.push('registry: personas must be a non-empty array');
65
+ if (!reg.orchestrator || typeof reg.orchestrator !== 'object') {
66
+ errors.push('registry: orchestrator must be a non-empty object');
67
67
  }
68
68
 
69
- const agents = Array.isArray(reg.agents) ? reg.agents : [];
70
- const personas = Array.isArray(reg.personas) ? reg.personas : [];
69
+ const specialists = Array.isArray(reg.specialists) ? reg.specialists : [];
70
+ const orchestrator = reg.orchestrator && typeof reg.orchestrator === 'object' ? reg.orchestrator : null;
71
71
 
72
72
  const allIds = new Set();
73
- const agentNames = new Set(agents.map((agent) => agent?.name).filter(Boolean));
73
+ const specialistNames = new Set(specialists.map((s) => s?.name).filter(Boolean));
74
74
 
75
75
  function validateIdentity(entry, kind) {
76
76
  const n = entry.name;
@@ -78,7 +78,7 @@ export function validateRegistry(reg, options = {}) {
78
78
  errors.push(`${kind}: name must be a non-empty string`);
79
79
  return '(unnamed)';
80
80
  }
81
- if (allIds.has(n)) errors.push(`${n}: duplicate id across agents/personas`);
81
+ if (allIds.has(n)) errors.push(`${n}: duplicate id across specialists/orchestrator`);
82
82
  allIds.add(n);
83
83
  if (!/^[a-z][a-z0-9-]*$/.test(n)) {
84
84
  errors.push(`${n}: id must be lowercase kebab-case`);
@@ -86,99 +86,99 @@ export function validateRegistry(reg, options = {}) {
86
86
  return n;
87
87
  }
88
88
 
89
- for (const agent of agents) {
90
- const label = validateIdentity(agent, 'agent');
89
+ for (const specialist of specialists) {
90
+ const label = validateIdentity(specialist, 'specialist');
91
91
 
92
- if (!agent.description || typeof agent.description !== 'string') {
92
+ if (!specialist.description || typeof specialist.description !== 'string') {
93
93
  errors.push(`${label}: description must be a non-empty string`);
94
- } else if (agent.description.length > DESCRIPTION_MAX_CHARS) {
95
- errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${agent.description.length})`);
94
+ } else if (specialist.description.length > DESCRIPTION_MAX_CHARS) {
95
+ errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${specialist.description.length})`);
96
96
  }
97
- if (agent.prompt !== undefined && typeof agent.prompt !== 'string') {
97
+ if (specialist.prompt !== undefined && typeof specialist.prompt !== 'string') {
98
98
  errors.push(`${label}: prompt must be a string when present`);
99
99
  }
100
- if (agent.promptFile !== undefined && typeof agent.promptFile !== 'string') {
100
+ if (specialist.promptFile !== undefined && typeof specialist.promptFile !== 'string') {
101
101
  errors.push(`${label}: promptFile must be a string when present`);
102
102
  }
103
- if (!agent.prompt && !agent.promptFile) {
103
+ if (!specialist.prompt && !specialist.promptFile) {
104
104
  errors.push(`${label}: must define prompt or promptFile`);
105
- } else if (agent.promptFile && !existsSync(join(rootDir, agent.promptFile))) {
106
- errors.push(`${label}: promptFile does not exist (${agent.promptFile})`);
105
+ } else if (specialist.promptFile && !existsSync(join(rootDir, specialist.promptFile))) {
106
+ errors.push(`${label}: promptFile does not exist (${specialist.promptFile})`);
107
107
  }
108
- const promptText = typeof agent.prompt === 'string'
109
- ? agent.prompt
110
- : loadPromptContent(rootDir, agent.promptFile);
108
+ const promptText = typeof specialist.prompt === 'string'
109
+ ? specialist.prompt
110
+ : loadPromptContent(rootDir, specialist.promptFile);
111
111
  if (promptText) {
112
- const cap = Number.isInteger(agent.wordCapOverride) && agent.wordCapOverride > 0
113
- ? agent.wordCapOverride
112
+ const cap = Number.isInteger(specialist.wordCapOverride) && specialist.wordCapOverride > 0
113
+ ? specialist.wordCapOverride
114
114
  : PROMPT_HARD_CAP_WORDS;
115
115
  const words = wordCount(promptText);
116
116
  if (words > cap) {
117
117
  errors.push(`${label}: prompt is ${words} words, exceeds cap of ${cap} (set wordCapOverride if intentional)`);
118
118
  }
119
119
  }
120
- if (!agent.model && !agent.modelTier) {
120
+ if (!specialist.model && !specialist.modelTier) {
121
121
  errors.push(`${label}: must define model or modelTier`);
122
122
  }
123
- if (agent.modelTier && !VALID_TIERS.has(agent.modelTier)) {
124
- errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${agent.modelTier}'`);
123
+ if (specialist.modelTier && !VALID_TIERS.has(specialist.modelTier)) {
124
+ errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${specialist.modelTier}'`);
125
125
  }
126
- if (agent.model && !hasProviderModelShape(agent.model)) {
126
+ if (specialist.model && !hasProviderModelShape(specialist.model)) {
127
127
  errors.push(`${label}: model must be provider/model-id format`);
128
128
  }
129
- if (agent.reasoningEffort && !VALID_REASONING_EFFORTS.has(agent.reasoningEffort)) {
129
+ if (specialist.reasoningEffort && !VALID_REASONING_EFFORTS.has(specialist.reasoningEffort)) {
130
130
  errors.push(`${label}: reasoningEffort must be low|medium|high|xhigh`);
131
131
  }
132
- if ('claudeTools' in agent && (!agent.claudeTools || typeof agent.claudeTools !== 'string')) {
132
+ if ('claudeTools' in specialist && (!specialist.claudeTools || typeof specialist.claudeTools !== 'string')) {
133
133
  errors.push(`${label}: claudeTools must be a non-empty string when present`);
134
134
  }
135
- validateStringArray(errors, agent.chain, `${label}.chain`, agentNames);
136
- validateStringArray(errors, agent.alsoInvokes, `${label}.alsoInvokes`, agentNames);
135
+ validateStringArray(errors, specialist.chain, `${label}.chain`, specialistNames);
136
+ validateStringArray(errors, specialist.alsoInvokes, `${label}.alsoInvokes`, specialistNames);
137
137
  }
138
138
 
139
- for (const persona of personas) {
140
- const label = validateIdentity(persona, 'persona');
139
+ if (orchestrator) {
140
+ const label = validateIdentity(orchestrator, 'orchestrator');
141
141
 
142
- if (!persona.description || typeof persona.description !== 'string') {
142
+ if (!orchestrator.description || typeof orchestrator.description !== 'string') {
143
143
  errors.push(`${label}: description must be a non-empty string`);
144
- } else if (persona.description.length > DESCRIPTION_MAX_CHARS) {
145
- errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${persona.description.length})`);
144
+ } else if (orchestrator.description.length > DESCRIPTION_MAX_CHARS) {
145
+ errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${orchestrator.description.length})`);
146
146
  }
147
- if (persona.displayName && persona.displayName.length > DISPLAY_NAME_MAX_CHARS) {
148
- errors.push(`${label}: displayName exceeds ${DISPLAY_NAME_MAX_CHARS} chars (got ${persona.displayName.length})`);
147
+ if (orchestrator.displayName && orchestrator.displayName.length > DISPLAY_NAME_MAX_CHARS) {
148
+ errors.push(`${label}: displayName exceeds ${DISPLAY_NAME_MAX_CHARS} chars (got ${orchestrator.displayName.length})`);
149
149
  }
150
- if (!persona.role || typeof persona.role !== 'string') {
150
+ if (!orchestrator.role || typeof orchestrator.role !== 'string') {
151
151
  errors.push(`${label}: role must be a non-empty string`);
152
152
  }
153
- if (!persona.promptFile || typeof persona.promptFile !== 'string') {
153
+ if (!orchestrator.promptFile || typeof orchestrator.promptFile !== 'string') {
154
154
  errors.push(`${label}: promptFile must be a non-empty string`);
155
- } else if (!existsSync(join(rootDir, persona.promptFile))) {
156
- errors.push(`${label}: promptFile does not exist (${persona.promptFile})`);
155
+ } else if (!existsSync(join(rootDir, orchestrator.promptFile))) {
156
+ errors.push(`${label}: promptFile does not exist (${orchestrator.promptFile})`);
157
157
  }
158
- const personaPromptText = loadPromptContent(rootDir, persona.promptFile);
159
- if (personaPromptText) {
160
- const cap = Number.isInteger(persona.wordCapOverride) && persona.wordCapOverride > 0
161
- ? persona.wordCapOverride
158
+ const oPromptText = loadPromptContent(rootDir, orchestrator.promptFile);
159
+ if (oPromptText) {
160
+ const cap = Number.isInteger(orchestrator.wordCapOverride) && orchestrator.wordCapOverride > 0
161
+ ? orchestrator.wordCapOverride
162
162
  : PROMPT_HARD_CAP_WORDS;
163
- const words = wordCount(personaPromptText);
163
+ const words = wordCount(oPromptText);
164
164
  if (words > cap) {
165
165
  errors.push(`${label}: prompt is ${words} words, exceeds cap of ${cap} (set wordCapOverride if intentional)`);
166
166
  }
167
167
  }
168
- if (!persona.model && !persona.modelTier) {
168
+ if (!orchestrator.model && !orchestrator.modelTier) {
169
169
  errors.push(`${label}: must define model or modelTier`);
170
170
  }
171
- if (persona.modelTier && !VALID_TIERS.has(persona.modelTier)) {
172
- errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${persona.modelTier}'`);
171
+ if (orchestrator.modelTier && !VALID_TIERS.has(orchestrator.modelTier)) {
172
+ errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${orchestrator.modelTier}'`);
173
173
  }
174
- if (persona.model && !hasProviderModelShape(persona.model)) {
174
+ if (orchestrator.model && !hasProviderModelShape(orchestrator.model)) {
175
175
  errors.push(`${label}: model must be provider/model-id format`);
176
176
  }
177
- if (persona.reasoningEffort && !VALID_REASONING_EFFORTS.has(persona.reasoningEffort)) {
177
+ if (orchestrator.reasoningEffort && !VALID_REASONING_EFFORTS.has(orchestrator.reasoningEffort)) {
178
178
  errors.push(`${label}: reasoningEffort must be low|medium|high|xhigh`);
179
179
  }
180
- validateStringArray(errors, persona.chain, `${label}.chain`, agentNames);
181
- validateStringArray(errors, persona.alsoInvokes, `${label}.alsoInvokes`, agentNames);
180
+ validateStringArray(errors, orchestrator.chain, `${label}.chain`, specialistNames);
181
+ validateStringArray(errors, orchestrator.alsoInvokes, `${label}.alsoInvokes`, specialistNames);
182
182
  }
183
183
 
184
184
  if (!reg.models || typeof reg.models !== 'object') {
@@ -210,14 +210,15 @@ export function validateRegistry(reg, options = {}) {
210
210
  }
211
211
  }
212
212
 
213
- const summary = `${personas.length} personas, ${agents.length} agents`;
213
+ const specialistCount = specialists.length;
214
+ const summary = `1 orchestrator, ${specialistCount} specialists`;
214
215
  return { valid: errors.length === 0, errors, summary };
215
216
  }
216
217
 
217
218
  const __dirname = dirname(fileURLToPath(import.meta.url));
218
219
 
219
220
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
220
- const reg = JSON.parse(readFileSync(join(__dirname, '..', 'agents', 'registry.json'), 'utf8'));
221
+ const reg = JSON.parse(readFileSync(join(__dirname, '..', 'specialists', 'registry.json'), 'utf8'));
221
222
  const result = validateRegistry(reg);
222
223
  if (result.valid) {
223
224
  console.log(`✓ Registry valid (${result.summary})`);