@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.
- package/README.md +20 -17
- package/bin/construct +1199 -69
- package/commands/work/optimize-prompts.md +1 -1
- package/db/schema/007_tags.sql +30 -0
- package/db/schema/008_skill_usage.sql +24 -0
- package/db/schema/009_scheduler.sql +14 -0
- package/examples/internal/roles/architect/bad/clever-plan-without-contracts.md +1 -1
- package/examples/internal/roles/architect/golden/explicit-tradeoff-before-plan.md +1 -1
- package/examples/internal/roles/engineer/bad/speculative-abstraction.md +1 -1
- package/examples/internal/roles/engineer/golden/read-before-write.md +1 -1
- package/examples/internal/roles/qa/bad/coverage-theater.md +1 -1
- package/examples/internal/roles/qa/golden/regression-gate.md +1 -1
- package/examples/seed-observations/decisions.md +1 -1
- package/lib/audit-skills.mjs +3 -3
- package/lib/auto-docs.mjs +2 -2
- package/lib/cli-commands.mjs +276 -122
- package/lib/comment-lint.mjs +5 -1
- package/lib/completions.mjs +1 -1
- package/lib/config/schema.mjs +15 -1
- package/lib/contracts/validate.mjs +65 -17
- package/lib/distill.mjs +22 -4
- package/lib/doc-stamp.mjs +48 -0
- package/lib/doctor/watchers/consistency.mjs +62 -12
- package/lib/document-extract.mjs +221 -28
- package/lib/document-ingest.mjs +2 -0
- package/lib/embed/inbox.mjs +48 -3
- package/lib/embed/role-framing.mjs +3 -3
- package/lib/env-config.mjs +9 -3
- package/lib/extractors/calendar.mjs +173 -0
- package/lib/extractors/shared/drop-info.mjs +22 -0
- package/lib/extractors/transcript.mjs +0 -0
- package/lib/gates-audit.mjs +8 -2
- package/lib/git-hooks/prepare-commit-msg +1 -1
- package/lib/headhunt.mjs +2 -2
- package/lib/hooks/agent-tracker.mjs +6 -11
- package/lib/hooks/guard-bash.mjs +35 -14
- package/lib/hooks/mcp-audit.mjs +12 -0
- package/lib/hooks/pre-compact.mjs +92 -5
- package/lib/hooks/pre-push-gate.mjs +41 -8
- package/lib/hooks/registry-sync.mjs +2 -2
- package/lib/hooks/stop-notify.mjs +7 -6
- package/lib/host-capabilities.mjs +10 -1
- package/lib/hygiene/scan.mjs +141 -0
- package/lib/init-unified.mjs +18 -0
- package/lib/install/stage-project.mjs +4 -4
- package/lib/intake/classify.mjs +222 -55
- package/lib/intake/filesystem-queue.mjs +25 -5
- package/lib/intake/postgres-queue.mjs +45 -3
- package/lib/intake/prepare.mjs +3 -0
- package/lib/intake/quarantine.mjs +205 -0
- package/lib/knowledge/postgres-search.mjs +132 -0
- package/lib/knowledge/search.mjs +14 -4
- package/lib/mcp/server.mjs +9 -3
- package/lib/mcp/tools/skills.mjs +32 -11
- package/lib/mcp/tools/workflow.mjs +1 -1
- package/lib/migrations/index.mjs +1 -1
- package/lib/model-registry.mjs +1 -1
- package/lib/opencode-runtime-plugin.mjs +1 -1
- package/lib/orchestration-policy.mjs +4 -4
- package/lib/overrides/resolver.mjs +3 -3
- package/lib/parity.mjs +4 -4
- package/lib/policy/engine.mjs +2 -2
- package/lib/profiles/lifecycle.mjs +1 -1
- package/lib/prompt-metadata.mjs +4 -4
- package/lib/providers/circuit-breaker.mjs +14 -0
- package/lib/providers/contract.mjs +67 -3
- package/lib/providers/creds.mjs +219 -0
- package/lib/providers/scaffold.mjs +107 -0
- package/lib/role-preload.mjs +5 -0
- package/lib/roles/catalog.mjs +1 -1
- package/lib/roles/manifest.mjs +2 -2
- package/lib/scheduler/index.mjs +112 -0
- package/lib/scheduler/solo.mjs +183 -0
- package/lib/server/index.mjs +7 -1
- package/lib/server/insights.mjs +14 -12
- package/lib/server/langfuse-login.mjs +58 -0
- package/lib/server/static/assets/index-ab25c707.js +1 -1
- package/lib/{agent-contracts-enforce.mjs → specialist-contracts-enforce.mjs} +4 -4
- package/lib/{agent-contracts.mjs → specialist-contracts.mjs} +9 -8
- package/lib/{agents → specialists}/postconditions.mjs +3 -3
- package/lib/{agents → specialists}/schema.mjs +6 -6
- package/lib/status.mjs +13 -11
- package/lib/storage/backup.mjs +2 -2
- package/lib/tags/lifecycle.mjs +278 -0
- package/lib/tags/vocabulary.mjs +140 -0
- package/lib/telemetry/otel-tracer.mjs +184 -0
- package/lib/telemetry/skill-calls.mjs +73 -12
- package/lib/uninstall/uninstall.mjs +1 -1
- package/lib/update.mjs +1 -1
- package/lib/validator.mjs +57 -56
- package/lib/workflows/instantiate.mjs +320 -0
- package/package.json +13 -3
- package/personas/construct.md +2 -2
- package/platforms/claude/CLAUDE.md +1 -1
- package/rules/common/no-fabrication.md +1 -1
- package/scripts/{sync-agents.mjs → sync-specialists.mjs} +126 -54
- package/skills/ai/prompt-optimizer.md +3 -3
- package/skills/exploration/dependency-graph-reading.md +98 -0
- package/skills/exploration/tracer-bullet-method.md +71 -0
- package/skills/exploration/unknown-codebase-onboarding.md +91 -0
- package/skills/operating/change-management.md +91 -0
- package/skills/operating/incident-response.md +75 -0
- package/skills/operating/oncall-rotation.md +95 -0
- package/skills/operating/orchestration-reference.md +2 -2
- package/skills/strategy/competitive-landscape.md +75 -0
- package/skills/strategy/market-research-methods.md +87 -0
- package/skills/strategy/narrative-arc.md +77 -0
- package/skills/strategy/pricing-positioning.md +94 -0
- package/{agents → specialists}/contracts.schema.json +1 -1
- package/specialists/policy-inventory.json +160 -0
- package/{agents → specialists}/prompts/cx-accessibility.md +1 -1
- package/{agents → specialists}/prompts/cx-ai-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-architect.md +1 -1
- package/{agents → specialists}/prompts/cx-business-strategist.md +1 -1
- package/{agents → specialists}/prompts/cx-data-analyst.md +1 -1
- package/{agents → specialists}/prompts/cx-data-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-debugger.md +1 -1
- package/{agents → specialists}/prompts/cx-designer.md +1 -1
- package/{agents → specialists}/prompts/cx-devil-advocate.md +1 -1
- package/{agents → specialists}/prompts/cx-docs-keeper.md +2 -2
- package/{agents → specialists}/prompts/cx-engineer.md +2 -2
- package/{agents → specialists}/prompts/cx-evaluator.md +1 -1
- package/{agents → specialists}/prompts/cx-explorer.md +1 -1
- package/{agents → specialists}/prompts/cx-legal-compliance.md +1 -1
- package/{agents → specialists}/prompts/cx-operations.md +1 -1
- package/{agents → specialists}/prompts/cx-orchestrator.md +2 -2
- package/{agents → specialists}/prompts/cx-platform-engineer.md +1 -1
- package/{agents → specialists}/prompts/cx-product-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-qa.md +1 -1
- package/{agents → specialists}/prompts/cx-rd-lead.md +1 -1
- package/{agents → specialists}/prompts/cx-release-manager.md +1 -1
- package/{agents → specialists}/prompts/cx-researcher.md +1 -1
- package/{agents → specialists}/prompts/cx-reviewer.md +2 -2
- package/{agents → specialists}/prompts/cx-security.md +1 -1
- package/{agents → specialists}/prompts/cx-sre.md +2 -2
- package/{agents → specialists}/prompts/cx-test-automation.md +1 -1
- package/{agents → specialists}/prompts/cx-trace-reviewer.md +3 -3
- package/{agents → specialists}/prompts/cx-ux-researcher.md +1 -1
- package/{agents → specialists}/registry.json +651 -423
- package/{agents → specialists}/role-manifests.json +8 -8
- package/templates/provider-scaffold/health.test.mjs +30 -0
- package/templates/provider-scaffold/index.mjs +48 -0
- package/templates/workflows/cross-team-handoff.yml +85 -0
- package/templates/workflows/engineering-onboarding.yml +77 -0
- package/templates/workflows/new-feature.yml +53 -0
- /package/{agents → specialists}/contracts.json +0 -0
- /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
|
|
5
|
-
* `construct skills
|
|
6
|
-
* load events in a 30-day window (pruning candidates),
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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] = {
|
|
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, '
|
|
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-
|
|
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.
|
|
63
|
-
errors.push('registry:
|
|
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 (!
|
|
66
|
-
errors.push('registry:
|
|
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
|
|
70
|
-
const
|
|
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
|
|
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
|
|
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
|
|
90
|
-
const label = validateIdentity(
|
|
89
|
+
for (const specialist of specialists) {
|
|
90
|
+
const label = validateIdentity(specialist, 'specialist');
|
|
91
91
|
|
|
92
|
-
if (!
|
|
92
|
+
if (!specialist.description || typeof specialist.description !== 'string') {
|
|
93
93
|
errors.push(`${label}: description must be a non-empty string`);
|
|
94
|
-
} else if (
|
|
95
|
-
errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${
|
|
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 (
|
|
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 (
|
|
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 (!
|
|
103
|
+
if (!specialist.prompt && !specialist.promptFile) {
|
|
104
104
|
errors.push(`${label}: must define prompt or promptFile`);
|
|
105
|
-
} else if (
|
|
106
|
-
errors.push(`${label}: promptFile does not exist (${
|
|
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
|
|
109
|
-
?
|
|
110
|
-
: loadPromptContent(rootDir,
|
|
108
|
+
const promptText = typeof specialist.prompt === 'string'
|
|
109
|
+
? specialist.prompt
|
|
110
|
+
: loadPromptContent(rootDir, specialist.promptFile);
|
|
111
111
|
if (promptText) {
|
|
112
|
-
const cap = Number.isInteger(
|
|
113
|
-
?
|
|
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 (!
|
|
120
|
+
if (!specialist.model && !specialist.modelTier) {
|
|
121
121
|
errors.push(`${label}: must define model or modelTier`);
|
|
122
122
|
}
|
|
123
|
-
if (
|
|
124
|
-
errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${
|
|
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 (
|
|
126
|
+
if (specialist.model && !hasProviderModelShape(specialist.model)) {
|
|
127
127
|
errors.push(`${label}: model must be provider/model-id format`);
|
|
128
128
|
}
|
|
129
|
-
if (
|
|
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
|
|
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,
|
|
136
|
-
validateStringArray(errors,
|
|
135
|
+
validateStringArray(errors, specialist.chain, `${label}.chain`, specialistNames);
|
|
136
|
+
validateStringArray(errors, specialist.alsoInvokes, `${label}.alsoInvokes`, specialistNames);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
const label = validateIdentity(
|
|
139
|
+
if (orchestrator) {
|
|
140
|
+
const label = validateIdentity(orchestrator, 'orchestrator');
|
|
141
141
|
|
|
142
|
-
if (!
|
|
142
|
+
if (!orchestrator.description || typeof orchestrator.description !== 'string') {
|
|
143
143
|
errors.push(`${label}: description must be a non-empty string`);
|
|
144
|
-
} else if (
|
|
145
|
-
errors.push(`${label}: description exceeds ${DESCRIPTION_MAX_CHARS} chars (got ${
|
|
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 (
|
|
148
|
-
errors.push(`${label}: displayName exceeds ${DISPLAY_NAME_MAX_CHARS} chars (got ${
|
|
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 (!
|
|
150
|
+
if (!orchestrator.role || typeof orchestrator.role !== 'string') {
|
|
151
151
|
errors.push(`${label}: role must be a non-empty string`);
|
|
152
152
|
}
|
|
153
|
-
if (!
|
|
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,
|
|
156
|
-
errors.push(`${label}: promptFile does not exist (${
|
|
155
|
+
} else if (!existsSync(join(rootDir, orchestrator.promptFile))) {
|
|
156
|
+
errors.push(`${label}: promptFile does not exist (${orchestrator.promptFile})`);
|
|
157
157
|
}
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
const cap = Number.isInteger(
|
|
161
|
-
?
|
|
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(
|
|
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 (!
|
|
168
|
+
if (!orchestrator.model && !orchestrator.modelTier) {
|
|
169
169
|
errors.push(`${label}: must define model or modelTier`);
|
|
170
170
|
}
|
|
171
|
-
if (
|
|
172
|
-
errors.push(`${label}: modelTier must be reasoning|standard|fast, got '${
|
|
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 (
|
|
174
|
+
if (orchestrator.model && !hasProviderModelShape(orchestrator.model)) {
|
|
175
175
|
errors.push(`${label}: model must be provider/model-id format`);
|
|
176
176
|
}
|
|
177
|
-
if (
|
|
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,
|
|
181
|
-
validateStringArray(errors,
|
|
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
|
|
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, '..', '
|
|
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})`);
|