loki-mode 5.51.0 → 5.52.1

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 (44) hide show
  1. package/README.md +4 -56
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/hooks/validate-bash.sh +5 -2
  5. package/dashboard/__init__.py +1 -1
  6. package/dashboard/server.py +1 -1
  7. package/docs/INSTALLATION.md +1 -1
  8. package/docs/alternative-installations.md +3 -3
  9. package/docs/certification/01-core-concepts/lab.md +174 -0
  10. package/docs/certification/01-core-concepts/lesson.md +182 -0
  11. package/docs/certification/01-core-concepts/quiz.md +93 -0
  12. package/docs/certification/02-enterprise-features/lab.md +154 -0
  13. package/docs/certification/02-enterprise-features/lesson.md +202 -0
  14. package/docs/certification/02-enterprise-features/quiz.md +93 -0
  15. package/docs/certification/03-advanced-patterns/lab.md +138 -0
  16. package/docs/certification/03-advanced-patterns/lesson.md +199 -0
  17. package/docs/certification/03-advanced-patterns/quiz.md +93 -0
  18. package/docs/certification/04-production-deployment/lab.md +160 -0
  19. package/docs/certification/04-production-deployment/lesson.md +261 -0
  20. package/docs/certification/04-production-deployment/quiz.md +93 -0
  21. package/docs/certification/05-troubleshooting/lab.md +254 -0
  22. package/docs/certification/05-troubleshooting/lesson.md +266 -0
  23. package/docs/certification/05-troubleshooting/quiz.md +93 -0
  24. package/docs/certification/README.md +80 -0
  25. package/docs/certification/answer-key.md +117 -0
  26. package/docs/certification/certification-exam.md +471 -0
  27. package/docs/certification/sample-prds/microservices-platform.md +100 -0
  28. package/docs/certification/sample-prds/saas-dashboard.md +60 -0
  29. package/docs/certification/sample-prds/todo-app.md +44 -0
  30. package/mcp/__init__.py +1 -1
  31. package/mcp/server.py +230 -0
  32. package/package.json +1 -1
  33. package/src/plugins/agent-plugin.js +123 -0
  34. package/src/plugins/gate-plugin.js +153 -0
  35. package/src/plugins/index.js +116 -0
  36. package/src/plugins/integration-plugin.js +174 -0
  37. package/src/plugins/loader.js +275 -0
  38. package/src/plugins/mcp-plugin.js +190 -0
  39. package/src/plugins/schemas/agent.json +59 -0
  40. package/src/plugins/schemas/integration.json +62 -0
  41. package/src/plugins/schemas/mcp_tool.json +73 -0
  42. package/src/plugins/schemas/quality_gate.json +52 -0
  43. package/src/plugins/validator.js +297 -0
  44. /package/dashboard/{secrets.py → app_secrets.py} +0 -0
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ const { readFileSync, readdirSync, existsSync, statSync, watch } = require('fs');
4
+ const { join, extname } = require('path');
5
+ const { PluginValidator } = require('./validator');
6
+
7
+ /**
8
+ * Simple YAML parser for plugin configs.
9
+ * Handles: key-value pairs, arrays (- item), multiline strings (|), booleans, numbers.
10
+ * This avoids requiring the js-yaml dependency.
11
+ */
12
+ function parseSimpleYAML(content) {
13
+ const result = {};
14
+ const lines = content.split('\n');
15
+ let currentKey = null;
16
+ let multilineValue = null;
17
+ let multilineIndent = 0;
18
+ let arrayKey = null;
19
+
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+
23
+ // Skip empty lines and comments (unless in multiline mode)
24
+ if (!multilineValue && (line.trim() === '' || line.trim().startsWith('#'))) {
25
+ continue;
26
+ }
27
+
28
+ // Handle multiline string continuation
29
+ if (multilineValue !== null) {
30
+ const stripped = line;
31
+ const indent = stripped.length - stripped.trimStart().length;
32
+ if (indent >= multilineIndent && stripped.trim() !== '') {
33
+ if (result[currentKey] === '') {
34
+ result[currentKey] = stripped.trimStart();
35
+ } else {
36
+ result[currentKey] += '\n' + stripped.trimStart();
37
+ }
38
+ continue;
39
+ } else {
40
+ // End of multiline
41
+ multilineValue = null;
42
+ // Fall through to process this line normally
43
+ if (line.trim() === '') continue;
44
+ }
45
+ }
46
+
47
+ // Handle array items
48
+ const arrayMatch = line.match(/^(\s+)- (.+)$/);
49
+ if (arrayMatch && arrayKey) {
50
+ if (!Array.isArray(result[arrayKey])) {
51
+ result[arrayKey] = [];
52
+ }
53
+ result[arrayKey].push(parseYAMLValue(arrayMatch[2].trim()));
54
+ continue;
55
+ }
56
+
57
+ // Handle key-value pairs
58
+ const kvMatch = line.match(/^([a-z_][a-z0-9_]*)\s*:\s*(.*)$/i);
59
+ if (kvMatch) {
60
+ const key = kvMatch[1];
61
+ const rawValue = kvMatch[2].trim();
62
+
63
+ // Check for multiline indicator
64
+ if (rawValue === '|' || rawValue === '>') {
65
+ currentKey = key;
66
+ multilineValue = '';
67
+ multilineIndent = 2; // expect at least 2-space indent
68
+ result[key] = '';
69
+ arrayKey = null;
70
+ continue;
71
+ }
72
+
73
+ // Check if next line starts an array
74
+ if (rawValue === '' && i + 1 < lines.length && lines[i + 1].match(/^\s+- /)) {
75
+ arrayKey = key;
76
+ result[key] = [];
77
+ continue;
78
+ }
79
+
80
+ result[key] = parseYAMLValue(rawValue);
81
+ currentKey = key;
82
+ arrayKey = null;
83
+ }
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ /**
90
+ * Parse a single YAML value (string, number, boolean, null).
91
+ */
92
+ function parseYAMLValue(raw) {
93
+ if (raw === '' || raw === 'null' || raw === '~') return null;
94
+ if (raw === 'true') return true;
95
+ if (raw === 'false') return false;
96
+
97
+ // Quoted strings
98
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
99
+ return raw.slice(1, -1);
100
+ }
101
+
102
+ // Numbers
103
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
104
+ if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
105
+
106
+ return raw;
107
+ }
108
+
109
+ class PluginLoader {
110
+ /**
111
+ * Create a new PluginLoader.
112
+ * @param {string} pluginsDir - Path to plugins directory (default: .loki/plugins)
113
+ * @param {string} [schemasDir] - Path to schemas directory
114
+ */
115
+ constructor(pluginsDir, schemasDir) {
116
+ this.pluginsDir = pluginsDir || '.loki/plugins';
117
+ this.validator = new PluginValidator(schemasDir);
118
+ this._watchers = [];
119
+ }
120
+
121
+ /**
122
+ * Discover plugin files in the plugins directory.
123
+ * @returns {string[]} Array of file paths
124
+ */
125
+ discover() {
126
+ if (!existsSync(this.pluginsDir)) {
127
+ return [];
128
+ }
129
+
130
+ try {
131
+ const stat = statSync(this.pluginsDir);
132
+ if (!stat.isDirectory()) {
133
+ return [];
134
+ }
135
+ } catch {
136
+ return [];
137
+ }
138
+
139
+ try {
140
+ const entries = readdirSync(this.pluginsDir);
141
+ const pluginFiles = [];
142
+
143
+ for (const entry of entries) {
144
+ const ext = extname(entry).toLowerCase();
145
+ if (ext === '.yaml' || ext === '.yml' || ext === '.json') {
146
+ pluginFiles.push(join(this.pluginsDir, entry));
147
+ }
148
+ }
149
+
150
+ return pluginFiles.sort();
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Parse a plugin file (YAML or JSON).
158
+ * @param {string} filePath - Path to the plugin file
159
+ * @returns {object|null} Parsed config or null on error
160
+ */
161
+ _parseFile(filePath) {
162
+ try {
163
+ const content = readFileSync(filePath, 'utf8');
164
+ const ext = extname(filePath).toLowerCase();
165
+
166
+ if (ext === '.json') {
167
+ return JSON.parse(content);
168
+ }
169
+
170
+ // YAML parsing
171
+ return parseSimpleYAML(content);
172
+ } catch (err) {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Load all plugins from the plugins directory.
179
+ * @returns {{ loaded: Array<{path: string, config: object}>, failed: Array<{path: string, errors: string[]}> }}
180
+ */
181
+ loadAll() {
182
+ const files = this.discover();
183
+ const loaded = [];
184
+ const failed = [];
185
+
186
+ for (const filePath of files) {
187
+ try {
188
+ const config = this._parseFile(filePath);
189
+
190
+ if (!config) {
191
+ failed.push({ path: filePath, errors: ['Failed to parse plugin file'] });
192
+ continue;
193
+ }
194
+
195
+ const result = this.validator.validate(config);
196
+
197
+ if (result.valid) {
198
+ loaded.push({ path: filePath, config });
199
+ } else {
200
+ failed.push({ path: filePath, errors: result.errors });
201
+ }
202
+ } catch (err) {
203
+ failed.push({ path: filePath, errors: [err.message || 'Unknown error'] });
204
+ }
205
+ }
206
+
207
+ return { loaded, failed };
208
+ }
209
+
210
+ /**
211
+ * Load a single plugin file.
212
+ * @param {string} filePath - Path to the plugin file
213
+ * @returns {{ config: object|null, errors: string[] }}
214
+ */
215
+ loadOne(filePath) {
216
+ const config = this._parseFile(filePath);
217
+
218
+ if (!config) {
219
+ return { config: null, errors: ['Failed to parse plugin file'] };
220
+ }
221
+
222
+ const result = this.validator.validate(config);
223
+
224
+ if (result.valid) {
225
+ return { config, errors: [] };
226
+ }
227
+
228
+ return { config: null, errors: result.errors };
229
+ }
230
+
231
+ /**
232
+ * Watch the plugins directory for changes.
233
+ * @param {function} callback - Called with (eventType, filePath) on changes
234
+ * @returns {function} Cleanup function to stop watching
235
+ */
236
+ watchForChanges(callback) {
237
+ if (!existsSync(this.pluginsDir)) {
238
+ return () => {};
239
+ }
240
+
241
+ try {
242
+ const watcher = watch(this.pluginsDir, (eventType, filename) => {
243
+ if (!filename) return;
244
+
245
+ const ext = extname(filename).toLowerCase();
246
+ if (ext === '.yaml' || ext === '.yml' || ext === '.json') {
247
+ const filePath = join(this.pluginsDir, filename);
248
+ callback(eventType, filePath);
249
+ }
250
+ });
251
+
252
+ this._watchers.push(watcher);
253
+
254
+ return () => {
255
+ watcher.close();
256
+ const idx = this._watchers.indexOf(watcher);
257
+ if (idx >= 0) this._watchers.splice(idx, 1);
258
+ };
259
+ } catch {
260
+ return () => {};
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Stop all file watchers.
266
+ */
267
+ stopWatching() {
268
+ for (const watcher of this._watchers) {
269
+ try { watcher.close(); } catch { /* ignore */ }
270
+ }
271
+ this._watchers = [];
272
+ }
273
+ }
274
+
275
+ module.exports = { PluginLoader, parseSimpleYAML, parseYAMLValue };
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ const { execFile } = require('child_process');
4
+
5
+ // In-memory registry for custom MCP tool plugins
6
+ const _registeredTools = new Map();
7
+
8
+ class MCPPlugin {
9
+ /**
10
+ * Register a custom MCP tool plugin.
11
+ *
12
+ * @param {object} pluginConfig - Validated MCP tool plugin config
13
+ * @returns {{ success: boolean, error?: string }}
14
+ */
15
+ static register(pluginConfig) {
16
+ if (!pluginConfig || pluginConfig.type !== 'mcp_tool') {
17
+ return { success: false, error: 'Invalid plugin config: type must be "mcp_tool"' };
18
+ }
19
+
20
+ const name = pluginConfig.name;
21
+
22
+ if (_registeredTools.has(name)) {
23
+ return { success: false, error: `MCP tool plugin "${name}" is already registered` };
24
+ }
25
+
26
+ const toolDef = {
27
+ name: name,
28
+ description: pluginConfig.description,
29
+ command: pluginConfig.command,
30
+ parameters: pluginConfig.parameters || [],
31
+ timeout_ms: pluginConfig.timeout_ms || 30000,
32
+ working_directory: pluginConfig.working_directory || 'project',
33
+ registered_at: new Date().toISOString(),
34
+ };
35
+
36
+ _registeredTools.set(name, toolDef);
37
+ return { success: true };
38
+ }
39
+
40
+ /**
41
+ * Unregister a custom MCP tool plugin.
42
+ *
43
+ * @param {string} pluginName - Name of the tool to remove
44
+ * @returns {{ success: boolean, error?: string }}
45
+ */
46
+ static unregister(pluginName) {
47
+ if (!_registeredTools.has(pluginName)) {
48
+ return { success: false, error: `MCP tool plugin "${pluginName}" is not registered` };
49
+ }
50
+
51
+ _registeredTools.delete(pluginName);
52
+ return { success: true };
53
+ }
54
+
55
+ /**
56
+ * Execute an MCP tool command with parameter substitution.
57
+ *
58
+ * @param {object} pluginConfig - The MCP tool plugin config
59
+ * @param {object} params - Input parameters
60
+ * @param {string} [projectDir] - Project directory for sandbox
61
+ * @returns {Promise<{ success: boolean, output: string, duration_ms: number }>}
62
+ */
63
+ static async execute(pluginConfig, params, projectDir) {
64
+ const command = pluginConfig.command;
65
+ const timeoutMs = pluginConfig.timeout_ms || 30000;
66
+ const startTime = Date.now();
67
+
68
+ if (!command) {
69
+ return {
70
+ success: false,
71
+ output: 'Error: no command specified for MCP tool',
72
+ duration_ms: 0,
73
+ };
74
+ }
75
+
76
+ // Substitute parameters into command with shell-safe quoting
77
+ let resolvedCommand = command;
78
+ if (params && typeof params === 'object') {
79
+ for (const [key, value] of Object.entries(params)) {
80
+ const safeValue = MCPPlugin._sanitizeValue(value);
81
+ resolvedCommand = resolvedCommand.replace(
82
+ new RegExp(`\\{\\{params\\.${key}\\}\\}`, 'g'),
83
+ safeValue
84
+ );
85
+ }
86
+ }
87
+
88
+ return new Promise((resolve) => {
89
+ const cwd = projectDir || process.cwd();
90
+
91
+ execFile('/bin/sh', ['-c', resolvedCommand], {
92
+ cwd,
93
+ timeout: timeoutMs,
94
+ maxBuffer: 1024 * 1024, // 1MB
95
+ env: { ...process.env, LOKI_MCP_TOOL: pluginConfig.name || 'unknown' },
96
+ }, (error, stdout, stderr) => {
97
+ const durationMs = Date.now() - startTime;
98
+ const output = (stdout || '') + (stderr ? '\n' + stderr : '');
99
+
100
+ if (error) {
101
+ if (error.killed) {
102
+ resolve({
103
+ success: false,
104
+ output: `Timeout: command exceeded ${timeoutMs}ms limit`,
105
+ duration_ms: durationMs,
106
+ });
107
+ } else {
108
+ resolve({
109
+ success: false,
110
+ output: output.trim() || error.message || 'Command failed',
111
+ duration_ms: durationMs,
112
+ });
113
+ }
114
+ } else {
115
+ resolve({
116
+ success: true,
117
+ output: output.trim(),
118
+ duration_ms: durationMs,
119
+ });
120
+ }
121
+ });
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Get the MCP tool definition in a format suitable for MCP protocol.
127
+ *
128
+ * @param {string} name - Tool name
129
+ * @returns {object|null} MCP-compatible tool definition
130
+ */
131
+ static getMCPDefinition(name) {
132
+ const tool = _registeredTools.get(name);
133
+ if (!tool) return null;
134
+
135
+ const inputSchema = {
136
+ type: 'object',
137
+ properties: {},
138
+ required: [],
139
+ };
140
+
141
+ for (const param of tool.parameters) {
142
+ inputSchema.properties[param.name] = {
143
+ type: param.type || 'string',
144
+ description: param.description || '',
145
+ };
146
+ if (param.default !== undefined) {
147
+ inputSchema.properties[param.name].default = param.default;
148
+ }
149
+ if (param.required) {
150
+ inputSchema.required.push(param.name);
151
+ }
152
+ }
153
+
154
+ return {
155
+ name: tool.name,
156
+ description: tool.description,
157
+ inputSchema,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * List all registered MCP tool plugins.
163
+ *
164
+ * @returns {object[]}
165
+ */
166
+ static listRegistered() {
167
+ return Array.from(_registeredTools.values());
168
+ }
169
+
170
+ /**
171
+ * Shell-safe value sanitization using POSIX single-quote escaping.
172
+ * Wraps value in single quotes with internal single quotes escaped.
173
+ *
174
+ * @param {*} value - The value to sanitize
175
+ * @returns {string} Shell-safe quoted string
176
+ */
177
+ static _sanitizeValue(value) {
178
+ const str = String(value);
179
+ return "'" + str.replace(/'/g, "'\\''") + "'";
180
+ }
181
+
182
+ /**
183
+ * Clear all registered tools (primarily for testing).
184
+ */
185
+ static _clearAll() {
186
+ _registeredTools.clear();
187
+ }
188
+ }
189
+
190
+ module.exports = { MCPPlugin };
@@ -0,0 +1,59 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Agent Plugin Schema",
4
+ "description": "Schema for custom agent plugins in Loki Mode",
5
+ "type": "object",
6
+ "required": ["type", "name", "description", "prompt_template"],
7
+ "properties": {
8
+ "type": {
9
+ "const": "agent"
10
+ },
11
+ "name": {
12
+ "type": "string",
13
+ "pattern": "^[a-z][a-z0-9-]{2,49}$",
14
+ "description": "Unique agent name (lowercase, 3-50 chars, alphanumeric and hyphens)"
15
+ },
16
+ "description": {
17
+ "type": "string",
18
+ "maxLength": 500,
19
+ "description": "Human-readable description of the agent"
20
+ },
21
+ "trigger": {
22
+ "type": "string",
23
+ "description": "Regex pattern for when to activate this agent"
24
+ },
25
+ "prompt_template": {
26
+ "type": "string",
27
+ "maxLength": 10000,
28
+ "description": "Prompt template for the agent (supports {{event.*}} variables)"
29
+ },
30
+ "quality_gate": {
31
+ "type": "boolean",
32
+ "default": false,
33
+ "description": "Whether this agent participates in quality gate reviews"
34
+ },
35
+ "category": {
36
+ "type": "string",
37
+ "enum": [
38
+ "engineering",
39
+ "operations",
40
+ "business",
41
+ "data",
42
+ "product",
43
+ "growth",
44
+ "review",
45
+ "orchestration",
46
+ "custom"
47
+ ],
48
+ "description": "Swarm category for this agent"
49
+ },
50
+ "capabilities": {
51
+ "type": "array",
52
+ "items": {
53
+ "type": "string"
54
+ },
55
+ "description": "List of capabilities this agent provides"
56
+ }
57
+ },
58
+ "additionalProperties": false
59
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Integration Plugin Schema",
4
+ "description": "Schema for custom integration plugins in Loki Mode",
5
+ "type": "object",
6
+ "required": ["type", "name", "description", "webhook_url", "events"],
7
+ "properties": {
8
+ "type": {
9
+ "const": "integration"
10
+ },
11
+ "name": {
12
+ "type": "string",
13
+ "pattern": "^[a-z][a-z0-9-]{2,49}$",
14
+ "description": "Unique integration name (lowercase, 3-50 chars, alphanumeric and hyphens)"
15
+ },
16
+ "description": {
17
+ "type": "string",
18
+ "maxLength": 500,
19
+ "description": "Human-readable description of the integration"
20
+ },
21
+ "webhook_url": {
22
+ "type": "string",
23
+ "format": "uri",
24
+ "description": "URL to POST event payloads to"
25
+ },
26
+ "events": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "string"
30
+ },
31
+ "minItems": 1,
32
+ "description": "List of event types to subscribe to"
33
+ },
34
+ "payload_template": {
35
+ "type": "string",
36
+ "maxLength": 5000,
37
+ "description": "JSON template for the webhook payload (supports {{event.*}} variables)"
38
+ },
39
+ "headers": {
40
+ "type": "object",
41
+ "additionalProperties": {
42
+ "type": "string"
43
+ },
44
+ "description": "Additional HTTP headers to send with webhook requests"
45
+ },
46
+ "timeout_ms": {
47
+ "type": "integer",
48
+ "minimum": 1000,
49
+ "maximum": 30000,
50
+ "default": 5000,
51
+ "description": "HTTP request timeout in milliseconds"
52
+ },
53
+ "retry_count": {
54
+ "type": "integer",
55
+ "minimum": 0,
56
+ "maximum": 3,
57
+ "default": 1,
58
+ "description": "Number of retry attempts on failure"
59
+ }
60
+ },
61
+ "additionalProperties": false
62
+ }
@@ -0,0 +1,73 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "MCP Tool Plugin Schema",
4
+ "description": "Schema for custom MCP tool plugins in Loki Mode",
5
+ "type": "object",
6
+ "required": ["type", "name", "description", "command"],
7
+ "properties": {
8
+ "type": {
9
+ "const": "mcp_tool"
10
+ },
11
+ "name": {
12
+ "type": "string",
13
+ "pattern": "^[a-z][a-z0-9-]{2,49}$",
14
+ "description": "Unique tool name (lowercase, 3-50 chars, alphanumeric and hyphens)"
15
+ },
16
+ "description": {
17
+ "type": "string",
18
+ "maxLength": 500,
19
+ "description": "Human-readable description of the MCP tool"
20
+ },
21
+ "command": {
22
+ "type": "string",
23
+ "maxLength": 1000,
24
+ "description": "Command to execute when tool is invoked"
25
+ },
26
+ "parameters": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "object",
30
+ "required": ["name", "type"],
31
+ "properties": {
32
+ "name": {
33
+ "type": "string",
34
+ "description": "Parameter name"
35
+ },
36
+ "type": {
37
+ "type": "string",
38
+ "enum": ["string", "number", "boolean"],
39
+ "description": "Parameter type"
40
+ },
41
+ "description": {
42
+ "type": "string",
43
+ "description": "Parameter description"
44
+ },
45
+ "required": {
46
+ "type": "boolean",
47
+ "default": false,
48
+ "description": "Whether the parameter is required"
49
+ },
50
+ "default": {
51
+ "description": "Default value for the parameter"
52
+ }
53
+ },
54
+ "additionalProperties": false
55
+ },
56
+ "description": "Tool input parameters"
57
+ },
58
+ "timeout_ms": {
59
+ "type": "integer",
60
+ "minimum": 1000,
61
+ "maximum": 120000,
62
+ "default": 30000,
63
+ "description": "Maximum execution time in milliseconds"
64
+ },
65
+ "working_directory": {
66
+ "type": "string",
67
+ "enum": ["project", "plugin"],
68
+ "default": "project",
69
+ "description": "Working directory for command execution"
70
+ }
71
+ },
72
+ "additionalProperties": false
73
+ }