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,153 @@
1
+ 'use strict';
2
+
3
+ const { execFile } = require('child_process');
4
+ const { join } = require('path');
5
+
6
+ // In-memory registry for custom gate plugins
7
+ const _registeredGates = new Map();
8
+
9
+ class GatePlugin {
10
+ /**
11
+ * Register a custom quality gate plugin.
12
+ *
13
+ * @param {object} pluginConfig - Validated quality gate plugin config
14
+ * @returns {{ success: boolean, error?: string }}
15
+ */
16
+ static register(pluginConfig) {
17
+ if (!pluginConfig || pluginConfig.type !== 'quality_gate') {
18
+ return { success: false, error: 'Invalid plugin config: type must be "quality_gate"' };
19
+ }
20
+
21
+ const name = pluginConfig.name;
22
+
23
+ if (_registeredGates.has(name)) {
24
+ return { success: false, error: `Gate plugin "${name}" is already registered` };
25
+ }
26
+
27
+ const gateDef = {
28
+ name: name,
29
+ description: pluginConfig.description,
30
+ phase: pluginConfig.phase || 'pre-commit',
31
+ command: pluginConfig.command,
32
+ timeout_ms: pluginConfig.timeout_ms || 30000,
33
+ blocking: pluginConfig.blocking !== undefined ? pluginConfig.blocking : true,
34
+ severity: pluginConfig.severity || 'high',
35
+ registered_at: new Date().toISOString(),
36
+ };
37
+
38
+ _registeredGates.set(name, gateDef);
39
+ return { success: true };
40
+ }
41
+
42
+ /**
43
+ * Unregister a custom quality gate plugin.
44
+ *
45
+ * @param {string} pluginName - Name of the gate plugin to remove
46
+ * @returns {{ success: boolean, error?: string }}
47
+ */
48
+ static unregister(pluginName) {
49
+ if (!_registeredGates.has(pluginName)) {
50
+ return { success: false, error: `Gate plugin "${pluginName}" is not registered` };
51
+ }
52
+
53
+ _registeredGates.delete(pluginName);
54
+ return { success: true };
55
+ }
56
+
57
+ /**
58
+ * Execute a quality gate command.
59
+ *
60
+ * @param {object} pluginConfig - The gate plugin config
61
+ * @param {string} projectDir - Project directory to run the command in
62
+ * @returns {Promise<{ passed: boolean, output: string, duration_ms: number }>}
63
+ */
64
+ static async execute(pluginConfig, projectDir) {
65
+ const command = pluginConfig.command;
66
+ const timeoutMs = pluginConfig.timeout_ms || 30000;
67
+ const startTime = Date.now();
68
+
69
+ if (!command) {
70
+ return {
71
+ passed: false,
72
+ output: 'Error: no command specified for quality gate',
73
+ duration_ms: 0,
74
+ };
75
+ }
76
+
77
+ return new Promise((resolve) => {
78
+ const cwd = projectDir || process.cwd();
79
+
80
+ // Split command into executable and args
81
+ // Use shell execution for simple commands
82
+ const child = execFile('/bin/sh', ['-c', command], {
83
+ cwd,
84
+ timeout: timeoutMs,
85
+ maxBuffer: 1024 * 1024, // 1MB
86
+ env: { ...process.env, LOKI_GATE: pluginConfig.name || 'unknown' },
87
+ }, (error, stdout, stderr) => {
88
+ const durationMs = Date.now() - startTime;
89
+ const output = (stdout || '') + (stderr ? '\n' + stderr : '');
90
+
91
+ if (error) {
92
+ if (error.killed || error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
93
+ resolve({
94
+ passed: false,
95
+ output: `Timeout: command exceeded ${timeoutMs}ms limit`,
96
+ duration_ms: durationMs,
97
+ });
98
+ } else {
99
+ resolve({
100
+ passed: false,
101
+ output: output.trim() || error.message || 'Command failed',
102
+ duration_ms: durationMs,
103
+ });
104
+ }
105
+ } else {
106
+ resolve({
107
+ passed: true,
108
+ output: output.trim(),
109
+ duration_ms: durationMs,
110
+ });
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Get all gates registered for a specific phase.
118
+ *
119
+ * @param {string} phase - SDLC phase
120
+ * @returns {object[]} Array of gate definitions
121
+ */
122
+ static getByPhase(phase) {
123
+ return Array.from(_registeredGates.values()).filter(g => g.phase === phase);
124
+ }
125
+
126
+ /**
127
+ * List all registered gate plugins.
128
+ *
129
+ * @returns {object[]} Array of gate definitions
130
+ */
131
+ static listRegistered() {
132
+ return Array.from(_registeredGates.values());
133
+ }
134
+
135
+ /**
136
+ * Check if a gate is registered.
137
+ *
138
+ * @param {string} name - Gate name
139
+ * @returns {boolean}
140
+ */
141
+ static isRegistered(name) {
142
+ return _registeredGates.has(name);
143
+ }
144
+
145
+ /**
146
+ * Clear all registered gates (primarily for testing).
147
+ */
148
+ static _clearAll() {
149
+ _registeredGates.clear();
150
+ }
151
+ }
152
+
153
+ module.exports = { GatePlugin };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const { PluginValidator, BUILTIN_AGENT_NAMES, VALID_PLUGIN_TYPES } = require('./validator');
4
+ const { PluginLoader, parseSimpleYAML } = require('./loader');
5
+ const { AgentPlugin } = require('./agent-plugin');
6
+ const { GatePlugin } = require('./gate-plugin');
7
+ const { IntegrationPlugin } = require('./integration-plugin');
8
+ const { MCPPlugin } = require('./mcp-plugin');
9
+
10
+ // Plugin type to handler mapping
11
+ const PLUGIN_HANDLERS = {
12
+ agent: AgentPlugin,
13
+ quality_gate: GatePlugin,
14
+ integration: IntegrationPlugin,
15
+ mcp_tool: MCPPlugin,
16
+ };
17
+
18
+ /**
19
+ * Initialize the plugin system: discover, load, validate, and register all plugins.
20
+ *
21
+ * @param {string} [pluginsDir='.loki/plugins'] - Path to plugins directory
22
+ * @param {string} [schemasDir] - Path to schemas directory (defaults to built-in schemas)
23
+ * @param {object} [options] - Additional options
24
+ * @param {object} [options.agentRegistry] - External agent registry to register agents into
25
+ * @param {boolean} [options.watch=false] - Watch for file changes
26
+ * @returns {{ loaded: number, failed: number, details: { loaded: object[], failed: object[] } }}
27
+ */
28
+ function initializePlugins(pluginsDir, schemasDir, options) {
29
+ const opts = options || {};
30
+ const loader = new PluginLoader(pluginsDir, schemasDir);
31
+ const { loaded, failed } = loader.loadAll();
32
+
33
+ const registered = [];
34
+ const registrationErrors = [];
35
+
36
+ for (const plugin of loaded) {
37
+ const handler = PLUGIN_HANDLERS[plugin.config.type];
38
+ if (!handler) {
39
+ registrationErrors.push({
40
+ path: plugin.path,
41
+ errors: [`No handler for plugin type: ${plugin.config.type}`],
42
+ });
43
+ continue;
44
+ }
45
+
46
+ let result;
47
+ if (plugin.config.type === 'agent') {
48
+ result = handler.register(plugin.config, opts.agentRegistry);
49
+ } else {
50
+ result = handler.register(plugin.config);
51
+ }
52
+
53
+ if (result.success) {
54
+ registered.push(plugin);
55
+ } else {
56
+ registrationErrors.push({
57
+ path: plugin.path,
58
+ errors: [result.error],
59
+ });
60
+ }
61
+ }
62
+
63
+ // Set up file watching if requested
64
+ let stopWatching = null;
65
+ if (opts.watch) {
66
+ stopWatching = loader.watchForChanges((eventType, filePath) => {
67
+ const fs = require('fs');
68
+ const path = require('path');
69
+
70
+ // Handle file deletion
71
+ if (!fs.existsSync(filePath)) {
72
+ const name = path.basename(filePath, path.extname(filePath));
73
+ for (const handler of Object.values(PLUGIN_HANDLERS)) {
74
+ if (handler.unregister) handler.unregister(name);
75
+ }
76
+ return;
77
+ }
78
+
79
+ const { config, errors } = loader.loadOne(filePath);
80
+ if (config) {
81
+ const handler = PLUGIN_HANDLERS[config.type];
82
+ if (handler) {
83
+ // Only unregister AFTER successful load and validation
84
+ if (handler.unregister) handler.unregister(config.name);
85
+ handler.register(config, opts.agentRegistry);
86
+ }
87
+ }
88
+ // If loadOne fails, keep old plugin registered (fail-safe)
89
+ });
90
+ }
91
+
92
+ const allFailed = [...failed, ...registrationErrors];
93
+
94
+ return {
95
+ loaded: registered.length,
96
+ failed: allFailed.length,
97
+ details: {
98
+ loaded: registered,
99
+ failed: allFailed,
100
+ },
101
+ stopWatching: stopWatching || (() => {}),
102
+ };
103
+ }
104
+
105
+ module.exports = {
106
+ PluginValidator,
107
+ PluginLoader,
108
+ AgentPlugin,
109
+ GatePlugin,
110
+ IntegrationPlugin,
111
+ MCPPlugin,
112
+ initializePlugins,
113
+ parseSimpleYAML,
114
+ BUILTIN_AGENT_NAMES,
115
+ VALID_PLUGIN_TYPES,
116
+ };
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const { request } = require('https');
4
+ const { request: httpRequest } = require('http');
5
+
6
+ // In-memory registry for custom integration plugins
7
+ const _registeredIntegrations = new Map();
8
+
9
+ class IntegrationPlugin {
10
+ /**
11
+ * Register a custom integration plugin.
12
+ *
13
+ * @param {object} pluginConfig - Validated integration plugin config
14
+ * @returns {{ success: boolean, error?: string }}
15
+ */
16
+ static register(pluginConfig) {
17
+ if (!pluginConfig || pluginConfig.type !== 'integration') {
18
+ return { success: false, error: 'Invalid plugin config: type must be "integration"' };
19
+ }
20
+
21
+ const name = pluginConfig.name;
22
+
23
+ if (_registeredIntegrations.has(name)) {
24
+ return { success: false, error: `Integration plugin "${name}" is already registered` };
25
+ }
26
+
27
+ const intDef = {
28
+ name: name,
29
+ description: pluginConfig.description,
30
+ webhook_url: pluginConfig.webhook_url,
31
+ events: pluginConfig.events || [],
32
+ payload_template: pluginConfig.payload_template || '{"event": "{{event.type}}", "message": "{{event.message}}"}',
33
+ headers: pluginConfig.headers || {},
34
+ timeout_ms: pluginConfig.timeout_ms || 5000,
35
+ retry_count: pluginConfig.retry_count || 1,
36
+ registered_at: new Date().toISOString(),
37
+ };
38
+
39
+ _registeredIntegrations.set(name, intDef);
40
+ return { success: true };
41
+ }
42
+
43
+ /**
44
+ * Unregister a custom integration plugin.
45
+ *
46
+ * @param {string} pluginName - Name of the integration to remove
47
+ * @returns {{ success: boolean, error?: string }}
48
+ */
49
+ static unregister(pluginName) {
50
+ if (!_registeredIntegrations.has(pluginName)) {
51
+ return { success: false, error: `Integration plugin "${pluginName}" is not registered` };
52
+ }
53
+
54
+ _registeredIntegrations.delete(pluginName);
55
+ return { success: true };
56
+ }
57
+
58
+ /**
59
+ * Render a template string with event data.
60
+ * Replaces {{event.field}} patterns with actual event values.
61
+ *
62
+ * @param {string} template - Template string
63
+ * @param {object} event - Event data
64
+ * @returns {string} Rendered string
65
+ */
66
+ static renderTemplate(template, event) {
67
+ if (!template || typeof template !== 'string') return template || '';
68
+
69
+ return template.replace(/\{\{event\.(\w+(?:\.\w+)*)\}\}/g, (match, path) => {
70
+ const parts = path.split('.');
71
+ let value = event;
72
+ for (const part of parts) {
73
+ if (value === null || value === undefined) return '';
74
+ value = value[part];
75
+ }
76
+ if (value === undefined || value === null) return '';
77
+ if (typeof value === 'object') return JSON.stringify(value);
78
+ // JSON-safe escape: handles quotes, backslashes, control chars
79
+ return JSON.stringify(String(value)).slice(1, -1);
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Handle an event by sending it to the integration webhook.
85
+ * Fire-and-forget with timeout.
86
+ *
87
+ * @param {object} pluginConfig - The integration plugin config
88
+ * @param {object} event - The event data
89
+ * @returns {Promise<{ sent: boolean, status?: number, error?: string }>}
90
+ */
91
+ static async handleEvent(pluginConfig, event) {
92
+ const webhookUrl = pluginConfig.webhook_url;
93
+ const timeoutMs = pluginConfig.timeout_ms || 5000;
94
+ const headers = { ...pluginConfig.headers, 'Content-Type': 'application/json' };
95
+
96
+ // Render payload template
97
+ const payload = IntegrationPlugin.renderTemplate(
98
+ pluginConfig.payload_template || '{"event": "{{event.type}}", "message": "{{event.message}}"}',
99
+ event
100
+ );
101
+
102
+ return new Promise((resolve) => {
103
+ try {
104
+ const url = new URL(webhookUrl);
105
+ const isHttps = url.protocol === 'https:';
106
+ const reqFn = isHttps ? request : httpRequest;
107
+
108
+ const options = {
109
+ hostname: url.hostname,
110
+ port: url.port || (isHttps ? 443 : 80),
111
+ path: url.pathname + url.search,
112
+ method: 'POST',
113
+ headers: {
114
+ ...headers,
115
+ 'Content-Length': Buffer.byteLength(payload),
116
+ },
117
+ timeout: timeoutMs,
118
+ };
119
+
120
+ const req = reqFn(options, (res) => {
121
+ let body = '';
122
+ res.on('data', (chunk) => { body += chunk; });
123
+ res.on('end', () => {
124
+ resolve({ sent: true, status: res.statusCode });
125
+ });
126
+ });
127
+
128
+ req.on('error', (err) => {
129
+ resolve({ sent: false, error: err.message });
130
+ });
131
+
132
+ req.on('timeout', () => {
133
+ req.destroy();
134
+ resolve({ sent: false, error: `Timeout after ${timeoutMs}ms` });
135
+ });
136
+
137
+ req.write(payload);
138
+ req.end();
139
+ } catch (err) {
140
+ resolve({ sent: false, error: err.message });
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Get integrations subscribed to a specific event type.
147
+ *
148
+ * @param {string} eventType - The event type
149
+ * @returns {object[]} Matching integration definitions
150
+ */
151
+ static getByEvent(eventType) {
152
+ return Array.from(_registeredIntegrations.values()).filter(
153
+ i => i.events.includes(eventType) || i.events.includes('*')
154
+ );
155
+ }
156
+
157
+ /**
158
+ * List all registered integration plugins.
159
+ *
160
+ * @returns {object[]}
161
+ */
162
+ static listRegistered() {
163
+ return Array.from(_registeredIntegrations.values());
164
+ }
165
+
166
+ /**
167
+ * Clear all registered integrations (primarily for testing).
168
+ */
169
+ static _clearAll() {
170
+ _registeredIntegrations.clear();
171
+ }
172
+ }
173
+
174
+ module.exports = { IntegrationPlugin };