@geraldmaron/construct 1.0.16 → 1.0.18
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 +4 -1
- package/bin/construct +236 -21
- package/bin/construct-postinstall.mjs +17 -2
- package/commands/design/flow.md +2 -0
- package/commands/design/ui.md +2 -0
- package/lib/cli-commands.mjs +65 -13
- package/lib/embed/semantic.mjs +5 -3
- package/lib/embedded-contract/audit.mjs +52 -0
- package/lib/embedded-contract/capability.mjs +179 -0
- package/lib/embedded-contract/contract-version.mjs +39 -0
- package/lib/embedded-contract/envelope.mjs +70 -0
- package/lib/embedded-contract/index.mjs +71 -0
- package/lib/embedded-contract/ingest.mjs +77 -0
- package/lib/embedded-contract/model-resolve.mjs +186 -0
- package/lib/embedded-contract/redaction.mjs +91 -0
- package/lib/embedded-contract/role-facts.mjs +66 -0
- package/lib/embedded-contract/triage.mjs +134 -0
- package/lib/embedded-contract/workflow-defs.mjs +125 -0
- package/lib/embedded-contract/workflow-invoke.mjs +218 -0
- package/lib/hooks/config-protection.mjs +12 -5
- package/lib/init-unified.mjs +36 -26
- package/lib/intake/classify.mjs +6 -0
- package/lib/intake/tables/rnd.mjs +33 -0
- package/lib/mcp/server.mjs +69 -0
- package/lib/mcp/tools/embedded-contract.mjs +77 -0
- package/lib/model-router.mjs +40 -0
- package/lib/op-log.mjs +61 -0
- package/lib/roles/catalog.mjs +26 -95
- package/lib/service-manager.mjs +33 -11
- package/lib/setup.mjs +21 -3
- package/lib/validators/skills.mjs +21 -0
- package/package.json +12 -7
- package/scripts/sync-specialists.mjs +12 -3
- package/skills/ai/agent-dev.md +2 -0
- package/skills/ai/llm-security.md +2 -0
- package/skills/ai/ml-ops.md +2 -0
- package/skills/ai/orchestration-workflow.md +2 -0
- package/skills/ai/prompt-and-eval.md +2 -0
- package/skills/ai/prompt-optimizer.md +2 -0
- package/skills/ai/rag-system.md +2 -0
- package/skills/ai/trace-triage.md +36 -0
- package/skills/architecture/api-design.md +2 -0
- package/skills/architecture/caching.md +2 -0
- package/skills/architecture/cloud-native.md +2 -0
- package/skills/architecture/message-queue.md +2 -0
- package/skills/architecture/security-arch.md +2 -0
- package/skills/compliance/ai-disclosure.md +2 -0
- package/skills/compliance/data-privacy.md +2 -0
- package/skills/compliance/license-audit.md +2 -0
- package/skills/compliance/regulatory-review.md +2 -0
- package/skills/development/cpp.md +2 -0
- package/skills/development/go.md +2 -0
- package/skills/development/java.md +2 -0
- package/skills/development/kotlin.md +2 -0
- package/skills/development/mobile-crossplatform.md +2 -0
- package/skills/development/python.md +2 -0
- package/skills/development/rust.md +2 -0
- package/skills/development/shell.md +2 -0
- package/skills/development/swift.md +2 -0
- package/skills/development/typescript.md +2 -0
- package/skills/devops/ci-cd.md +2 -0
- package/skills/devops/containerization.md +2 -0
- package/skills/devops/cost-optimization.md +2 -0
- package/skills/devops/data-engineering.md +2 -0
- package/skills/devops/database.md +2 -0
- package/skills/devops/dependency-management.md +2 -0
- package/skills/devops/devsecops.md +2 -0
- package/skills/devops/git-workflow.md +2 -0
- package/skills/devops/incident-response.md +2 -0
- package/skills/devops/monorepo.md +2 -0
- package/skills/devops/observability.md +2 -0
- package/skills/devops/performance.md +2 -0
- package/skills/devops/testing.md +2 -0
- package/skills/docs/adr-workflow.md +2 -0
- package/skills/docs/backlog-proposal-workflow.md +2 -0
- package/skills/docs/customer-profile-workflow.md +2 -0
- package/skills/docs/document-ingest-workflow.md +2 -0
- package/skills/docs/evidence-ingest-workflow.md +2 -0
- package/skills/docs/init-docs.md +2 -0
- package/skills/docs/init-project.md +2 -0
- package/skills/docs/memo-and-decision-capture.md +45 -0
- package/skills/docs/prd-workflow.md +2 -0
- package/skills/docs/prfaq-workflow.md +2 -0
- package/skills/docs/product-intelligence-review.md +2 -0
- package/skills/docs/product-intelligence-workflow.md +2 -0
- package/skills/docs/product-signal-workflow.md +2 -0
- package/skills/docs/research-workflow.md +2 -0
- package/skills/docs/runbook-workflow.md +2 -0
- package/skills/docs/strategy-workflow.md +2 -0
- package/skills/docs/transcript-synthesis.md +43 -0
- package/skills/exploration/dependency-graph-reading.md +2 -0
- package/skills/exploration/repo-map.md +2 -0
- package/skills/exploration/tracer-bullet-method.md +2 -0
- package/skills/exploration/unknown-codebase-onboarding.md +2 -0
- package/skills/frameworks/django.md +2 -0
- package/skills/frameworks/nextjs.md +2 -0
- package/skills/frameworks/react.md +2 -0
- package/skills/frameworks/spring-boot.md +2 -0
- package/skills/frontend-design/accessibility.md +2 -0
- package/skills/frontend-design/component-patterns.md +2 -0
- package/skills/frontend-design/engineering.md +2 -0
- package/skills/frontend-design/screen-reader-testing.md +34 -0
- package/skills/frontend-design/state-management.md +2 -0
- package/skills/frontend-design/ui-aesthetics.md +2 -0
- package/skills/frontend-design/ux-principles.md +2 -0
- package/skills/operating/change-management.md +2 -0
- package/skills/operating/incident-response.md +2 -0
- package/skills/operating/oncall-rotation.md +2 -0
- package/skills/operating/orchestration-reference.md +2 -0
- package/skills/operating/raw-data-structuring.md +44 -0
- package/skills/operating/unstructured-triage.md +45 -0
- package/skills/quality-gates/premortem.md +37 -0
- package/skills/quality-gates/review-work.md +2 -0
- package/skills/quality-gates/verify-change.md +2 -0
- package/skills/quality-gates/verify-module.md +2 -0
- package/skills/quality-gates/verify-quality.md +2 -0
- package/skills/quality-gates/verify-security.md +2 -0
- package/skills/roles/architect.ai-systems.md +2 -0
- package/skills/roles/architect.data.md +2 -0
- package/skills/roles/architect.enterprise.md +2 -0
- package/skills/roles/architect.integration.md +2 -0
- package/skills/roles/architect.md +2 -0
- package/skills/roles/architect.platform.md +2 -0
- package/skills/roles/data-analyst.experiment.md +2 -0
- package/skills/roles/data-analyst.md +2 -0
- package/skills/roles/data-analyst.product-intelligence.md +2 -0
- package/skills/roles/data-analyst.product.md +2 -0
- package/skills/roles/data-analyst.telemetry.md +2 -0
- package/skills/roles/data-engineer.pipeline.md +2 -0
- package/skills/roles/data-engineer.vector-retrieval.md +2 -0
- package/skills/roles/data-engineer.warehouse.md +2 -0
- package/skills/roles/debugger.md +2 -0
- package/skills/roles/designer.accessibility.md +2 -0
- package/skills/roles/designer.md +2 -0
- package/skills/roles/engineer.ai.md +2 -0
- package/skills/roles/engineer.data.md +2 -0
- package/skills/roles/engineer.md +2 -0
- package/skills/roles/engineer.platform.md +2 -0
- package/skills/roles/operator.docs.md +2 -0
- package/skills/roles/operator.md +2 -0
- package/skills/roles/operator.release.md +2 -0
- package/skills/roles/operator.sre.md +2 -0
- package/skills/roles/orchestrator.md +2 -0
- package/skills/roles/product-manager.ai-product.md +2 -0
- package/skills/roles/product-manager.business-strategy.md +2 -0
- package/skills/roles/product-manager.enterprise.md +2 -0
- package/skills/roles/product-manager.growth.md +2 -0
- package/skills/roles/product-manager.md +2 -0
- package/skills/roles/product-manager.platform.md +2 -0
- package/skills/roles/product-manager.product.md +2 -0
- package/skills/roles/qa.ai-eval.md +2 -0
- package/skills/roles/qa.api-contract.md +2 -0
- package/skills/roles/qa.data-pipeline.md +2 -0
- package/skills/roles/qa.md +2 -0
- package/skills/roles/qa.test-automation.md +2 -0
- package/skills/roles/qa.web-ui.md +2 -0
- package/skills/roles/researcher.explorer.md +2 -0
- package/skills/roles/researcher.md +2 -0
- package/skills/roles/researcher.ux.md +2 -0
- package/skills/roles/reviewer.devil-advocate.md +2 -0
- package/skills/roles/reviewer.evaluator.md +2 -0
- package/skills/roles/reviewer.md +2 -0
- package/skills/roles/reviewer.trace.md +2 -0
- package/skills/roles/security.ai.md +2 -0
- package/skills/roles/security.appsec.md +2 -0
- package/skills/roles/security.cloud.md +2 -0
- package/skills/roles/security.legal-compliance.md +2 -0
- package/skills/roles/security.md +2 -0
- package/skills/roles/security.privacy.md +2 -0
- package/skills/roles/security.supply-chain.md +2 -0
- package/skills/security/blue-team.md +2 -0
- package/skills/security/code-audit.md +2 -0
- package/skills/security/pentest.md +2 -0
- package/skills/security/red-team.md +2 -0
- package/skills/security/threat-intel.md +2 -0
- package/skills/security/vuln-research.md +2 -0
- package/skills/strategy/competitive-landscape.md +2 -0
- package/skills/strategy/jobs-to-be-done.md +38 -0
- package/skills/strategy/market-research-methods.md +2 -0
- package/skills/strategy/narrative-arc.md +2 -0
- package/skills/strategy/pricing-positioning.md +2 -0
- package/skills/utility/clean-code.md +2 -0
- package/specialists/prompts/cx-architect.md +1 -0
- package/specialists/prompts/cx-designer.md +1 -1
- package/specialists/prompts/cx-engineer.md +1 -1
- package/specialists/prompts/cx-operations.md +1 -0
- package/specialists/prompts/cx-rd-lead.md +1 -0
- package/specialists/prompts/cx-sre.md +1 -0
- package/specialists/registry.json +20 -11
- package/specialists/role-manifests.json +1 -1
- package/templates/docs/test-plan.md +96 -0
package/lib/init-unified.mjs
CHANGED
|
@@ -283,20 +283,26 @@ function parseWithDocs(value) {
|
|
|
283
283
|
return LANE_ORDER;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
286
|
+
// Map singular aliases to canonical lane keys BEFORE validating, so
|
|
287
|
+
// `--with-docs=adr,rfc` resolves rather than being silently filtered out.
|
|
288
|
+
const ALIASES = { adr: 'adrs', rfc: 'rfcs', runbook: 'runbooks', postmortem: 'postmortems', prd: 'prds', brief: 'briefs', memo: 'memos', changelog: 'changelogs', meeting: 'meetings', note: 'notes' };
|
|
289
|
+
|
|
290
|
+
const requested = value.split(',').map(v => v.trim().toLowerCase()).filter(Boolean);
|
|
291
|
+
const resolved = [];
|
|
292
|
+
const unknown = [];
|
|
293
|
+
for (const lane of requested) {
|
|
294
|
+
const canonical = ALIASES[lane] || lane;
|
|
295
|
+
if (LANE_ORDER.includes(canonical)) resolved.push(canonical);
|
|
296
|
+
else unknown.push(lane);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Surface typo'd lanes loudly instead of dropping them silently; valid lanes
|
|
300
|
+
// still proceed so one bad token does not abort init.
|
|
301
|
+
if (unknown.length) {
|
|
302
|
+
console.warn(`Warning: ignoring unknown --with-docs lane(s): ${unknown.join(', ')}. Valid lanes: ${LANE_ORDER.join(', ')}`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return [...new Set(resolved)]; // dedupe
|
|
300
306
|
}
|
|
301
307
|
|
|
302
308
|
function inferProjectName(targetPath) {
|
|
@@ -741,8 +747,6 @@ This project enforces documentation quality:
|
|
|
741
747
|
}
|
|
742
748
|
|
|
743
749
|
function preflight(target) {
|
|
744
|
-
console.log('[TRACE init:pre-flight]');
|
|
745
|
-
|
|
746
750
|
// Check git repo
|
|
747
751
|
try {
|
|
748
752
|
execSync('git rev-parse --show-toplevel', { cwd: target, stdio: 'ignore' });
|
|
@@ -797,6 +801,14 @@ async function main() {
|
|
|
797
801
|
|
|
798
802
|
const { clean } = preflight(target);
|
|
799
803
|
|
|
804
|
+
// Interactive sessions check machine prerequisites up front and offer to run
|
|
805
|
+
// `construct install` before any scaffolding, so a missing dependency is
|
|
806
|
+
// surfaced at the start rather than midway. Non-interactive runs rely on
|
|
807
|
+
// preflight's git check and stay silent so CI/tests are unaffected.
|
|
808
|
+
if (interactive && process.stdin.isTTY) {
|
|
809
|
+
await checkPrerequisites();
|
|
810
|
+
}
|
|
811
|
+
|
|
800
812
|
if (!quiet) {
|
|
801
813
|
console.log(`Initializing Construct in ${path.relative(process.cwd(), target) || "."}`);
|
|
802
814
|
}
|
|
@@ -1037,7 +1049,6 @@ async function main() {
|
|
|
1037
1049
|
// so the user can inspect what's watched and edit the file directly. Empty
|
|
1038
1050
|
// parentDirs is the correct default: the inbox watcher always scans
|
|
1039
1051
|
// .cx/inbox/ and docs/intake/ (when present); extra dirs are opt-in.
|
|
1040
|
-
console.log('[TRACE init:intake-ask]');
|
|
1041
1052
|
|
|
1042
1053
|
const intakeConfig = (await askIntakeCollection(target, skipInteractive)) ?? { parentDirs: [], maxDepth: 4 };
|
|
1043
1054
|
// When a custom intake surface was detected, default includeProjectInbox
|
|
@@ -1063,8 +1074,6 @@ async function main() {
|
|
|
1063
1074
|
}
|
|
1064
1075
|
|
|
1065
1076
|
// Ask about documentation system
|
|
1066
|
-
console.log('[TRACE init:docs-ask]');
|
|
1067
|
-
|
|
1068
1077
|
const { lanes, withArchitecture, withReadme, docsPreset: userDocsPreset } = await askDocumentationQuestions();
|
|
1069
1078
|
|
|
1070
1079
|
// Create README.md if requested or missing
|
|
@@ -1072,9 +1081,7 @@ async function main() {
|
|
|
1072
1081
|
if (withReadme || !fs.existsSync(readmePath)) {
|
|
1073
1082
|
writeIfMissing(readmePath, buildProjectReadme(projectName));
|
|
1074
1083
|
}
|
|
1075
|
-
|
|
1076
|
-
console.log('[TRACE init:docs-write]');
|
|
1077
|
-
|
|
1084
|
+
|
|
1078
1085
|
// Create documentation system if lanes specified
|
|
1079
1086
|
if (lanes.length > 0) {
|
|
1080
1087
|
// Filter out lanes that the project already covers elsewhere
|
|
@@ -1195,19 +1202,22 @@ async function main() {
|
|
|
1195
1202
|
console.log(' NEXT STEPS');
|
|
1196
1203
|
console.log('═══════════════════════════════════════════════════════════');
|
|
1197
1204
|
console.log('');
|
|
1198
|
-
|
|
1205
|
+
// Number steps from a running counter so the list stays sequential when
|
|
1206
|
+
// the optional intake step is absent (was: hard-coded 1,2,4 → a visible gap).
|
|
1207
|
+
let step = 0;
|
|
1208
|
+
console.log(`${++step}. Review AGENTS.md`);
|
|
1199
1209
|
console.log(' Operating rules and guidelines for this project');
|
|
1200
1210
|
console.log('');
|
|
1201
|
-
console.log(
|
|
1211
|
+
console.log(`${++step}. Edit plan.md`);
|
|
1202
1212
|
console.log(' Add your current work and tasks');
|
|
1203
1213
|
console.log('');
|
|
1204
1214
|
if (lanes.includes('intake')) {
|
|
1205
|
-
console.log(
|
|
1215
|
+
console.log(`${++step}. Use Intake`);
|
|
1206
1216
|
console.log(' Drop files in .cx/inbox/ for processing');
|
|
1207
1217
|
console.log(' Run: construct intake');
|
|
1208
1218
|
console.log('');
|
|
1209
1219
|
}
|
|
1210
|
-
console.log(
|
|
1220
|
+
console.log(`${++step}. Start working`);
|
|
1211
1221
|
console.log(' Address @construct in your editor to begin');
|
|
1212
1222
|
console.log('');
|
|
1213
1223
|
}
|
package/lib/intake/classify.mjs
CHANGED
|
@@ -90,6 +90,9 @@ const FILENAME_HINTS = [
|
|
|
90
90
|
{ re: /eval|metric|benchmark/i, intakeType: 'eval-finding' },
|
|
91
91
|
{ re: /-bug\b|\bbug-/i, intakeType: 'bug' },
|
|
92
92
|
{ re: /runbook/i, intakeType: 'ops' },
|
|
93
|
+
{ re: /^memo[-_]|[-_]memo\b/i, intakeType: 'memo' },
|
|
94
|
+
{ re: /transcript|meeting-notes|standup|stand-up/i, intakeType: 'transcript' },
|
|
95
|
+
{ re: /dataset|raw-?data|data-?dump|\.csv$|\.tsv$/i, intakeType: 'raw-data' },
|
|
93
96
|
];
|
|
94
97
|
|
|
95
98
|
// Title-level family terms by intakeType. When the doc's H1 contains a
|
|
@@ -105,6 +108,9 @@ const TITLE_FAMILY = {
|
|
|
105
108
|
'eval-finding': /^#\s*(eval(uation)?(\s+finding)?\b|benchmark)/im,
|
|
106
109
|
bug: /^#\s*(bug\s+report|defect)\b/im,
|
|
107
110
|
ops: /^#\s*(runbook|operations\s+guide)\b/im,
|
|
111
|
+
memo: /^#\s*(memo|decision\s+memo|status\s+update)\b/im,
|
|
112
|
+
transcript: /^#\s*(transcript|meeting\s+(notes|minutes)|call\s+notes)\b/im,
|
|
113
|
+
'raw-data': /^#\s*(dataset|raw\s+data|data\s+export)\b/im,
|
|
108
114
|
};
|
|
109
115
|
|
|
110
116
|
// Title-lock returns the highest confidence the classifier emits because
|
|
@@ -22,6 +22,9 @@ export const INTAKE_TYPES = [
|
|
|
22
22
|
'ops',
|
|
23
23
|
'security',
|
|
24
24
|
'legal-compliance',
|
|
25
|
+
'memo',
|
|
26
|
+
'transcript',
|
|
27
|
+
'raw-data',
|
|
25
28
|
'unknown',
|
|
26
29
|
];
|
|
27
30
|
|
|
@@ -170,6 +173,36 @@ export const CLASSIFICATION_TABLE = [
|
|
|
170
173
|
risk: 'low',
|
|
171
174
|
requiresApproval: false,
|
|
172
175
|
},
|
|
176
|
+
{
|
|
177
|
+
intakeType: 'memo',
|
|
178
|
+
keywords: ['memo', 'decision memo', 'for your information', 'fyi', 'action item', 'action items', 'status update', 'weekly update', 'announcement', 'heads up', 'team update', 'decided to', 'proposal to'],
|
|
179
|
+
rdStage: 'artifact',
|
|
180
|
+
primaryOwner: 'docs-keeper',
|
|
181
|
+
recommendedChain: ['docs-keeper', 'reviewer'],
|
|
182
|
+
recommendedAction: 'summarize',
|
|
183
|
+
risk: 'low',
|
|
184
|
+
requiresApproval: false,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
intakeType: 'transcript',
|
|
188
|
+
keywords: ['transcript', 'webvtt', 'meeting notes', 'meeting minutes', 'minutes of', 'attendees', 'call notes', 'stand-up notes', 'standup notes', 'recording of', 'speaker 1', 'speaker 2'],
|
|
189
|
+
rdStage: 'signal',
|
|
190
|
+
primaryOwner: 'researcher',
|
|
191
|
+
recommendedChain: ['researcher', 'data-analyst'],
|
|
192
|
+
recommendedAction: 'summarize',
|
|
193
|
+
risk: 'low',
|
|
194
|
+
requiresApproval: false,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
intakeType: 'raw-data',
|
|
198
|
+
keywords: ['dataset', 'raw data', 'data dump', 'csv export', 'data export', 'column names', 'field names', 'rows and columns', 'records export', 'telemetry export', 'json export'],
|
|
199
|
+
rdStage: 'research',
|
|
200
|
+
primaryOwner: 'data-analyst',
|
|
201
|
+
recommendedChain: ['data-analyst', 'data-engineer'],
|
|
202
|
+
recommendedAction: 'summarize',
|
|
203
|
+
risk: 'low',
|
|
204
|
+
requiresApproval: false,
|
|
205
|
+
},
|
|
173
206
|
];
|
|
174
207
|
|
|
175
208
|
export default { INTAKE_TYPES, STAGES, CLASSIFICATION_TABLE, UNKNOWN_TRIAGE };
|
package/lib/mcp/server.mjs
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
profileCreate, profileArchive, sandboxList, learningStatus,
|
|
57
57
|
knowledgeGraphAsk,
|
|
58
58
|
} from './tools/profile.mjs';
|
|
59
|
+
import { modelResolve, triageRecommend, workflowInvoke, capabilityDescribe } from './tools/embedded-contract.mjs';
|
|
59
60
|
|
|
60
61
|
const DEFAULT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
61
62
|
const ROOT_DIR = resolve(process.env.CX_TOOLKIT_DIR || DEFAULT_ROOT_DIR);
|
|
@@ -1044,6 +1045,70 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1044
1045
|
},
|
|
1045
1046
|
},
|
|
1046
1047
|
},
|
|
1048
|
+
{
|
|
1049
|
+
name: 'model_resolve',
|
|
1050
|
+
description: 'Resolve which model an embedded Construct workflow should use given the host/IDE provider context. Precedence: host model → same-provider-family fallback → Construct tier default → structured config error. Never reads or returns credential values (requiresCredential is a boolean) and never claims unverified provider health. Read-only; performs no writes.',
|
|
1051
|
+
inputSchema: {
|
|
1052
|
+
type: 'object',
|
|
1053
|
+
properties: {
|
|
1054
|
+
workflow_type: { type: 'string', description: 'Workflow type hint (e.g. evidence-ingest, prd-draft, architecture-review). Selects a tier when requested_tier is absent.' },
|
|
1055
|
+
requested_tier: { type: 'string', enum: ['reasoning', 'standard', 'fast'], description: 'Desired tier; overrides the workflow-type hint.' },
|
|
1056
|
+
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
1057
|
+
host_model: { type: 'string', description: 'Model the host is currently using (e.g. anthropic/claude-sonnet-4-6).' },
|
|
1058
|
+
host_provider: { type: 'string', description: 'Provider family the host uses, when no host_model is given.' },
|
|
1059
|
+
capabilities: { type: 'array', items: { type: 'string' }, description: 'Optional required capabilities; unverifiable ones are returned as warnings.' },
|
|
1060
|
+
allow_cross_provider_fallback: { type: 'boolean', description: 'Permit falling back outside the host provider family (default false).' },
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
name: 'triage_recommend',
|
|
1066
|
+
description: 'Classify an artifact and return a role-aware plan (primary owner, role chain with rationale, suggested skills, evidence requirements, expected outputs, approval requirements, risks, next steps, canExecute) WITHOUT enqueuing or executing. Classification confidence is reported distinctly from any generation confidence. Read-only; performs no durable write.',
|
|
1067
|
+
inputSchema: {
|
|
1068
|
+
type: 'object',
|
|
1069
|
+
properties: {
|
|
1070
|
+
input: { type: 'string', description: 'Artifact text to classify (meeting notes, bug report, proposal, etc.). Provide this OR file_path.' },
|
|
1071
|
+
file_path: { type: 'string', description: 'Path to a file to extract and classify (PDF/Office via docling, audio/video via whisper, transcripts, plain text). Used when input is absent.' },
|
|
1072
|
+
source_path: { type: 'string', description: 'Optional filename/source hint to aid classification.' },
|
|
1073
|
+
artifact_type: { type: 'string', description: 'Optional artifact-type hint (advisory).' },
|
|
1074
|
+
domain: { type: 'string', description: 'Optional broad domain hint.' },
|
|
1075
|
+
desired_outcome: { type: 'string', description: 'Optional desired outcome (advisory).' },
|
|
1076
|
+
constraints: { type: 'array', items: { type: 'string' }, description: 'Optional constraints (advisory).' },
|
|
1077
|
+
available_roles: { type: 'array', items: { type: 'string' }, description: 'Restrict the plan to these role ids; dropped roles are reported as warnings.' },
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
name: 'workflow_invoke',
|
|
1083
|
+
description: 'Invoke a named Construct workflow (roles/skills) non-interactively and return a provenanced execution plan: selected roles, rationale, applied skills, resolved model, evidence requirements, output contract, risks, and a traceId. Construct returns the orchestration plan; the host runtime performs specialist reasoning. Durable writes occur ONLY when approval_mode is allow-durable-write; proposal-only and requires-human-approval perform no durable writes.',
|
|
1084
|
+
inputSchema: {
|
|
1085
|
+
type: 'object',
|
|
1086
|
+
required: ['workflow_type'],
|
|
1087
|
+
properties: {
|
|
1088
|
+
workflow_type: { type: 'string', description: 'One of: evidence-ingest, proposal-review, prd-draft, architecture-review, risk-review, research-synthesis.' },
|
|
1089
|
+
input: { type: 'string', description: 'Artifact text the workflow operates on. Provide this OR file_path.' },
|
|
1090
|
+
file_path: { type: 'string', description: 'Path to a file to extract (docling/whisper/transcript) and operate on, used when input is absent.' },
|
|
1091
|
+
context: { type: 'object', description: 'Optional structured context; keys matching evidence requirements mark them satisfied.' },
|
|
1092
|
+
role_strategy: { type: 'string', enum: ['auto', 'explicit', 'constrained'], description: 'auto = default chain; explicit = use requested_roles; constrained = default chain intersected with requested_roles.' },
|
|
1093
|
+
requested_roles: { type: 'array', items: { type: 'string' }, description: 'Role ids for explicit/constrained strategies.' },
|
|
1094
|
+
approval_mode: { type: 'string', enum: ['proposal-only', 'requires-human-approval', 'allow-durable-write'], description: 'Gate for durable writes (default: the workflow type default).' },
|
|
1095
|
+
trace: { type: 'boolean', description: 'Emit a traceId for provenance correlation (default true).' },
|
|
1096
|
+
host: { type: 'string', description: 'Host/IDE identifier (advisory).' },
|
|
1097
|
+
host_model: { type: 'string', description: 'Model the host uses, for model resolution.' },
|
|
1098
|
+
host_provider: { type: 'string', description: 'Provider family the host uses, for model resolution.' },
|
|
1099
|
+
},
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
name: 'capability_describe',
|
|
1104
|
+
description: 'Describe what this Construct install can do: versions, contract interfaces (CLI/MCP/SDK), roles, skills, workflows, schemas, models/providers, policies, telemetry posture, and plugins. Read-only and secret-free — provider entries carry env-key names and a configured boolean only, never credential values. Reads live registries so the published contract cannot drift from reality.',
|
|
1105
|
+
inputSchema: {
|
|
1106
|
+
type: 'object',
|
|
1107
|
+
properties: {
|
|
1108
|
+
root_dir: { type: 'string', description: 'Optional Construct install root (default: server toolkit dir).' },
|
|
1109
|
+
},
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1047
1112
|
],
|
|
1048
1113
|
}));
|
|
1049
1114
|
|
|
@@ -1121,6 +1186,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1121
1186
|
else if (name === 'sandbox_list') result = sandboxList();
|
|
1122
1187
|
else if (name === 'learning_status') result = learningStatus(args);
|
|
1123
1188
|
else if (name === 'knowledge_graph_ask') result = knowledgeGraphAsk(args);
|
|
1189
|
+
else if (name === 'model_resolve') result = modelResolve(args);
|
|
1190
|
+
else if (name === 'triage_recommend') result = await triageRecommend(args);
|
|
1191
|
+
else if (name === 'workflow_invoke') result = await workflowInvoke(args);
|
|
1192
|
+
else if (name === 'capability_describe') result = capabilityDescribe(args);
|
|
1124
1193
|
else result = { error: `Unknown tool: ${name}` };
|
|
1125
1194
|
} catch (err) {
|
|
1126
1195
|
result = { error: err.message ?? String(err) };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/mcp/tools/embedded-contract.mjs — MCP surface for the Embedded Contract Layer.
|
|
3
|
+
*
|
|
4
|
+
* Thin adapters that translate MCP tool arguments into the embedded-contract
|
|
5
|
+
* core calls and return the same versioned envelope the CLI-JSON and SDK
|
|
6
|
+
* surfaces emit. No business logic lives here — it delegates to
|
|
7
|
+
* lib/embedded-contract/* so the three surfaces stay structurally identical.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { wrapContractResult } from '../../embedded-contract/envelope.mjs';
|
|
11
|
+
import { resolveEmbeddedModel as resolveModelCore } from '../../embedded-contract/model-resolve.mjs';
|
|
12
|
+
import { recommendPlan as recommendPlanCore } from '../../embedded-contract/triage.mjs';
|
|
13
|
+
import { invokeWorkflow as invokeWorkflowCore } from '../../embedded-contract/workflow-invoke.mjs';
|
|
14
|
+
import { buildCapabilityContract } from '../../embedded-contract/capability.mjs';
|
|
15
|
+
import { resolveInput } from '../../embedded-contract/ingest.mjs';
|
|
16
|
+
|
|
17
|
+
async function ingestArgs(args) {
|
|
18
|
+
if (args.input || !args.file_path) return { input: args.input, ingestion: undefined };
|
|
19
|
+
const resolved = await resolveInput({ filePath: args.file_path });
|
|
20
|
+
return { input: resolved.text, ingestion: resolved.error ? { ...(resolved.ingestion || {}), error: resolved.error } : resolved.ingestion };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function csv(value) {
|
|
24
|
+
if (Array.isArray(value)) return value;
|
|
25
|
+
if (typeof value === 'string' && value.trim()) return value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function modelResolve(args = {}) {
|
|
30
|
+
const request = {
|
|
31
|
+
workflowType: args.workflow_type,
|
|
32
|
+
requestedTier: args.requested_tier,
|
|
33
|
+
host: args.host,
|
|
34
|
+
hostModel: args.host_model,
|
|
35
|
+
hostProvider: args.host_provider,
|
|
36
|
+
capabilities: csv(args.capabilities),
|
|
37
|
+
allowCrossProviderFallback: Boolean(args.allow_cross_provider_fallback),
|
|
38
|
+
};
|
|
39
|
+
return wrapContractResult(resolveModelCore(request), { surface: 'mcp' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function triageRecommend(args = {}) {
|
|
43
|
+
const { input, ingestion } = await ingestArgs(args);
|
|
44
|
+
const request = {
|
|
45
|
+
input,
|
|
46
|
+
ingestion,
|
|
47
|
+
sourcePath: args.source_path || args.file_path,
|
|
48
|
+
artifactType: args.artifact_type,
|
|
49
|
+
domain: args.domain,
|
|
50
|
+
desiredOutcome: args.desired_outcome,
|
|
51
|
+
constraints: csv(args.constraints),
|
|
52
|
+
availableRoles: csv(args.available_roles),
|
|
53
|
+
};
|
|
54
|
+
return wrapContractResult(recommendPlanCore(request), { surface: 'mcp' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function workflowInvoke(args = {}) {
|
|
58
|
+
const { input, ingestion } = await ingestArgs(args);
|
|
59
|
+
const request = {
|
|
60
|
+
workflowType: args.workflow_type,
|
|
61
|
+
input,
|
|
62
|
+
ingestion,
|
|
63
|
+
context: args.context,
|
|
64
|
+
roleStrategy: args.role_strategy,
|
|
65
|
+
requestedRoles: csv(args.requested_roles),
|
|
66
|
+
approvalMode: args.approval_mode,
|
|
67
|
+
trace: args.trace !== false,
|
|
68
|
+
host: args.host,
|
|
69
|
+
hostModel: args.host_model,
|
|
70
|
+
hostProvider: args.host_provider,
|
|
71
|
+
};
|
|
72
|
+
return wrapContractResult(await invokeWorkflowCore(request), { surface: 'mcp' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function capabilityDescribe(args = {}) {
|
|
76
|
+
return wrapContractResult(buildCapabilityContract({ rootDir: args.root_dir }), { surface: 'mcp' });
|
|
77
|
+
}
|
package/lib/model-router.mjs
CHANGED
|
@@ -418,6 +418,46 @@ export function resolveTiersForPrimary(primaryModelId) {
|
|
|
418
418
|
return family.resolve({ reasoning: undefined, standard: undefined, fast: undefined });
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
// Credential detection here is env-only — no network or process probe — so
|
|
422
|
+
// callers (embedded model resolution, capability discovery) stay synchronous
|
|
423
|
+
// and deterministic, and never expose a key value, only its presence.
|
|
424
|
+
|
|
425
|
+
function familyDescriptor(family, env) {
|
|
426
|
+
const requiresEnv = PROVIDER_ENV_MAP[family.id] || (Array.isArray(family.requiresEnv) ? family.requiresEnv : []);
|
|
427
|
+
const local = family.local === true;
|
|
428
|
+
const configured = local || requiresEnv.some((key) => {
|
|
429
|
+
const value = env?.[key];
|
|
430
|
+
return typeof value === "string" && value.length > 0;
|
|
431
|
+
});
|
|
432
|
+
return {
|
|
433
|
+
id: family.id,
|
|
434
|
+
label: family.label,
|
|
435
|
+
local,
|
|
436
|
+
requiresEnv,
|
|
437
|
+
tiers: family.resolve({}),
|
|
438
|
+
configured,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Describe the provider family a model id belongs to, including which env keys
|
|
444
|
+
* confirm credentials and whether they are present. Returns null when no family
|
|
445
|
+
* matches.
|
|
446
|
+
*/
|
|
447
|
+
export function describeModelFamily(modelId, { env = process.env } = {}) {
|
|
448
|
+
const family = matchProviderFamily(modelId);
|
|
449
|
+
if (!family) return null;
|
|
450
|
+
return familyDescriptor(family, env);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Describe every provider family. Resolves a host-supplied provider id and
|
|
455
|
+
* enumerates model availability for the embedded contract layer.
|
|
456
|
+
*/
|
|
457
|
+
export function listModelFamilies({ env = process.env } = {}) {
|
|
458
|
+
return PROVIDER_FAMILY_TIERS.map((family) => familyDescriptor(family, env));
|
|
459
|
+
}
|
|
460
|
+
|
|
421
461
|
/**
|
|
422
462
|
* Read the OpenRouter API key from the OpenCode config file.
|
|
423
463
|
*/
|
package/lib/op-log.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/op-log.mjs — per-operation structured log file for long-running CLI ops.
|
|
3
|
+
*
|
|
4
|
+
* Commands like `dev`, `install`, `init`, and `sync` keep their human-facing
|
|
5
|
+
* output on stdout (println/ok/warn). This module adds a parallel machine log:
|
|
6
|
+
* one JSONL file per run at `<home>/.cx/<op>-<timestamp>.log`, every line
|
|
7
|
+
* stamped with a shared correlation id (`op_id`) so a failed run can be
|
|
8
|
+
* reconstructed after the fact and concurrent runs never interleave
|
|
9
|
+
* ambiguously. It reuses `makeLogger` from lib/logger.mjs for the line format.
|
|
10
|
+
*
|
|
11
|
+
* Writes are synchronous (an open fd + writeSync), not a buffered stream:
|
|
12
|
+
* CLI ops routinely end in process.exit(), and a buffered stream can drop its
|
|
13
|
+
* tail before the event loop drains it. Synchronous append loses nothing.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort by contract: if the log dir can't be created or the file can't
|
|
16
|
+
* open, the returned handle silently no-ops. Logging must never break the
|
|
17
|
+
* operation it observes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
|
|
24
|
+
import { makeLogger, newRequestId } from './logger.mjs';
|
|
25
|
+
|
|
26
|
+
const NOOP_LOGGER = Object.freeze({ debug() {}, info() {}, warn() {}, error() {} });
|
|
27
|
+
|
|
28
|
+
export function startOpLog(op, { homeDir = os.homedir(), env = process.env, now = new Date() } = {}) {
|
|
29
|
+
const id = newRequestId();
|
|
30
|
+
const dir = path.join(homeDir, '.cx');
|
|
31
|
+
const stamp = now.toISOString().replace(/[:.]/g, '-');
|
|
32
|
+
const logPath = path.join(dir, `${op}-${stamp}.log`);
|
|
33
|
+
|
|
34
|
+
let fd = null;
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
fd = fs.openSync(logPath, 'a');
|
|
38
|
+
} catch {
|
|
39
|
+
fd = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Adapt the fd to the minimal stream surface makeLogger needs: a sync write.
|
|
43
|
+
const stream = fd === null ? null : { write: (line) => { try { fs.writeSync(fd, line); } catch { /* best-effort */ } } };
|
|
44
|
+
const log = stream
|
|
45
|
+
? makeLogger({ env, stream }).child({ op, op_id: id })
|
|
46
|
+
: NOOP_LOGGER;
|
|
47
|
+
|
|
48
|
+
log.info('op.start', { pid: process.pid });
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
logPath: fd === null ? null : logPath,
|
|
53
|
+
event: (name, fields = {}) => log.info(name, fields),
|
|
54
|
+
warn: (name, fields = {}) => log.warn(name, fields),
|
|
55
|
+
error: (name, fields = {}) => log.error(name, fields),
|
|
56
|
+
close: (status = 'ok', fields = {}) => {
|
|
57
|
+
log.info('op.end', { status, ...fields });
|
|
58
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } fd = null; }
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/lib/roles/catalog.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* lib/roles/catalog.mjs — role listing and display helpers.
|
|
3
3
|
*
|
|
4
|
-
* Reads specialists/registry.json to produce
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Reads the flat `specialists` array in specialists/registry.json to produce the
|
|
5
|
+
* list of available roles. Each descriptor carries the registry-prefixed display
|
|
6
|
+
* name (cx-<name>), description, model tier, edit capability, and skill ids — the
|
|
7
|
+
* shape consumed by the `roles:list` CLI command and the embedded capability
|
|
8
|
+
* contract (lib/embed/capability.mjs).
|
|
9
|
+
*
|
|
10
|
+
* The registry once carried `departments` and `consolidatedRoles` groupings; the
|
|
11
|
+
* schema since collapsed to a single `specialists` array, so this reader exposes
|
|
12
|
+
* the flat list only and never invents grouping that has no source.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
import { readFileSync } from 'node:fs';
|
|
@@ -22,109 +28,34 @@ function loadRegistry() {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
|
-
* Return
|
|
31
|
+
* Return the list of role descriptors from the registry's specialists array.
|
|
26
32
|
*
|
|
27
|
-
* @
|
|
28
|
-
* @param {boolean} opts.departments Group by department when true.
|
|
29
|
-
* @param {boolean} opts.consolidated Return consolidated 12-role view when true.
|
|
30
|
-
* @returns {Array<object>}
|
|
33
|
+
* @returns {Array<{id:string,name:string,description:string,modelTier:string,internal:boolean,canEdit:boolean,skills:string[]}>}
|
|
31
34
|
*/
|
|
32
|
-
export function listRoles(
|
|
35
|
+
export function listRoles() {
|
|
33
36
|
const registry = loadRegistry();
|
|
37
|
+
const prefix = registry.prefix || 'cx';
|
|
38
|
+
const specialists = Array.isArray(registry.specialists) ? registry.specialists : [];
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const agents = Array.isArray(registry.agents)
|
|
45
|
-
? registry.agents
|
|
46
|
-
: Object.values(registry.agents || {});
|
|
47
|
-
|
|
48
|
-
if (!departments) {
|
|
49
|
-
return agents.map((a) => ({
|
|
50
|
-
id: a.name,
|
|
51
|
-
name: `cx-${a.name}`,
|
|
52
|
-
description: a.description || '',
|
|
53
|
-
internal: !!a.internal,
|
|
54
|
-
}));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Group by department based on subDepartment membership in registry.departments
|
|
58
|
-
const depts = registry.departments || {};
|
|
59
|
-
const grouped = {};
|
|
60
|
-
for (const [deptId, dept] of Object.entries(depts)) {
|
|
61
|
-
grouped[deptId] = {
|
|
62
|
-
name: dept.name || deptId,
|
|
63
|
-
roles: [],
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
grouped._ungrouped = { name: 'Other', roles: [] };
|
|
67
|
-
|
|
68
|
-
for (const agent of agents) {
|
|
69
|
-
let placed = false;
|
|
70
|
-
for (const [deptId, dept] of Object.entries(depts)) {
|
|
71
|
-
const subs = dept.subDepartments || {};
|
|
72
|
-
for (const sub of Object.values(subs)) {
|
|
73
|
-
if ((sub.roles || []).includes(agent.name) || (sub.roles || []).includes(`cx-${agent.name}`)) {
|
|
74
|
-
grouped[deptId].roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
|
|
75
|
-
placed = true;
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
if (placed) break;
|
|
80
|
-
}
|
|
81
|
-
if (!placed) {
|
|
82
|
-
grouped._ungrouped.roles.push({ id: agent.name, name: `cx-${agent.name}`, description: agent.description || '' });
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return Object.entries(grouped)
|
|
87
|
-
.filter(([, g]) => g.roles.length > 0)
|
|
88
|
-
.map(([id, g]) => ({ departmentId: id, departmentName: g.name, roles: g.roles }));
|
|
40
|
+
return specialists.map((s) => ({
|
|
41
|
+
id: s.name,
|
|
42
|
+
name: `${prefix}-${s.name}`,
|
|
43
|
+
description: s.description || '',
|
|
44
|
+
modelTier: s.modelTier || 'standard',
|
|
45
|
+
internal: !!s.internal,
|
|
46
|
+
canEdit: !!s.canEdit,
|
|
47
|
+
skills: Array.isArray(s.skills) ? s.skills : [],
|
|
48
|
+
}));
|
|
89
49
|
}
|
|
90
50
|
|
|
91
51
|
/**
|
|
92
52
|
* Format the role list as a human-readable string.
|
|
93
53
|
*
|
|
94
|
-
* @param {object} opts Same options as listRoles.
|
|
95
54
|
* @returns {string}
|
|
96
55
|
*/
|
|
97
|
-
export function formatRoleList(
|
|
98
|
-
const roles = listRoles(
|
|
99
|
-
const lines = [];
|
|
100
|
-
|
|
101
|
-
if (consolidated) {
|
|
102
|
-
lines.push('Consolidated Roles (12-role simplified structure)');
|
|
103
|
-
lines.push('=================================================');
|
|
104
|
-
for (const r of roles) {
|
|
105
|
-
lines.push(`\n${r.id}`);
|
|
106
|
-
lines.push(` Absorbs: ${r.absorbs.join(', ')}`);
|
|
107
|
-
lines.push(` Use when: ${r.whenToUse}`);
|
|
108
|
-
}
|
|
109
|
-
lines.push('');
|
|
110
|
-
return lines.join('\n');
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (departments) {
|
|
114
|
-
lines.push('Available Roles by Department');
|
|
115
|
-
lines.push('=============================');
|
|
116
|
-
for (const group of roles) {
|
|
117
|
-
lines.push(`\n${group.departmentName}`);
|
|
118
|
-
for (const role of group.roles) {
|
|
119
|
-
lines.push(` ${role.name.padEnd(30)} ${role.description}`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
lines.push('');
|
|
123
|
-
return lines.join('\n');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
lines.push('Available Roles');
|
|
127
|
-
lines.push('===============');
|
|
56
|
+
export function formatRoleList() {
|
|
57
|
+
const roles = listRoles();
|
|
58
|
+
const lines = ['Available Roles', '==============='];
|
|
128
59
|
for (const role of roles) {
|
|
129
60
|
lines.push(` ${role.name.padEnd(30)} ${role.description}`);
|
|
130
61
|
}
|