opencode-pilot 0.3.0 → 0.5.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/README.md CHANGED
@@ -9,6 +9,7 @@ Automation daemon for [OpenCode](https://github.com/sst/opencode) - polls for wo
9
9
  - **Polling automation** - Automatically start sessions from GitHub issues, Linear tickets, etc.
10
10
  - **Readiness evaluation** - Check labels, dependencies, and priority before starting work
11
11
  - **Template-based prompts** - Customize prompts with placeholders for issue data
12
+ - **Built-in presets** - Common patterns like "my GitHub issues" work out of the box
12
13
 
13
14
  ## Installation
14
15
 
@@ -44,10 +45,26 @@ See [examples/config.yaml](examples/config.yaml) for a complete example with all
44
45
 
45
46
  ### Key Sections
46
47
 
47
- - **`sources`** - What to poll (GitHub issues, Linear tickets, etc.)
48
+ - **`defaults`** - Default values applied to all sources
49
+ - **`sources`** - What to poll (presets, shorthand, or full config)
48
50
  - **`tools`** - Field mappings to normalize different MCP APIs
49
51
  - **`repos`** - Repository paths and settings (use YAML anchors to share config)
50
52
 
53
+ ### Source Syntax
54
+
55
+ Three ways to configure sources, from simplest to most flexible:
56
+
57
+ 1. **Presets** - Built-in definitions for common patterns (`github/my-issues`, `github/review-requests`, etc.)
58
+ 2. **GitHub shorthand** - Simple `github: "query"` syntax for custom GitHub searches
59
+ 3. **Full syntax** - Complete control with `tool`, `args`, and `item` for any MCP source
60
+
61
+ ### Available Presets
62
+
63
+ - `github/my-issues` - Issues assigned to me
64
+ - `github/review-requests` - PRs needing my review
65
+ - `github/my-prs-feedback` - My PRs with change requests
66
+ - `linear/my-issues` - Linear tickets (requires `teamId`, `assigneeId`)
67
+
51
68
  ### Prompt Templates
52
69
 
53
70
  Create prompt templates as markdown files in `~/.config/opencode-pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
@@ -4,63 +4,88 @@
4
4
  # Also create templates/ directory with prompt template files
5
5
 
6
6
  # =============================================================================
7
- # SERVICE - Daemon configuration
7
+ # DEFAULTS - Applied to all sources (source values override these)
8
8
  # =============================================================================
9
- # port: 4097 # HTTP port for health checks (default: 4097)
9
+ defaults:
10
+ agent: plan # Default agent for all sources
11
+ prompt: default # Default prompt template
10
12
 
11
13
  # =============================================================================
12
- # TOOLS - Field mappings for MCP servers (normalize different APIs)
13
- # =============================================================================
14
- tools:
15
- github:
16
- mappings: {} # GitHub already uses standard field names
17
-
18
- linear:
19
- mappings:
20
- body: title # Use title as body
21
- number: "url:/([A-Z0-9]+-[0-9]+)/" # Extract PROJ-123 from URL
22
-
23
- # =============================================================================
24
- # SOURCES - What to poll (generic MCP tool references)
14
+ # SOURCES - What to poll
15
+ #
16
+ # Three syntax options:
17
+ # 1. Presets: Use built-in presets for common patterns
18
+ # 2. Shorthand: Use `github: "query"` for GitHub sources
19
+ # 3. Full: Specify tool, args, item for custom sources
25
20
  # =============================================================================
26
21
  sources:
22
+ # ----- PRESET SYNTAX (recommended for common patterns) -----
23
+
27
24
  # GitHub issues assigned to me
28
- - name: my-issues
29
- tool:
30
- mcp: github
31
- name: search_issues
32
- args:
33
- q: "is:issue assignee:@me state:open"
34
- item:
35
- id: "{html_url}"
25
+ - preset: github/my-issues
36
26
  prompt: worktree
37
- agent: plan
38
27
 
39
- # GitHub PRs needing review
40
- - name: review-requests
41
- tool:
42
- mcp: github
43
- name: search_issues
44
- args:
45
- q: "is:pr review-requested:@me state:open"
46
- item:
47
- id: "{html_url}"
28
+ # GitHub PRs needing my review
29
+ - preset: github/review-requests
48
30
  prompt: review
49
- agent: plan
50
31
 
51
- # Linear issues
52
- # NOTE: Replace teamId and assigneeId with your actual Linear UUIDs
53
- # Find these via: linear_list_teams and check user IDs in team members
54
- - name: linear-work
55
- tool:
56
- mcp: linear
57
- name: list_issues
32
+ # My PRs that have change requests
33
+ - preset: github/my-prs-feedback
34
+ prompt: review-feedback
35
+
36
+ # Linear issues (requires teamId and assigneeId)
37
+ # Find IDs via: opencode-pilot discover linear
38
+ - preset: linear/my-issues
58
39
  args:
59
40
  teamId: "your-team-uuid" # Replace with actual team UUID
60
41
  assigneeId: "your-user-uuid" # Replace with actual user UUID
61
- status: "Todo"
62
- item:
63
- id: "linear:{id}"
42
+ status: "In Progress"
64
43
  working_dir: ~/code/myproject
65
44
  prompt: worktree
66
- agent: plan
45
+
46
+ # ----- SHORTHAND SYNTAX (for custom GitHub queries) -----
47
+
48
+ # Custom GitHub query with shorthand
49
+ # - name: urgent-issues
50
+ # github: "is:issue assignee:@me label:urgent state:open"
51
+ # prompt: worktree
52
+
53
+ # ----- FULL SYNTAX (for non-GitHub sources or full control) -----
54
+
55
+ # Apple Reminders example (requires tools config below)
56
+ # - name: agent-tasks
57
+ # tool:
58
+ # mcp: apple-reminders
59
+ # name: list_reminders
60
+ # args:
61
+ # list_name: "Agent Tasks"
62
+ # completed: false
63
+ # item:
64
+ # id: "reminder:{id}"
65
+ # prompt: agent-planning
66
+ # working_dir: ~
67
+
68
+ # =============================================================================
69
+ # TOOLS - Field mappings for custom MCP servers (OPTIONAL)
70
+ #
71
+ # Note: GitHub and Linear presets include built-in mappings.
72
+ # Only needed for custom sources or to override preset defaults.
73
+ #
74
+ # Each tool provider can specify:
75
+ # response_key: Key to extract array from response (e.g., "reminders")
76
+ # mappings: Field name translations (e.g., { body: "notes" })
77
+ # =============================================================================
78
+ # tools:
79
+ # apple-reminders:
80
+ # response_key: reminders
81
+ # mappings:
82
+ # title: name
83
+ # body: notes
84
+
85
+ # =============================================================================
86
+ # Available presets (no tools config needed):
87
+ # github/my-issues - Issues assigned to me
88
+ # github/review-requests - PRs needing my review
89
+ # github/my-prs-feedback - My PRs with change requests
90
+ # linear/my-issues - Linear tickets (requires teamId, assigneeId)
91
+ # =============================================================================
@@ -0,0 +1,9 @@
1
+ /devcontainer {html_url}
2
+
3
+ Work on this issue in an isolated devcontainer:
4
+
5
+ {title}
6
+
7
+ {body}
8
+
9
+ Complete the implementation and create a PR when ready.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -9,7 +9,7 @@
9
9
  * 5. Track processed items to avoid duplicates
10
10
  */
11
11
 
12
- import { loadRepoConfig, getRepoConfig, getAllSources, getToolMappings } from "./repo-config.js";
12
+ import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig } from "./repo-config.js";
13
13
  import { createPoller, pollGenericSource } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
@@ -100,8 +100,8 @@ export async function pollOnce(options = {}) {
100
100
  // Fetch items from source
101
101
  if (!skipMcp) {
102
102
  try {
103
- const mappings = getToolMappings(source.tool.mcp);
104
- items = await pollGenericSource(source, { mappings });
103
+ const toolProviderConfig = getToolProviderConfig(source.tool.mcp);
104
+ items = await pollGenericSource(source, { toolProviderConfig });
105
105
  debug(`Fetched ${items.length} items from ${sourceName}`);
106
106
  } catch (err) {
107
107
  console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
package/service/poller.js CHANGED
@@ -107,14 +107,28 @@ export function transformItems(items, idTemplate) {
107
107
 
108
108
  /**
109
109
  * Parse JSON text as an array with error handling
110
+ * @param {string} text - JSON text to parse
111
+ * @param {string} sourceName - Source name for error logging
112
+ * @param {string} [responseKey] - Key to extract array from response object
113
+ * @returns {Array} Parsed array of items
110
114
  */
111
- function parseJsonArray(text, sourceName) {
115
+ export function parseJsonArray(text, sourceName, responseKey) {
112
116
  try {
113
117
  const data = JSON.parse(text);
118
+
119
+ // If already an array, return it
114
120
  if (Array.isArray(data)) return data;
115
- if (data.items) return data.items;
116
- if (data.issues) return data.issues;
117
- if (data.nodes) return data.nodes;
121
+
122
+ // If response_key is configured, use it to extract the array
123
+ if (responseKey) {
124
+ const items = data[responseKey];
125
+ if (Array.isArray(items)) return items;
126
+ // response_key was specified but not found or not an array
127
+ console.error(`[poller] Response key '${responseKey}' not found or not an array in ${sourceName} response`);
128
+ return [];
129
+ }
130
+
131
+ // No response_key - wrap single object as array
118
132
  return [data];
119
133
  } catch (err) {
120
134
  console.error(`[poller] Failed to parse ${sourceName} response:`, err.message);
@@ -206,13 +220,15 @@ function createTimeout(ms, operation) {
206
220
  * @param {object} [options] - Additional options
207
221
  * @param {number} [options.timeout] - Timeout in ms (default: 30000)
208
222
  * @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
209
- * @param {object} [options.mappings] - Field mappings to apply to items
223
+ * @param {object} [options.toolProviderConfig] - Tool provider config (response_key, mappings)
210
224
  * @returns {Promise<Array>} Array of items from the source with IDs and mappings applied
211
225
  */
212
226
  export async function pollGenericSource(source, options = {}) {
213
227
  const toolConfig = getToolConfig(source);
214
228
  const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
215
- const mappings = options.mappings || null;
229
+ const toolProviderConfig = options.toolProviderConfig || {};
230
+ const responseKey = toolProviderConfig.response_key;
231
+ const mappings = toolProviderConfig.mappings || null;
216
232
  const mcpConfig = getMcpConfig(toolConfig.mcpServer, options.opencodeConfigPath);
217
233
  const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
218
234
 
@@ -235,7 +251,7 @@ export async function pollGenericSource(source, options = {}) {
235
251
  const text = result.content?.[0]?.text;
236
252
  if (!text) return [];
237
253
 
238
- const rawItems = parseJsonArray(text, source.name);
254
+ const rawItems = parseJsonArray(text, source.name, responseKey);
239
255
 
240
256
  // Apply field mappings before transforming
241
257
  const mappedItems = mappings
@@ -0,0 +1,37 @@
1
+ # GitHub source presets
2
+
3
+ # Provider-level config (applies to all GitHub presets)
4
+ _provider:
5
+ response_key: items
6
+ mappings: {}
7
+
8
+ # Presets
9
+ my-issues:
10
+ name: my-issues
11
+ tool:
12
+ mcp: github
13
+ name: search_issues
14
+ args:
15
+ q: "is:issue assignee:@me state:open"
16
+ item:
17
+ id: "{html_url}"
18
+
19
+ review-requests:
20
+ name: review-requests
21
+ tool:
22
+ mcp: github
23
+ name: search_issues
24
+ args:
25
+ q: "is:pr review-requested:@me state:open"
26
+ item:
27
+ id: "{html_url}"
28
+
29
+ my-prs-feedback:
30
+ name: my-prs-feedback
31
+ tool:
32
+ mcp: github
33
+ name: search_issues
34
+ args:
35
+ q: "is:pr author:@me state:open review:changes_requested"
36
+ item:
37
+ id: "{html_url}"
@@ -0,0 +1,157 @@
1
+ /**
2
+ * presets/index.js - Built-in source presets for common patterns
3
+ *
4
+ * Presets reduce config verbosity by providing sensible defaults
5
+ * for common polling sources like GitHub issues and Linear tickets.
6
+ *
7
+ * Presets are loaded from YAML files in this directory.
8
+ * Each file can include a _provider key with provider-level config
9
+ * (response_key, mappings) that applies to all presets for that provider.
10
+ */
11
+
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import { fileURLToPath } from "url";
15
+ import YAML from "yaml";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+
19
+ // Cache for loaded provider data
20
+ const providerCache = {};
21
+
22
+ /**
23
+ * Load provider data from a YAML file
24
+ * @param {string} provider - Provider name (e.g., "github", "linear")
25
+ * @returns {object} Provider data with presets and _provider config
26
+ */
27
+ function loadProviderData(provider) {
28
+ if (providerCache[provider]) {
29
+ return providerCache[provider];
30
+ }
31
+
32
+ const filePath = path.join(__dirname, `${provider}.yaml`);
33
+ if (!fs.existsSync(filePath)) {
34
+ providerCache[provider] = { presets: {}, providerConfig: null };
35
+ return providerCache[provider];
36
+ }
37
+
38
+ try {
39
+ const content = fs.readFileSync(filePath, "utf-8");
40
+ const data = YAML.parse(content) || {};
41
+
42
+ // Extract _provider config and presets
43
+ const { _provider, ...presets } = data;
44
+
45
+ providerCache[provider] = {
46
+ presets,
47
+ providerConfig: _provider || null,
48
+ };
49
+ return providerCache[provider];
50
+ } catch (err) {
51
+ console.error(`Warning: Failed to load presets from ${filePath}: ${err.message}`);
52
+ providerCache[provider] = { presets: {}, providerConfig: null };
53
+ return providerCache[provider];
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Build the full presets registry from YAML files
59
+ * Format: "provider/preset-name" -> preset config
60
+ */
61
+ function buildPresetsRegistry() {
62
+ const registry = {};
63
+ const providers = ["github", "linear"];
64
+
65
+ for (const provider of providers) {
66
+ const { presets } = loadProviderData(provider);
67
+ for (const [name, config] of Object.entries(presets)) {
68
+ registry[`${provider}/${name}`] = config;
69
+ }
70
+ }
71
+
72
+ return registry;
73
+ }
74
+
75
+ // Load presets once at module initialization
76
+ const PRESETS = buildPresetsRegistry();
77
+
78
+ /**
79
+ * Get a preset by name
80
+ * @param {string} presetName - Preset identifier (e.g., "github/my-issues")
81
+ * @returns {object|null} Preset configuration or null if not found
82
+ */
83
+ export function getPreset(presetName) {
84
+ return PRESETS[presetName] || null;
85
+ }
86
+
87
+ /**
88
+ * Get provider config (response_key, mappings) for a provider
89
+ * @param {string} provider - Provider name (e.g., "github", "linear")
90
+ * @returns {object|null} Provider config or null if not found
91
+ */
92
+ export function getProviderConfig(provider) {
93
+ const { providerConfig } = loadProviderData(provider);
94
+ return providerConfig;
95
+ }
96
+
97
+ /**
98
+ * List all available preset names
99
+ * @returns {string[]} Array of preset names
100
+ */
101
+ export function listPresets() {
102
+ return Object.keys(PRESETS);
103
+ }
104
+
105
+ /**
106
+ * Expand a preset into a full source configuration
107
+ * User config is merged on top of preset defaults
108
+ * @param {string} presetName - Preset identifier
109
+ * @param {object} userConfig - User's source config (overrides preset)
110
+ * @returns {object} Merged source configuration
111
+ * @throws {Error} If preset is unknown
112
+ */
113
+ export function expandPreset(presetName, userConfig) {
114
+ const preset = getPreset(presetName);
115
+ if (!preset) {
116
+ throw new Error(`Unknown preset: ${presetName}`);
117
+ }
118
+
119
+ // Deep merge: preset as base, user config on top
120
+ return {
121
+ ...preset,
122
+ ...userConfig,
123
+ // Merge nested objects specially
124
+ tool: userConfig.tool || preset.tool,
125
+ args: {
126
+ ...preset.args,
127
+ ...(userConfig.args || {}),
128
+ },
129
+ item: userConfig.item || preset.item,
130
+ // Remove the preset key from final output
131
+ preset: undefined,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Expand GitHub shorthand syntax into full source config
137
+ * @param {string} query - GitHub search query
138
+ * @param {object} userConfig - Rest of user's source config
139
+ * @returns {object} Full source configuration
140
+ */
141
+ export function expandGitHubShorthand(query, userConfig) {
142
+ return {
143
+ ...userConfig,
144
+ tool: {
145
+ mcp: "github",
146
+ name: "search_issues",
147
+ },
148
+ args: {
149
+ q: query,
150
+ },
151
+ item: {
152
+ id: "{html_url}",
153
+ },
154
+ // Remove the github key from final output
155
+ github: undefined,
156
+ };
157
+ }
@@ -0,0 +1,19 @@
1
+ # Linear source presets
2
+
3
+ # Provider-level config (applies to all Linear presets)
4
+ _provider:
5
+ response_key: nodes
6
+ mappings:
7
+ body: title
8
+ number: "url:/([A-Z0-9]+-[0-9]+)/"
9
+
10
+ # Presets
11
+ my-issues:
12
+ name: my-issues
13
+ tool:
14
+ mcp: linear
15
+ name: list_issues
16
+ args: {}
17
+ # teamId and assigneeId are required - user must provide
18
+ item:
19
+ id: "linear:{id}"
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Manages configuration stored in ~/.config/opencode-pilot/config.yaml
5
5
  * Supports:
6
+ * - defaults: default values applied to all sources
6
7
  * - repos: per-repository settings (use YAML anchors for sharing)
7
- * - sources: polling sources with generic tool references
8
+ * - sources: polling sources with generic tool references, presets, or shorthand
8
9
  * - tools: field mappings for normalizing MCP responses
9
10
  * - templates: prompt templates stored as markdown files
10
11
  */
@@ -14,6 +15,7 @@ import path from "path";
14
15
  import os from "os";
15
16
  import YAML from "yaml";
16
17
  import { getNestedValue } from "./utils.js";
18
+ import { expandPreset, expandGitHubShorthand, getProviderConfig } from "./presets/index.js";
17
19
 
18
20
  // Default config path
19
21
  const DEFAULT_CONFIG_PATH = path.join(
@@ -100,13 +102,53 @@ export function getRepoConfig(repoKey) {
100
102
  return repoConfig;
101
103
  }
102
104
 
105
+ /**
106
+ * Normalize a single source config
107
+ * Expands presets, shorthand syntax, and applies defaults
108
+ * @param {object} source - Raw source config
109
+ * @param {object} defaults - Default values to apply
110
+ * @returns {object} Normalized source config
111
+ */
112
+ function normalizeSource(source, defaults) {
113
+ let normalized = { ...source };
114
+
115
+ // Expand preset if present
116
+ if (source.preset) {
117
+ normalized = expandPreset(source.preset, source);
118
+ }
119
+
120
+ // Expand GitHub shorthand if present
121
+ if (source.github) {
122
+ normalized = expandGitHubShorthand(source.github, source);
123
+ }
124
+
125
+ // Apply defaults (source values take precedence)
126
+ return {
127
+ ...defaults,
128
+ ...normalized,
129
+ };
130
+ }
131
+
103
132
  /**
104
133
  * Get all top-level sources (for polling)
105
- * @returns {Array} Array of source configurations
134
+ * Expands presets and shorthand syntax, applies defaults
135
+ * @returns {Array} Array of normalized source configurations
106
136
  */
107
137
  export function getSources() {
108
138
  const config = getRawConfig();
109
- return config.sources || [];
139
+ const rawSources = config.sources || [];
140
+ const defaults = config.defaults || {};
141
+
142
+ return rawSources.map((source) => normalizeSource(source, defaults));
143
+ }
144
+
145
+ /**
146
+ * Get defaults section from config
147
+ * @returns {object} Defaults configuration or empty object
148
+ */
149
+ export function getDefaults() {
150
+ const config = getRawConfig();
151
+ return config.defaults || {};
110
152
  }
111
153
 
112
154
  /**
@@ -134,6 +176,40 @@ export function getToolMappings(provider) {
134
176
  return toolConfig.mappings;
135
177
  }
136
178
 
179
+ /**
180
+ * Get full tool provider configuration (response_key, mappings, etc.)
181
+ * Checks user config first, then falls back to preset provider defaults
182
+ * @param {string} provider - Tool provider name (e.g., "github", "linear", "apple-reminders")
183
+ * @returns {object|null} Tool config including response_key and mappings, or null if not configured
184
+ */
185
+ export function getToolProviderConfig(provider) {
186
+ const config = getRawConfig();
187
+ const tools = config.tools || {};
188
+ const userToolConfig = tools[provider];
189
+
190
+ // Get preset provider config as fallback
191
+ const presetProviderConfig = getProviderConfig(provider);
192
+
193
+ // If user has config, merge with preset defaults (user takes precedence)
194
+ if (userToolConfig) {
195
+ if (presetProviderConfig) {
196
+ return {
197
+ ...presetProviderConfig,
198
+ ...userToolConfig,
199
+ // Deep merge mappings
200
+ mappings: {
201
+ ...(presetProviderConfig.mappings || {}),
202
+ ...(userToolConfig.mappings || {}),
203
+ },
204
+ };
205
+ }
206
+ return userToolConfig;
207
+ }
208
+
209
+ // Fall back to preset provider config
210
+ return presetProviderConfig;
211
+ }
212
+
137
213
  /**
138
214
  * Load a template from the templates directory
139
215
  * @param {string} templateName - Template name (without .md extension)
@@ -312,6 +312,65 @@ describe('poller.js', () => {
312
312
  });
313
313
  });
314
314
 
315
+ describe('parseJsonArray', () => {
316
+ test('parses direct array response', async () => {
317
+ const { parseJsonArray } = await import('../../service/poller.js');
318
+
319
+ const text = JSON.stringify([{ id: '1' }, { id: '2' }]);
320
+ const result = parseJsonArray(text, 'test');
321
+
322
+ assert.strictEqual(result.length, 2);
323
+ assert.strictEqual(result[0].id, '1');
324
+ });
325
+
326
+ test('extracts array using response_key', async () => {
327
+ const { parseJsonArray } = await import('../../service/poller.js');
328
+
329
+ const text = JSON.stringify({
330
+ reminders: [
331
+ { id: 'reminder-1', name: 'Task 1', completed: false },
332
+ { id: 'reminder-2', name: 'Task 2', completed: false },
333
+ { id: 'reminder-3', name: 'Task 3', completed: false }
334
+ ],
335
+ count: 3
336
+ });
337
+ const result = parseJsonArray(text, 'agent-tasks', 'reminders');
338
+
339
+ assert.strictEqual(result.length, 3);
340
+ assert.strictEqual(result[0].id, 'reminder-1');
341
+ assert.strictEqual(result[0].name, 'Task 1');
342
+ assert.strictEqual(result[1].id, 'reminder-2');
343
+ assert.strictEqual(result[2].id, 'reminder-3');
344
+ });
345
+
346
+ test('wraps single object as array when no response_key', async () => {
347
+ const { parseJsonArray } = await import('../../service/poller.js');
348
+
349
+ const text = JSON.stringify({ id: '1', title: 'Single item' });
350
+ const result = parseJsonArray(text, 'test');
351
+
352
+ assert.strictEqual(result.length, 1);
353
+ assert.strictEqual(result[0].id, '1');
354
+ });
355
+
356
+ test('returns empty array for invalid JSON', async () => {
357
+ const { parseJsonArray } = await import('../../service/poller.js');
358
+
359
+ const result = parseJsonArray('not valid json', 'test');
360
+
361
+ assert.strictEqual(result.length, 0);
362
+ });
363
+
364
+ test('returns empty array when response_key not found', async () => {
365
+ const { parseJsonArray } = await import('../../service/poller.js');
366
+
367
+ const text = JSON.stringify({ items: [{ id: '1' }] });
368
+ const result = parseJsonArray(text, 'test', 'reminders');
369
+
370
+ assert.strictEqual(result.length, 0);
371
+ });
372
+ });
373
+
315
374
  describe('transformItems with mappings', () => {
316
375
  test('applies mappings to all items', async () => {
317
376
  const { transformItems, applyMappings } = await import('../../service/poller.js');
@@ -314,6 +314,84 @@ sources: []
314
314
 
315
315
  assert.strictEqual(mappings, null);
316
316
  });
317
+
318
+ test('getToolProviderConfig returns full tool config with response_key', async () => {
319
+ writeFileSync(configPath, `
320
+ tools:
321
+ apple-reminders:
322
+ response_key: reminders
323
+ mappings:
324
+ body: notes
325
+
326
+ sources: []
327
+ `);
328
+
329
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
330
+ loadRepoConfig(configPath);
331
+
332
+ const toolConfig = getToolProviderConfig('apple-reminders');
333
+
334
+ assert.strictEqual(toolConfig.response_key, 'reminders');
335
+ assert.deepStrictEqual(toolConfig.mappings, { body: 'notes' });
336
+ });
337
+
338
+ test('getToolProviderConfig returns config without response_key', async () => {
339
+ writeFileSync(configPath, `
340
+ tools:
341
+ github:
342
+ mappings:
343
+ url: html_url
344
+
345
+ sources: []
346
+ `);
347
+
348
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
349
+ loadRepoConfig(configPath);
350
+
351
+ const toolConfig = getToolProviderConfig('github');
352
+
353
+ // GitHub preset has response_key: items, user config doesn't override it
354
+ assert.strictEqual(toolConfig.response_key, 'items');
355
+ assert.deepStrictEqual(toolConfig.mappings, { url: 'html_url' });
356
+ });
357
+
358
+ test('getToolProviderConfig falls back to preset provider config', async () => {
359
+ writeFileSync(configPath, `
360
+ sources: []
361
+ `);
362
+
363
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
364
+ loadRepoConfig(configPath);
365
+
366
+ // Linear preset has provider config with response_key and mappings
367
+ const toolConfig = getToolProviderConfig('linear');
368
+
369
+ assert.strictEqual(toolConfig.response_key, 'nodes');
370
+ assert.strictEqual(toolConfig.mappings.body, 'title');
371
+ assert.strictEqual(toolConfig.mappings.number, 'url:/([A-Z0-9]+-[0-9]+)/');
372
+ });
373
+
374
+ test('getToolProviderConfig merges user config with preset defaults', async () => {
375
+ writeFileSync(configPath, `
376
+ tools:
377
+ linear:
378
+ mappings:
379
+ custom_field: some_source
380
+
381
+ sources: []
382
+ `);
383
+
384
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
385
+ loadRepoConfig(configPath);
386
+
387
+ const toolConfig = getToolProviderConfig('linear');
388
+
389
+ // Should have preset response_key
390
+ assert.strictEqual(toolConfig.response_key, 'nodes');
391
+ // Should have preset mappings plus user mappings
392
+ assert.strictEqual(toolConfig.mappings.body, 'title');
393
+ assert.strictEqual(toolConfig.mappings.custom_field, 'some_source');
394
+ });
317
395
  });
318
396
 
319
397
  describe('repo resolution for sources', () => {
@@ -438,4 +516,217 @@ repos:
438
516
  assert.strictEqual(repoKey, null);
439
517
  });
440
518
  });
519
+
520
+ describe('presets', () => {
521
+ test('expands github/my-issues preset', async () => {
522
+ writeFileSync(configPath, `
523
+ sources:
524
+ - preset: github/my-issues
525
+ prompt: worktree
526
+ `);
527
+
528
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
529
+ loadRepoConfig(configPath);
530
+ const sources = getSources();
531
+
532
+ assert.strictEqual(sources.length, 1);
533
+ assert.strictEqual(sources[0].name, 'my-issues');
534
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'search_issues' });
535
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open');
536
+ assert.strictEqual(sources[0].item.id, '{html_url}');
537
+ assert.strictEqual(sources[0].prompt, 'worktree');
538
+ });
539
+
540
+ test('expands github/review-requests preset', async () => {
541
+ writeFileSync(configPath, `
542
+ sources:
543
+ - preset: github/review-requests
544
+ `);
545
+
546
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
547
+ loadRepoConfig(configPath);
548
+ const sources = getSources();
549
+
550
+ assert.strictEqual(sources[0].name, 'review-requests');
551
+ assert.strictEqual(sources[0].args.q, 'is:pr review-requested:@me state:open');
552
+ });
553
+
554
+ test('expands github/my-prs-feedback preset', async () => {
555
+ writeFileSync(configPath, `
556
+ sources:
557
+ - preset: github/my-prs-feedback
558
+ `);
559
+
560
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
561
+ loadRepoConfig(configPath);
562
+ const sources = getSources();
563
+
564
+ assert.strictEqual(sources[0].name, 'my-prs-feedback');
565
+ assert.strictEqual(sources[0].args.q, 'is:pr author:@me state:open review:changes_requested');
566
+ });
567
+
568
+ test('expands linear/my-issues preset with required args', async () => {
569
+ writeFileSync(configPath, `
570
+ sources:
571
+ - preset: linear/my-issues
572
+ args:
573
+ teamId: "team-uuid-123"
574
+ assigneeId: "user-uuid-456"
575
+ `);
576
+
577
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
578
+ loadRepoConfig(configPath);
579
+ const sources = getSources();
580
+
581
+ assert.strictEqual(sources[0].name, 'my-issues');
582
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'linear', name: 'list_issues' });
583
+ assert.strictEqual(sources[0].args.teamId, 'team-uuid-123');
584
+ assert.strictEqual(sources[0].args.assigneeId, 'user-uuid-456');
585
+ });
586
+
587
+ test('user config overrides preset values', async () => {
588
+ writeFileSync(configPath, `
589
+ sources:
590
+ - preset: github/my-issues
591
+ name: custom-name
592
+ args:
593
+ q: "is:issue assignee:@me state:open label:urgent"
594
+ agent: plan
595
+ `);
596
+
597
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
598
+ loadRepoConfig(configPath);
599
+ const sources = getSources();
600
+
601
+ assert.strictEqual(sources[0].name, 'custom-name');
602
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open label:urgent');
603
+ assert.strictEqual(sources[0].agent, 'plan');
604
+ });
605
+
606
+ test('throws error for unknown preset', async () => {
607
+ writeFileSync(configPath, `
608
+ sources:
609
+ - preset: unknown/preset
610
+ `);
611
+
612
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
613
+ loadRepoConfig(configPath);
614
+
615
+ assert.throws(() => getSources(), /Unknown preset: unknown\/preset/);
616
+ });
617
+ });
618
+
619
+ describe('shorthand syntax', () => {
620
+ test('expands github shorthand to full source', async () => {
621
+ writeFileSync(configPath, `
622
+ sources:
623
+ - name: my-issues
624
+ github: "is:issue assignee:@me state:open"
625
+ prompt: worktree
626
+ `);
627
+
628
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
629
+ loadRepoConfig(configPath);
630
+ const sources = getSources();
631
+
632
+ assert.strictEqual(sources.length, 1);
633
+ assert.strictEqual(sources[0].name, 'my-issues');
634
+ assert.deepStrictEqual(sources[0].tool, { mcp: 'github', name: 'search_issues' });
635
+ assert.strictEqual(sources[0].args.q, 'is:issue assignee:@me state:open');
636
+ assert.strictEqual(sources[0].item.id, '{html_url}');
637
+ assert.strictEqual(sources[0].prompt, 'worktree');
638
+ });
639
+
640
+ test('shorthand works with other source fields', async () => {
641
+ writeFileSync(configPath, `
642
+ sources:
643
+ - name: urgent-issues
644
+ github: "is:issue assignee:@me label:urgent"
645
+ agent: plan
646
+ working_dir: ~/code/myproject
647
+ `);
648
+
649
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
650
+ loadRepoConfig(configPath);
651
+ const sources = getSources();
652
+
653
+ assert.strictEqual(sources[0].agent, 'plan');
654
+ assert.strictEqual(sources[0].working_dir, '~/code/myproject');
655
+ });
656
+ });
657
+
658
+ describe('defaults section', () => {
659
+ test('applies defaults to sources without those fields', async () => {
660
+ writeFileSync(configPath, `
661
+ defaults:
662
+ agent: plan
663
+ prompt: default
664
+
665
+ sources:
666
+ - preset: github/my-issues
667
+ - preset: github/review-requests
668
+ prompt: review
669
+ `);
670
+
671
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
672
+ loadRepoConfig(configPath);
673
+ const sources = getSources();
674
+
675
+ // First source gets both defaults
676
+ assert.strictEqual(sources[0].agent, 'plan');
677
+ assert.strictEqual(sources[0].prompt, 'default');
678
+
679
+ // Second source overrides prompt but gets agent default
680
+ assert.strictEqual(sources[1].agent, 'plan');
681
+ assert.strictEqual(sources[1].prompt, 'review');
682
+ });
683
+
684
+ test('source values override defaults', async () => {
685
+ writeFileSync(configPath, `
686
+ defaults:
687
+ agent: plan
688
+ model: claude-3-sonnet
689
+
690
+ sources:
691
+ - preset: github/my-issues
692
+ agent: architect
693
+ `);
694
+
695
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
696
+ loadRepoConfig(configPath);
697
+ const sources = getSources();
698
+
699
+ assert.strictEqual(sources[0].agent, 'architect');
700
+ assert.strictEqual(sources[0].model, 'claude-3-sonnet');
701
+ });
702
+
703
+ test('getDefaults returns defaults section', async () => {
704
+ writeFileSync(configPath, `
705
+ defaults:
706
+ agent: plan
707
+ prompt: default
708
+ working_dir: ~/code
709
+ `);
710
+
711
+ const { loadRepoConfig, getDefaults } = await import('../../service/repo-config.js');
712
+ loadRepoConfig(configPath);
713
+ const defaults = getDefaults();
714
+
715
+ assert.strictEqual(defaults.agent, 'plan');
716
+ assert.strictEqual(defaults.prompt, 'default');
717
+ assert.strictEqual(defaults.working_dir, '~/code');
718
+ });
719
+
720
+ test('getDefaults returns empty object when no defaults', async () => {
721
+ writeFileSync(configPath, `
722
+ sources: []
723
+ `);
724
+
725
+ const { loadRepoConfig, getDefaults } = await import('../../service/repo-config.js');
726
+ loadRepoConfig(configPath);
727
+ const defaults = getDefaults();
728
+
729
+ assert.deepStrictEqual(defaults, {});
730
+ });
731
+ });
441
732
  });