ac-framework 1.9.7 → 1.9.9

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.
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * MCP Test Harness - Minimal server for async demo and testing
5
+ *
6
+ * SDK: @modelcontextprotocol/sdk@^1.27.1
7
+ * Transport: StdioServerTransport (stdio only)
8
+ *
9
+ * Features:
10
+ * - Real SDK behavior (not mocks)
11
+ * - In-memory storage for memory_* tools
12
+ * - Mocked collab_* tool responses
13
+ * - Intentional error injection for Demo 3
14
+ * - Lifecycle support for Demo 5
15
+ */
16
+
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { z } from 'zod';
20
+
21
+ const MEMORY_DB = new Map();
22
+ const COLLAB_SESSIONS = new Map();
23
+
24
+ let requestCount = 0;
25
+ let errorInjectionMode = null;
26
+
27
+ function setErrorInjection(mode) {
28
+ errorInjectionMode = mode;
29
+ console.error(`[harness] Error injection mode: ${mode || 'none'}`);
30
+ }
31
+
32
+ function handleError(errorType, defaultMessage) {
33
+ if (!errorInjectionMode || errorInjectionMode !== errorType) {
34
+ return null;
35
+ }
36
+ return {
37
+ content: [{ type: 'text', text: JSON.stringify({ error: defaultMessage }) }],
38
+ isError: true
39
+ };
40
+ }
41
+
42
+ function createSuccessResponse(data) {
43
+ return {
44
+ content: [{ type: 'text', text: JSON.stringify(data) }]
45
+ };
46
+ }
47
+
48
+ function createErrorResponse(message) {
49
+ return {
50
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
51
+ isError: true
52
+ };
53
+ }
54
+
55
+ class MCPTestHarness {
56
+ constructor() {
57
+ this.server = new McpServer({
58
+ name: 'acfm-test-harness',
59
+ version: '1.0.0',
60
+ });
61
+ this.setupTools();
62
+ }
63
+
64
+ setupTools() {
65
+ // ── memory_save ─────────────────────────────────────────────────────────
66
+ this.server.tool(
67
+ 'memory_save',
68
+ 'Save a memory observation (test harness)',
69
+ {
70
+ content: z.string().describe('Content to save'),
71
+ type: z.enum([
72
+ 'architectural_decision', 'bugfix_pattern', 'api_pattern',
73
+ 'performance_insight', 'security_fix', 'refactor_technique',
74
+ 'dependency_note', 'workaround', 'convention', 'context_boundary',
75
+ 'general_insight', 'session_summary'
76
+ ]).default('general_insight'),
77
+ importance: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
78
+ tags: z.array(z.string()).optional(),
79
+ projectPath: z.string().optional(),
80
+ changeName: z.string().optional(),
81
+ confidence: z.number().min(0).max(1).default(0.8)
82
+ },
83
+ async ({ content, type, importance, tags, projectPath, changeName, confidence }) => {
84
+ requestCount++;
85
+ const id = Date.now();
86
+
87
+ const validationError = handleError('validation', 'Missing required param: content');
88
+ if (validationError) return validationError;
89
+
90
+ const dbError = handleError('db_failure', 'Database write failed: disk full');
91
+ if (dbError) return dbError;
92
+
93
+ const memory = {
94
+ id,
95
+ content,
96
+ type,
97
+ importance,
98
+ tags: tags || [],
99
+ projectPath,
100
+ changeName,
101
+ confidence,
102
+ createdAt: new Date().toISOString()
103
+ };
104
+ MEMORY_DB.set(id.toString(), memory);
105
+
106
+ return createSuccessResponse({ success: true, id, operation: 'insert', revisionCount: 1 });
107
+ }
108
+ );
109
+
110
+ // ── memory_search ───────────────────────────────────────────────────────
111
+ this.server.tool(
112
+ 'memory_search',
113
+ 'Search memories using text query (test harness)',
114
+ {
115
+ query: z.string().describe('Search query'),
116
+ limit: z.number().int().positive().default(10),
117
+ type: z.enum([
118
+ 'architectural_decision', 'bugfix_pattern', 'api_pattern',
119
+ 'performance_insight', 'security_fix', 'refactor_technique',
120
+ 'dependency_note', 'workaround', 'convention', 'context_boundary',
121
+ 'general_insight', 'session_summary'
122
+ ]).optional(),
123
+ importance: z.enum(['critical', 'high', 'medium', 'low']).optional(),
124
+ projectPath: z.string().optional(),
125
+ minConfidence: z.number().min(0).max(1).default(0)
126
+ },
127
+ async ({ query, limit, type, importance, projectPath, minConfidence }) => {
128
+ requestCount++;
129
+
130
+ const validationError = handleError('validation', 'Invalid query parameter');
131
+ if (validationError) return validationError;
132
+
133
+ const results = [];
134
+ const queryLower = query.toLowerCase();
135
+ for (const [id, memory] of MEMORY_DB) {
136
+ if (memory.content.toLowerCase().includes(queryLower)) {
137
+ if (type && memory.type !== type) continue;
138
+ if (importance && memory.importance !== importance) continue;
139
+ if (projectPath && memory.projectPath !== projectPath) continue;
140
+ if (memory.confidence < minConfidence) continue;
141
+ results.push(memory);
142
+ if (results.length >= limit) break;
143
+ }
144
+ }
145
+
146
+ return createSuccessResponse({ query, count: results.length, results });
147
+ }
148
+ );
149
+
150
+ // ── memory_recall ───────────────────────────────────────────────────────
151
+ this.server.tool(
152
+ 'memory_recall',
153
+ 'Recall relevant context for a task (test harness)',
154
+ {
155
+ task: z.string().optional(),
156
+ projectPath: z.string().optional(),
157
+ changeName: z.string().optional(),
158
+ limit: z.number().int().positive().default(5),
159
+ days: z.number().int().positive().default(30)
160
+ },
161
+ async ({ task, projectPath, changeName, limit, days }) => {
162
+ requestCount++;
163
+
164
+ const dbError = handleError('db_failure', 'Database read failed: connection lost');
165
+ if (dbError) return dbError;
166
+
167
+ const memories = Array.from(MEMORY_DB.values())
168
+ .filter(m => {
169
+ if (task && !m.content.toLowerCase().includes(task.toLowerCase())) return false;
170
+ if (projectPath && m.projectPath !== projectPath) return false;
171
+ if (changeName && m.changeName !== changeName) return false;
172
+ return true;
173
+ })
174
+ .slice(0, limit);
175
+
176
+ return createSuccessResponse({ task: task || null, projectPath, count: memories.length, memories });
177
+ }
178
+ );
179
+
180
+ // ── memory_stats ────────────────────────────────────────────────────────
181
+ this.server.tool(
182
+ 'memory_stats',
183
+ 'Get memory system statistics (test harness)',
184
+ {
185
+ projectPath: z.string().optional(),
186
+ since: z.string().optional()
187
+ },
188
+ async ({ projectPath, since }) => {
189
+ requestCount++;
190
+
191
+ let memories = Array.from(MEMORY_DB.values());
192
+ if (projectPath) {
193
+ memories = memories.filter(m => m.projectPath === projectPath);
194
+ }
195
+ if (since) {
196
+ const sinceDate = new Date(since);
197
+ memories = memories.filter(m => new Date(m.createdAt) >= sinceDate);
198
+ }
199
+
200
+ const byType = {};
201
+ const byImportance = { critical: 0, high: 0, medium: 0, low: 0 };
202
+
203
+ for (const m of memories) {
204
+ byType[m.type] = (byType[m.type] || 0) + 1;
205
+ byImportance[m.importance]++;
206
+ }
207
+
208
+ return createSuccessResponse({
209
+ totalMemories: memories.length,
210
+ byType,
211
+ byImportance,
212
+ projectPath: projectPath || null,
213
+ since: since || null
214
+ });
215
+ }
216
+ );
217
+
218
+ // ── collab_start_session (MOCKED) ───────────────────────────────────────
219
+ this.server.tool(
220
+ 'collab_start_session',
221
+ 'Start a collaboration session (mocked in test harness)',
222
+ {
223
+ task: z.string().describe('Initial task'),
224
+ maxRounds: z.number().int().positive().default(3),
225
+ model: z.string().optional()
226
+ },
227
+ async ({ task, maxRounds, model }) => {
228
+ requestCount++;
229
+
230
+ const serverError = handleError('server_offline', 'Collab server not running');
231
+ if (serverError) return serverError;
232
+
233
+ const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
234
+ const state = {
235
+ sessionId,
236
+ task,
237
+ maxRounds,
238
+ model: model || 'opencode/default',
239
+ status: 'initialized',
240
+ round: 0,
241
+ events: []
242
+ };
243
+ COLLAB_SESSIONS.set(sessionId, state);
244
+
245
+ return createSuccessResponse({
246
+ success: true,
247
+ sessionId,
248
+ status: state.status,
249
+ model: state.model,
250
+ tmuxSessionName: null,
251
+ attachCommand: null
252
+ });
253
+ }
254
+ );
255
+
256
+ // ── collab_step (MOCKED) ────────────────────────────────────────────────
257
+ this.server.tool(
258
+ 'collab_step',
259
+ 'Execute a single collaboration step (mocked)',
260
+ {
261
+ sessionId: z.string().describe('Session ID'),
262
+ role: z.enum(['planner', 'critic', 'coder', 'reviewer']).describe('Role to execute')
263
+ },
264
+ async ({ sessionId, role }) => {
265
+ requestCount++;
266
+
267
+ const session = COLLAB_SESSIONS.get(sessionId);
268
+ if (!session) {
269
+ return createErrorResponse(`Session not found: ${sessionId}`);
270
+ }
271
+
272
+ session.round++;
273
+ const event = {
274
+ role,
275
+ timestamp: new Date().toISOString(),
276
+ status: 'completed',
277
+ output: `[MOCK] ${role} completed round ${session.round}`
278
+ };
279
+ session.events.push(event);
280
+
281
+ return createSuccessResponse({
282
+ sessionId,
283
+ round: session.round,
284
+ event,
285
+ status: session.round >= session.maxRounds ? 'complete' : 'in_progress'
286
+ });
287
+ }
288
+ );
289
+
290
+ // ── collab_get_result (MOCKED) ──────────────────────────────────────────
291
+ this.server.tool(
292
+ 'collab_get_result',
293
+ 'Get collaboration session result (mocked)',
294
+ {
295
+ sessionId: z.string().describe('Session ID')
296
+ },
297
+ async ({ sessionId }) => {
298
+ requestCount++;
299
+
300
+ const session = COLLAB_SESSIONS.get(sessionId);
301
+ if (!session) {
302
+ return createErrorResponse(`Session not found: ${sessionId}`);
303
+ }
304
+
305
+ return createSuccessResponse({
306
+ sessionId,
307
+ status: session.status,
308
+ rounds: session.round,
309
+ events: session.events,
310
+ summary: `[MOCK] Completed ${session.round} rounds for: ${session.task}`
311
+ });
312
+ }
313
+ );
314
+
315
+ // ── collab_status (MOCKED) ─────────────────────────────────────────────
316
+ this.server.tool(
317
+ 'collab_status',
318
+ 'Get collaboration system status (mocked)',
319
+ {
320
+ sessionId: z.string().optional()
321
+ },
322
+ async ({ sessionId }) => {
323
+ requestCount++;
324
+
325
+ if (sessionId) {
326
+ const session = COLLAB_SESSIONS.get(sessionId);
327
+ if (!session) {
328
+ return createErrorResponse(`Session not found: ${sessionId}`);
329
+ }
330
+ return createSuccessResponse({
331
+ sessionId,
332
+ status: session.status,
333
+ round: session.round,
334
+ maxRounds: session.maxRounds
335
+ });
336
+ }
337
+
338
+ return createSuccessResponse({
339
+ activeSessions: COLLAB_SESSIONS.size,
340
+ totalRequests: requestCount,
341
+ uptime: process.uptime()
342
+ });
343
+ }
344
+ );
345
+
346
+ // ── harness_control (DEBUG) ─────────────────────────────────────────────
347
+ this.server.tool(
348
+ 'harness_control',
349
+ 'Control test harness behavior (debug only)',
350
+ {
351
+ action: z.enum(['reset', 'inject_error', 'clear_errors', 'status']).describe('Action to perform'),
352
+ errorType: z.string().optional().describe('Error type: validation, db_failure, server_offline')
353
+ },
354
+ async ({ action, errorType }) => {
355
+ switch (action) {
356
+ case 'reset':
357
+ MEMORY_DB.clear();
358
+ COLLAB_SESSIONS.clear();
359
+ requestCount = 0;
360
+ errorInjectionMode = null;
361
+ return createSuccessResponse({ message: 'Harness reset complete' });
362
+
363
+ case 'inject_error':
364
+ setErrorInjection(errorType);
365
+ return createSuccessResponse({ message: `Error injection set: ${errorType}` });
366
+
367
+ case 'clear_errors':
368
+ setErrorInjection(null);
369
+ return createSuccessResponse({ message: 'Error injection cleared' });
370
+
371
+ case 'status':
372
+ return createSuccessResponse({
373
+ memories: MEMORY_DB.size,
374
+ sessions: COLLAB_SESSIONS.size,
375
+ requestCount,
376
+ errorInjectionMode: errorInjectionMode || 'none'
377
+ });
378
+
379
+ default:
380
+ return createErrorResponse(`Unknown action: ${action}`);
381
+ }
382
+ }
383
+ );
384
+ }
385
+
386
+ async run() {
387
+ const transport = new StdioServerTransport();
388
+ await this.server.connect(transport);
389
+ console.error('[harness] Test harness ready on stdio');
390
+ console.error('[harness] Available tools: memory_save, memory_search, memory_recall, memory_stats');
391
+ console.error('[harness] Collab tools (mocked): collab_start_session, collab_step, collab_get_result, collab_status');
392
+ console.error('[harness] Debug tool: harness_control (reset, inject_error, clear_errors, status)');
393
+ }
394
+ }
395
+
396
+ const harness = new MCPTestHarness();
397
+ harness.run().catch(err => {
398
+ console.error('[harness] Fatal error:', err.message);
399
+ process.exit(1);
400
+ });
401
+
402
+ process.on('SIGTERM', () => {
403
+ console.error('[harness] Received SIGTERM, shutting down gracefully');
404
+ process.exit(0);
405
+ });
406
+
407
+ process.on('SIGINT', () => {
408
+ console.error('[harness] Received SIGINT, shutting down');
409
+ process.exit(0);
410
+ });
@@ -17,6 +17,13 @@ function run(command, args, options = {}) {
17
17
  });
18
18
  }
19
19
 
20
+ function runInstallCommand(command) {
21
+ if (platform() === 'win32') {
22
+ return run('cmd.exe', ['/c', command], { stdio: 'inherit' });
23
+ }
24
+ return run('bash', ['-lc', command], { stdio: 'inherit' });
25
+ }
26
+
20
27
  export function hasCommand(command) {
21
28
  return Boolean(resolveCommandPath(command));
22
29
  }
@@ -78,6 +85,29 @@ function resolveTmuxInstallCommand() {
78
85
  return null;
79
86
  }
80
87
 
88
+ function resolveZellijInstallCommand() {
89
+ if (platform() === 'darwin') {
90
+ if (hasCommand('brew')) return 'brew install zellij';
91
+ return null;
92
+ }
93
+
94
+ if (platform() === 'linux') {
95
+ if (hasCommand('apt-get')) return 'sudo apt-get update && sudo apt-get install -y zellij';
96
+ if (hasCommand('dnf')) return 'sudo dnf install -y zellij';
97
+ if (hasCommand('yum')) return 'sudo yum install -y zellij';
98
+ if (hasCommand('pacman')) return 'sudo pacman -S --noconfirm zellij';
99
+ if (hasCommand('zypper')) return 'sudo zypper --non-interactive install zellij';
100
+ }
101
+
102
+ if (platform() === 'win32') {
103
+ if (hasCommand('winget')) return 'winget install --id zellij-org.zellij -e';
104
+ if (hasCommand('choco')) return 'choco install zellij -y';
105
+ if (hasCommand('scoop')) return 'scoop install zellij';
106
+ }
107
+
108
+ return null;
109
+ }
110
+
81
111
  export function installTmux() {
82
112
  if (hasCommand('tmux')) {
83
113
  return { success: true, installed: false, message: 'tmux already installed' };
@@ -92,7 +122,7 @@ export function installTmux() {
92
122
  };
93
123
  }
94
124
 
95
- const result = run('bash', ['-lc', installCommand], { stdio: 'inherit' });
125
+ const result = runInstallCommand(installCommand);
96
126
  if (result.status !== 0) {
97
127
  return { success: false, installed: false, message: 'tmux installation command failed' };
98
128
  }
@@ -106,12 +136,50 @@ export function installTmux() {
106
136
  };
107
137
  }
108
138
 
109
- export function ensureCollabDependencies() {
139
+ export function installZellij() {
140
+ if (hasCommand('zellij')) {
141
+ return { success: true, installed: false, message: 'zellij already installed' };
142
+ }
143
+
144
+ const installCommand = resolveZellijInstallCommand();
145
+ if (!installCommand) {
146
+ return {
147
+ success: false,
148
+ installed: false,
149
+ message: 'No supported package manager detected for automatic zellij installation',
150
+ };
151
+ }
152
+
153
+ const result = runInstallCommand(installCommand);
154
+ if (result.status !== 0) {
155
+ return { success: false, installed: false, message: 'zellij installation command failed' };
156
+ }
157
+
158
+ return {
159
+ success: hasCommand('zellij'),
160
+ installed: true,
161
+ message: hasCommand('zellij')
162
+ ? 'zellij installed successfully'
163
+ : 'zellij installer finished but binary is not available in PATH yet',
164
+ };
165
+ }
166
+
167
+ export function ensureCollabDependencies(options = {}) {
168
+ const installTmuxEnabled = options.installTmux ?? true;
169
+ const installZellijEnabled = options.installZellij ?? true;
110
170
  const opencode = installOpenCode();
111
- const tmux = installTmux();
171
+ const tmux = installTmuxEnabled
172
+ ? installTmux()
173
+ : { success: hasCommand('tmux'), installed: false, message: hasCommand('tmux') ? 'tmux already installed' : 'tmux installation skipped' };
174
+ const zellij = installZellijEnabled
175
+ ? installZellij()
176
+ : { success: hasCommand('zellij'), installed: false, message: hasCommand('zellij') ? 'zellij already installed' : 'zellij installation skipped' };
177
+
178
+ const hasMultiplexer = tmux.success || zellij.success;
112
179
  return {
113
180
  opencode,
114
181
  tmux,
115
- success: opencode.success && tmux.success,
182
+ zellij,
183
+ success: opencode.success && hasMultiplexer,
116
184
  };
117
185
  }