opencode-pilot 0.11.2 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -146,7 +146,17 @@ export async function pollOnce(options = {}) {
146
146
  const repoKey = repoKeys.length > 0 ? repoKeys[0] : null;
147
147
  const repoConfig = repoKey ? getRepoConfig(repoKey) : {};
148
148
 
149
- const readiness = evaluateReadiness(item, repoConfig);
149
+ // Merge source-level readiness config with repo config
150
+ // Source readiness takes precedence
151
+ const readinessConfig = {
152
+ ...repoConfig,
153
+ readiness: {
154
+ ...repoConfig.readiness,
155
+ ...source.readiness,
156
+ },
157
+ };
158
+
159
+ const readiness = evaluateReadiness(item, readinessConfig);
150
160
  debug(`Item ${item.id}: ready=${readiness.ready}, reason=${readiness.reason || 'none'}`);
151
161
  return {
152
162
  ...item,
package/service/poller.js CHANGED
@@ -67,15 +67,33 @@ export function applyMappings(item, mappings) {
67
67
 
68
68
  /**
69
69
  * Get tool configuration from a source
70
+ * Supports both MCP tools and CLI commands.
71
+ *
70
72
  * @param {object} source - Source configuration from config.yaml
71
- * @returns {object} Tool configuration
73
+ * @returns {object} Tool configuration with type indicator
72
74
  */
73
75
  export function getToolConfig(source) {
74
- if (!source.tool || !source.tool.mcp || !source.tool.name) {
75
- throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration (requires tool.mcp and tool.name)`);
76
+ if (!source.tool) {
77
+ throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration`);
78
+ }
79
+
80
+ // CLI command support
81
+ if (source.tool.command) {
82
+ return {
83
+ type: 'cli',
84
+ command: source.tool.command,
85
+ args: source.args || {},
86
+ idTemplate: source.item?.id || null,
87
+ };
88
+ }
89
+
90
+ // MCP tool support (existing behavior)
91
+ if (!source.tool.mcp || !source.tool.name) {
92
+ throw new Error(`Source '${source.name || 'unknown'}' missing tool configuration (requires tool.mcp and tool.name, or tool.command)`);
76
93
  }
77
94
 
78
95
  return {
96
+ type: 'mcp',
79
97
  mcpServer: source.tool.mcp,
80
98
  toolName: source.tool.name,
81
99
  args: source.args || {},
@@ -214,7 +232,84 @@ function createTimeout(ms, operation) {
214
232
  }
215
233
 
216
234
  /**
217
- * Poll a source using MCP tools
235
+ * Execute a CLI command and return parsed JSON output
236
+ *
237
+ * @param {string|string[]} command - Command to execute (string or array)
238
+ * @param {object} args - Arguments to substitute into command
239
+ * @param {number} timeout - Timeout in ms
240
+ * @returns {Promise<string>} Command output
241
+ */
242
+ async function executeCliCommand(command, args, timeout) {
243
+ const { exec } = await import('child_process');
244
+ const { promisify } = await import('util');
245
+ const execAsync = promisify(exec);
246
+
247
+ // Build command string
248
+ let cmdStr;
249
+ if (Array.isArray(command)) {
250
+ // Substitute args into command array
251
+ const expandedCmd = command.map(part => {
252
+ if (typeof part === 'string' && part.startsWith('$')) {
253
+ const argName = part.slice(1);
254
+ return args[argName] !== undefined ? String(args[argName]) : part;
255
+ }
256
+ return part;
257
+ });
258
+ // Quote parts with spaces
259
+ cmdStr = expandedCmd.map(p => p.includes(' ') ? `"${p}"` : p).join(' ');
260
+ } else {
261
+ // String command - substitute ${argName} patterns
262
+ cmdStr = command.replace(/\$\{(\w+)\}/g, (_, name) => {
263
+ return args[name] !== undefined ? String(args[name]) : '';
264
+ });
265
+ }
266
+
267
+ const { stdout } = await Promise.race([
268
+ execAsync(cmdStr, { env: { ...process.env } }),
269
+ createTimeout(timeout, `CLI command: ${cmdStr.slice(0, 50)}...`),
270
+ ]);
271
+
272
+ return stdout;
273
+ }
274
+
275
+ /**
276
+ * Poll a source using CLI command
277
+ *
278
+ * @param {object} source - Source configuration from config.yaml
279
+ * @param {object} toolConfig - Tool config from getToolConfig()
280
+ * @param {object} [options] - Additional options
281
+ * @param {number} [options.timeout] - Timeout in ms (default: 30000)
282
+ * @param {object} [options.toolProviderConfig] - Tool provider config (response_key, mappings)
283
+ * @returns {Promise<Array>} Array of items from the source with IDs and mappings applied
284
+ */
285
+ async function pollCliSource(source, toolConfig, options = {}) {
286
+ const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
287
+ const toolProviderConfig = options.toolProviderConfig || {};
288
+ const responseKey = toolProviderConfig.response_key;
289
+ const mappings = toolProviderConfig.mappings || null;
290
+
291
+ try {
292
+ const output = await executeCliCommand(toolConfig.command, toolConfig.args, timeout);
293
+
294
+ if (!output || !output.trim()) return [];
295
+
296
+ const rawItems = parseJsonArray(output, source.name, responseKey);
297
+
298
+ // Apply field mappings before transforming
299
+ const mappedItems = mappings
300
+ ? rawItems.map(item => applyMappings(item, mappings))
301
+ : rawItems;
302
+
303
+ // Transform items (add IDs)
304
+ return transformItems(mappedItems, toolConfig.idTemplate);
305
+ } catch (err) {
306
+ console.error(`[poller] CLI command failed for ${source.name}: ${err.message}`);
307
+ return [];
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Poll a source using MCP tools or CLI commands
218
313
  *
219
314
  * @param {object} source - Source configuration from config.yaml
220
315
  * @param {object} [options] - Additional options
@@ -225,6 +320,13 @@ function createTimeout(ms, operation) {
225
320
  */
226
321
  export async function pollGenericSource(source, options = {}) {
227
322
  const toolConfig = getToolConfig(source);
323
+
324
+ // Route to CLI handler if command-based
325
+ if (toolConfig.type === 'cli') {
326
+ return pollCliSource(source, toolConfig, options);
327
+ }
328
+
329
+ // MCP-based polling (existing behavior)
228
330
  const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
229
331
  const toolProviderConfig = options.toolProviderConfig || {};
230
332
  const responseKey = toolProviderConfig.response_key;
@@ -134,6 +134,46 @@ export function checkDependencies(issue, config) {
134
134
  return { ready: true };
135
135
  }
136
136
 
137
+ /**
138
+ * Check if item fields match required values
139
+ *
140
+ * Generic field-based readiness check. Configured via readiness.fields in config.
141
+ * All specified fields must match their required values for the item to be ready.
142
+ *
143
+ * Example config:
144
+ * readiness:
145
+ * fields:
146
+ * has_notes: true
147
+ * type: "meeting"
148
+ *
149
+ * @param {object} item - Item with fields to check
150
+ * @param {object} config - Config with optional readiness.fields
151
+ * @returns {object} { ready: boolean, reason?: string }
152
+ */
153
+ export function checkFields(item, config) {
154
+ const readinessConfig = config.readiness || {};
155
+ const fieldsConfig = readinessConfig.fields || {};
156
+
157
+ // No fields configured - skip check
158
+ if (Object.keys(fieldsConfig).length === 0) {
159
+ return { ready: true };
160
+ }
161
+
162
+ // Check each required field
163
+ for (const [field, requiredValue] of Object.entries(fieldsConfig)) {
164
+ const actualValue = item[field];
165
+
166
+ if (actualValue !== requiredValue) {
167
+ return {
168
+ ready: false,
169
+ reason: `Field '${field}' is ${JSON.stringify(actualValue)}, required ${JSON.stringify(requiredValue)}`,
170
+ };
171
+ }
172
+ }
173
+
174
+ return { ready: true };
175
+ }
176
+
137
177
  /**
138
178
  * Check if a PR/issue has meaningful (non-bot, non-author) comments
139
179
  *
@@ -246,6 +286,16 @@ export function evaluateReadiness(issue, config) {
246
286
  };
247
287
  }
248
288
 
289
+ // Check required field values
290
+ const fieldsResult = checkFields(issue, config);
291
+ if (!fieldsResult.ready) {
292
+ return {
293
+ ready: false,
294
+ reason: fieldsResult.reason,
295
+ priority: 0,
296
+ };
297
+ }
298
+
249
299
  // Calculate priority for ready issues
250
300
  const priority = calculatePriority(issue, config);
251
301
 
@@ -57,6 +57,60 @@ describe('poller.js', () => {
57
57
  });
58
58
  });
59
59
 
60
+ describe('getToolConfig', () => {
61
+ test('returns MCP config for mcp-based sources', async () => {
62
+ const { getToolConfig } = await import('../../service/poller.js');
63
+
64
+ const source = {
65
+ name: 'test-source',
66
+ tool: { mcp: 'github', name: 'search_issues' },
67
+ args: { q: 'is:open' },
68
+ item: { id: '{html_url}' }
69
+ };
70
+
71
+ const config = getToolConfig(source);
72
+ assert.strictEqual(config.type, 'mcp');
73
+ assert.strictEqual(config.mcpServer, 'github');
74
+ assert.strictEqual(config.toolName, 'search_issues');
75
+ assert.deepStrictEqual(config.args, { q: 'is:open' });
76
+ assert.strictEqual(config.idTemplate, '{html_url}');
77
+ });
78
+
79
+ test('returns CLI config for command-based sources', async () => {
80
+ const { getToolConfig } = await import('../../service/poller.js');
81
+
82
+ const source = {
83
+ name: 'test-source',
84
+ tool: { command: ['granola-cli', 'meetings', 'list', '20'] },
85
+ item: { id: 'meeting:{id}' }
86
+ };
87
+
88
+ const config = getToolConfig(source);
89
+ assert.strictEqual(config.type, 'cli');
90
+ assert.deepStrictEqual(config.command, ['granola-cli', 'meetings', 'list', '20']);
91
+ assert.strictEqual(config.idTemplate, 'meeting:{id}');
92
+ });
93
+
94
+ test('throws for sources missing tool config', async () => {
95
+ const { getToolConfig } = await import('../../service/poller.js');
96
+
97
+ const source = { name: 'bad-source' };
98
+
99
+ assert.throws(() => getToolConfig(source), /missing tool configuration/);
100
+ });
101
+
102
+ test('throws for mcp sources missing name', async () => {
103
+ const { getToolConfig } = await import('../../service/poller.js');
104
+
105
+ const source = {
106
+ name: 'bad-source',
107
+ tool: { mcp: 'github' } // missing name
108
+ };
109
+
110
+ assert.throws(() => getToolConfig(source), /missing tool configuration/);
111
+ });
112
+ });
113
+
60
114
  describe('createPoller', () => {
61
115
  test('creates poller with state tracking', async () => {
62
116
  const { createPoller } = await import('../../service/poller.js');
@@ -172,6 +172,143 @@ describe('readiness.js', () => {
172
172
  });
173
173
  });
174
174
 
175
+ describe('checkFields', () => {
176
+ test('returns ready when no fields configured', async () => {
177
+ const { checkFields } = await import('../../service/readiness.js');
178
+
179
+ const item = { id: 'item-1', has_notes: false };
180
+ const config = {};
181
+
182
+ const result = checkFields(item, config);
183
+
184
+ assert.strictEqual(result.ready, true);
185
+ });
186
+
187
+ test('returns ready when field matches required value (boolean)', async () => {
188
+ const { checkFields } = await import('../../service/readiness.js');
189
+
190
+ const meeting = { id: 'meeting-1', has_notes: true };
191
+ const config = {
192
+ readiness: {
193
+ fields: {
194
+ has_notes: true
195
+ }
196
+ }
197
+ };
198
+
199
+ const result = checkFields(meeting, config);
200
+
201
+ assert.strictEqual(result.ready, true);
202
+ });
203
+
204
+ test('returns not ready when field does not match required value', async () => {
205
+ const { checkFields } = await import('../../service/readiness.js');
206
+
207
+ const meeting = { id: 'meeting-1', has_notes: false };
208
+ const config = {
209
+ readiness: {
210
+ fields: {
211
+ has_notes: true
212
+ }
213
+ }
214
+ };
215
+
216
+ const result = checkFields(meeting, config);
217
+
218
+ assert.strictEqual(result.ready, false);
219
+ assert.ok(result.reason.includes('has_notes'));
220
+ });
221
+
222
+ test('returns not ready when required field is missing', async () => {
223
+ const { checkFields } = await import('../../service/readiness.js');
224
+
225
+ const item = { id: 'item-1', title: 'Test' };
226
+ const config = {
227
+ readiness: {
228
+ fields: {
229
+ has_notes: true
230
+ }
231
+ }
232
+ };
233
+
234
+ const result = checkFields(item, config);
235
+
236
+ assert.strictEqual(result.ready, false);
237
+ assert.ok(result.reason.includes('has_notes'));
238
+ });
239
+
240
+ test('checks multiple fields (all must match)', async () => {
241
+ const { checkFields } = await import('../../service/readiness.js');
242
+
243
+ const item = { id: 'item-1', has_notes: true, type: 'meeting' };
244
+ const config = {
245
+ readiness: {
246
+ fields: {
247
+ has_notes: true,
248
+ type: 'meeting'
249
+ }
250
+ }
251
+ };
252
+
253
+ const result = checkFields(item, config);
254
+
255
+ assert.strictEqual(result.ready, true);
256
+ });
257
+
258
+ test('fails if any field does not match', async () => {
259
+ const { checkFields } = await import('../../service/readiness.js');
260
+
261
+ const item = { id: 'item-1', has_notes: true, type: 'note' };
262
+ const config = {
263
+ readiness: {
264
+ fields: {
265
+ has_notes: true,
266
+ type: 'meeting'
267
+ }
268
+ }
269
+ };
270
+
271
+ const result = checkFields(item, config);
272
+
273
+ assert.strictEqual(result.ready, false);
274
+ assert.ok(result.reason.includes('type'));
275
+ });
276
+
277
+ test('supports string field values', async () => {
278
+ const { checkFields } = await import('../../service/readiness.js');
279
+
280
+ const item = { id: 'item-1', state: 'open' };
281
+ const config = {
282
+ readiness: {
283
+ fields: {
284
+ state: 'open'
285
+ }
286
+ }
287
+ };
288
+
289
+ const result = checkFields(item, config);
290
+
291
+ assert.strictEqual(result.ready, true);
292
+ });
293
+
294
+ test('supports numeric field values', async () => {
295
+ const { checkFields } = await import('../../service/readiness.js');
296
+
297
+ const item = { id: 'item-1', participant_count: 3 };
298
+ const config = {
299
+ readiness: {
300
+ fields: {
301
+ participant_count: 3
302
+ }
303
+ }
304
+ };
305
+
306
+ const result = checkFields(item, config);
307
+
308
+ assert.strictEqual(result.ready, true);
309
+ });
310
+ });
311
+
175
312
  describe('evaluateReadiness', () => {
176
313
  test('checks bot comments when _comments is present', async () => {
177
314
  const { evaluateReadiness } = await import('../../service/readiness.js');
@@ -206,5 +343,48 @@ describe('readiness.js', () => {
206
343
 
207
344
  assert.strictEqual(result.ready, true);
208
345
  });
346
+
347
+ test('checks fields when readiness.fields is configured', async () => {
348
+ const { evaluateReadiness } = await import('../../service/readiness.js');
349
+
350
+ const meeting = {
351
+ id: 'meeting-123',
352
+ title: 'Team Standup',
353
+ has_notes: false
354
+ };
355
+ const config = {
356
+ readiness: {
357
+ fields: {
358
+ has_notes: true
359
+ }
360
+ }
361
+ };
362
+
363
+ const result = evaluateReadiness(meeting, config);
364
+
365
+ assert.strictEqual(result.ready, false);
366
+ assert.ok(result.reason.includes('has_notes'));
367
+ });
368
+
369
+ test('passes when field matches required value', async () => {
370
+ const { evaluateReadiness } = await import('../../service/readiness.js');
371
+
372
+ const meeting = {
373
+ id: 'meeting-123',
374
+ title: 'Team Standup',
375
+ has_notes: true
376
+ };
377
+ const config = {
378
+ readiness: {
379
+ fields: {
380
+ has_notes: true
381
+ }
382
+ }
383
+ };
384
+
385
+ const result = evaluateReadiness(meeting, config);
386
+
387
+ assert.strictEqual(result.ready, true);
388
+ });
209
389
  });
210
390
  });
@@ -700,8 +700,9 @@ sources:
700
700
  loadRepoConfig(configPath);
701
701
  const sources = getSources();
702
702
 
703
- assert.strictEqual(sources[0].session.name, '{title}', 'linear/my-issues should use title');
703
+ assert.strictEqual(sources[0].session.name, '{title}', 'linear preset should use title');
704
704
  });
705
+
705
706
  });
706
707
 
707
708
  describe('shorthand syntax', () => {