opencode-pilot 0.25.0 → 0.26.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.
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.12.tar.gz"
5
- sha256 "ea6f7ba7814225f9e312143f0222777bf4094da87cdbadad1e6589208c5323ab"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.25.1.tar.gz"
5
+ sha256 "f8be5b0d08bdd45d5c0155bf40e8930c431fc7a6e6ec77d422bb89471fe91cdc"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
@@ -38,7 +38,7 @@ sources:
38
38
  # PR presets have detect_stacks: true by default, enabling session reuse
39
39
  # across stacked PRs (where one PR's head branch = another's base branch)
40
40
  - preset: github/review-requests
41
- # Per-source model override (takes precedence over defaults.model)
41
+ # Per-source model override (takes precedence over defaults.model and repos.*.model)
42
42
  # model: anthropic/claude-haiku-3.5
43
43
 
44
44
  # PRs needing attention (conflicts OR human feedback)
@@ -94,9 +94,16 @@ sources:
94
94
 
95
95
  # Explicit repo mappings (overrides repos_dir auto-discovery)
96
96
  # Only needed if a repo isn't in repos_dir or needs custom settings
97
+ # Repos can also specify per-directory overrides for model, agent, and prompt.
98
+ # Priority: source override > repo override > defaults
97
99
  # repos:
98
100
  # myorg/backend:
99
101
  # path: ~/code/backend
102
+ # # Per-directory model override (overrides defaults.model, overridden by source.model)
103
+ # # model: anthropic/claude-sonnet-4-20250514
104
+ # myorg/legacy-service:
105
+ # path: ~/code/legacy-service
106
+ # model: anthropic/claude-haiku-3.5 # use cheaper model for this repo
100
107
 
101
108
  # Cleanup config (optional, sensible defaults)
102
109
  # cleanup:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -363,13 +363,30 @@ export function buildPromptFromTemplate(templateName, item, templatesDir) {
363
363
 
364
364
  /**
365
365
  * Merge source, repo config, and defaults into action config
366
- * Priority: source > repo > defaults
367
- * @param {object} source - Source configuration
366
+ * Priority: explicit source > repo > defaults
367
+ * @param {object} source - Source configuration (may include _explicit tracking from normalizeSource)
368
368
  * @param {object} repoConfig - Repository configuration
369
369
  * @param {object} defaults - Default configuration
370
370
  * @returns {object} Merged action config
371
371
  */
372
372
  export function getActionConfig(source, repoConfig, defaults) {
373
+ // _explicit tracks fields set directly on the source (not inherited from defaults).
374
+ // When _explicit is absent (e.g., tests constructing source objects directly), fall
375
+ // back to treating non-undefined source fields as explicit.
376
+ const explicit = source._explicit;
377
+
378
+ const resolveField = (field) => {
379
+ if (explicit) {
380
+ if (explicit[field] !== undefined) return explicit[field];
381
+ if (repoConfig[field] !== undefined) return repoConfig[field];
382
+ return defaults[field];
383
+ }
384
+ // No tracking: source wins, then repo, then defaults
385
+ if (source[field] !== undefined) return source[field];
386
+ if (repoConfig[field] !== undefined) return repoConfig[field];
387
+ return defaults[field];
388
+ };
389
+
373
390
  return {
374
391
  // Defaults first
375
392
  ...defaults,
@@ -380,11 +397,11 @@ export function getActionConfig(source, repoConfig, defaults) {
380
397
  ...(defaults.session || {}),
381
398
  ...(repoConfig.session || {}),
382
399
  },
383
- // Source-level overrides (highest priority)
384
- ...(source.prompt && { prompt: source.prompt }),
385
- ...(source.agent && { agent: source.agent }),
386
- ...(source.model && { model: source.model }),
387
- ...(source.working_dir && { working_dir: source.working_dir }),
400
+ // Operational fields with correct priority: explicit source > repo > defaults
401
+ ...(resolveField('prompt') && { prompt: resolveField('prompt') }),
402
+ ...(resolveField('agent') && { agent: resolveField('agent') }),
403
+ ...(resolveField('model') && { model: resolveField('model') }),
404
+ ...(resolveField('working_dir') && { working_dir: resolveField('working_dir') }),
388
405
  };
389
406
  }
390
407
 
@@ -32,12 +32,29 @@ export function hasToolConfig(source) {
32
32
 
33
33
  /**
34
34
  * Build action config from source and repo config
35
- * Source fields override repo config fields
36
- * @param {object} source - Source configuration
35
+ * Priority for operational fields: explicit source > repo > defaults (baked into source)
36
+ * @param {object} source - Source configuration (may include _explicit tracking of explicitly-set fields)
37
37
  * @param {object} repoConfig - Repository configuration
38
38
  * @returns {object} Merged action config
39
39
  */
40
40
  export function buildActionConfigFromSource(source, repoConfig) {
41
+ // _explicit tracks fields set directly on the source (not inherited from defaults).
42
+ // When _explicit is absent (e.g., tests constructing source objects directly), fall
43
+ // back to treating source fields as explicit.
44
+ const explicit = source._explicit;
45
+
46
+ // Resolve each operational field using priority: explicit source > repo > defaults (source)
47
+ const resolveField = (field) => {
48
+ if (explicit) {
49
+ // Normalization tracked explicit fields: use them in priority order
50
+ if (explicit[field] !== undefined) return explicit[field];
51
+ if (repoConfig[field] !== undefined) return repoConfig[field];
52
+ return source[field]; // defaults (baked into source)
53
+ }
54
+ // No tracking available (e.g., raw source in tests): source wins, then repo
55
+ return source[field] !== undefined ? source[field] : repoConfig[field];
56
+ };
57
+
41
58
  return {
42
59
  // Repo config as base
43
60
  ...repoConfig,
@@ -45,11 +62,11 @@ export function buildActionConfigFromSource(source, repoConfig) {
45
62
  repo_path: source.working_dir || repoConfig.path || repoConfig.repo_path,
46
63
  // Session from source or repo
47
64
  session: source.session || repoConfig.session || {},
48
- // Source-level overrides (highest priority)
49
- ...(source.prompt && { prompt: source.prompt }),
50
- ...(source.agent && { agent: source.agent }),
51
- ...(source.model && { model: source.model }),
52
- ...(source.working_dir && { working_dir: source.working_dir }),
65
+ // Operational fields with correct priority
66
+ ...(resolveField('prompt') && { prompt: resolveField('prompt') }),
67
+ ...(resolveField('agent') && { agent: resolveField('agent') }),
68
+ ...(resolveField('model') && { model: resolveField('model') }),
69
+ ...(resolveField('working_dir') && { working_dir: resolveField('working_dir') }),
53
70
  ...(source.worktree_name && { worktree_name: source.worktree_name }),
54
71
  };
55
72
  }
package/service/poller.js CHANGED
@@ -1215,7 +1215,20 @@ export function createPoller(options = {}) {
1215
1215
  if (!meta) return false; // Not processed before
1216
1216
 
1217
1217
  // Check if item reappeared after being missing (e.g., uncompleted reminder)
1218
+ // Exception: suppress reprocessing when the item cycled through an intermediate
1219
+ // state (e.g., Linear: In Progress -> In Review -> In Progress). If the stored
1220
+ // state and the current state are both "in progress", the issue just passed
1221
+ // through code review and back — no new work is needed.
1218
1222
  if (meta.wasUnseen) {
1223
+ const storedState = meta.itemState;
1224
+ const currentState = item.state || item.status;
1225
+ if (storedState && currentState) {
1226
+ const stored = storedState.toLowerCase();
1227
+ const current = currentState.toLowerCase();
1228
+ if (stored === 'in progress' && current === 'in progress') {
1229
+ return false;
1230
+ }
1231
+ }
1219
1232
  return true;
1220
1233
  }
1221
1234
 
@@ -230,10 +230,22 @@ function normalizeSource(source, defaults) {
230
230
  }
231
231
 
232
232
  // Apply defaults (source values take precedence)
233
- return {
233
+ const merged = {
234
234
  ...defaults,
235
235
  ...normalized,
236
236
  };
237
+
238
+ // Track which operational fields were explicitly set in the source (not inherited from defaults).
239
+ // This allows downstream config builders to apply the correct priority:
240
+ // explicit source > repo > defaults
241
+ merged._explicit = {};
242
+ for (const field of ['model', 'agent', 'prompt', 'working_dir']) {
243
+ if (normalized[field] !== undefined) {
244
+ merged._explicit[field] = normalized[field];
245
+ }
246
+ }
247
+
248
+ return merged;
237
249
  }
238
250
 
239
251
  /**
@@ -378,5 +378,107 @@ sources:
378
378
  // Should use source working_dir since repo not in config
379
379
  assert.strictEqual(actionConfig.working_dir, '~/default/path');
380
380
  });
381
+
382
+ test('repo model overrides defaults model', async () => {
383
+ const config = `
384
+ defaults:
385
+ model: anthropic/claude-haiku-3.5
386
+
387
+ repos:
388
+ myorg/backend:
389
+ path: ~/code/backend
390
+ model: anthropic/claude-sonnet-4-20250514
391
+
392
+ sources:
393
+ - preset: github/my-issues
394
+ `;
395
+ writeFileSync(configPath, config);
396
+
397
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
398
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
399
+ loadRepoConfig(configPath);
400
+ const sources = getSources();
401
+
402
+ const item = {
403
+ repository: { nameWithOwner: 'myorg/backend' },
404
+ number: 123,
405
+ title: 'Fix bug',
406
+ url: 'https://github.com/myorg/backend/issues/123'
407
+ };
408
+
409
+ const actionConfig = buildActionConfigForItem(sources[0], item);
410
+
411
+ // Repo model should override the default model
412
+ assert.strictEqual(actionConfig.model, 'anthropic/claude-sonnet-4-20250514',
413
+ 'repo model should override defaults.model');
414
+ });
415
+
416
+ test('explicit source model overrides repo model', async () => {
417
+ const config = `
418
+ defaults:
419
+ model: anthropic/claude-haiku-3.5
420
+
421
+ repos:
422
+ myorg/backend:
423
+ path: ~/code/backend
424
+ model: anthropic/claude-sonnet-4-20250514
425
+
426
+ sources:
427
+ - preset: github/my-issues
428
+ model: anthropic/claude-opus-4
429
+ `;
430
+ writeFileSync(configPath, config);
431
+
432
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
433
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
434
+ loadRepoConfig(configPath);
435
+ const sources = getSources();
436
+
437
+ const item = {
438
+ repository: { nameWithOwner: 'myorg/backend' },
439
+ number: 123,
440
+ title: 'Fix bug',
441
+ url: 'https://github.com/myorg/backend/issues/123'
442
+ };
443
+
444
+ const actionConfig = buildActionConfigForItem(sources[0], item);
445
+
446
+ // Explicit source model should win over repo and defaults
447
+ assert.strictEqual(actionConfig.model, 'anthropic/claude-opus-4',
448
+ 'explicit source model should override repo model and defaults');
449
+ });
450
+
451
+ test('defaults model used when neither source nor repo specifies model', async () => {
452
+ const config = `
453
+ defaults:
454
+ model: anthropic/claude-haiku-3.5
455
+
456
+ repos:
457
+ myorg/backend:
458
+ path: ~/code/backend
459
+
460
+ sources:
461
+ - preset: github/my-issues
462
+ `;
463
+ writeFileSync(configPath, config);
464
+
465
+ const { loadRepoConfig, getSources } = await import('../../service/repo-config.js');
466
+ const { buildActionConfigForItem } = await import('../../service/poll-service.js');
467
+ loadRepoConfig(configPath);
468
+ const sources = getSources();
469
+
470
+ const item = {
471
+ repository: { nameWithOwner: 'myorg/backend' },
472
+ number: 123,
473
+ title: 'Fix bug',
474
+ url: 'https://github.com/myorg/backend/issues/123'
475
+ };
476
+
477
+ const actionConfig = buildActionConfigForItem(sources[0], item);
478
+
479
+ // Should fall back to defaults.model
480
+ assert.strictEqual(actionConfig.model, 'anthropic/claude-haiku-3.5',
481
+ 'defaults model should be used when neither source nor repo sets model');
482
+ });
381
483
  });
382
484
  });
@@ -746,6 +746,25 @@ describe('poller.js', () => {
746
746
  );
747
747
  });
748
748
 
749
+ test('shouldReprocess returns false when Linear issue cycles in_progress -> code_review -> in_progress', async () => {
750
+ const { createPoller } = await import('../../service/poller.js');
751
+
752
+ const poller = createPoller({ stateFile });
753
+ // Issue was processed while in_progress
754
+ poller.markProcessed('linear:ENG-1', { source: 'linear', itemState: 'In Progress' });
755
+
756
+ // Issue moved to In Review - disappears from the "my ready issues" poll
757
+ poller.markUnseen('linear', []);
758
+
759
+ // Issue moved back to In Progress - reappears
760
+ const item = { id: 'linear:ENG-1', status: 'In Progress' };
761
+ assert.strictEqual(
762
+ poller.shouldReprocess(item, { reprocessOn: ['status'] }),
763
+ false,
764
+ 'should NOT reprocess: issue cycled through code review and returned to same in_progress state'
765
+ );
766
+ });
767
+
749
768
  test('shouldReprocess returns true for reappeared item (e.g., uncompleted reminder)', async () => {
750
769
  const { createPoller } = await import('../../service/poller.js');
751
770