gobby 0.2.8__py3-none-any.whl → 0.2.11__py3-none-any.whl

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 (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/storage/skills.py CHANGED
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
23
23
  _UNSET: Any = object()
24
24
 
25
25
  # Valid source types for skills
26
- SkillSourceType = Literal["local", "github", "url", "zip", "filesystem"]
26
+ SkillSourceType = Literal["local", "github", "url", "zip", "filesystem", "hub"]
27
27
 
28
28
 
29
29
  @dataclass
@@ -91,6 +91,8 @@ class Skill:
91
91
 
92
92
  # Gobby-specific
93
93
  enabled: bool = True
94
+ always_apply: bool = False
95
+ injection_format: str = "summary" # "summary", "full", "content"
94
96
  project_id: str | None = None
95
97
 
96
98
  # Timestamps
@@ -131,6 +133,10 @@ class Skill:
131
133
  hub_slug=row["hub_slug"] if "hub_slug" in row.keys() else None,
132
134
  hub_version=row["hub_version"] if "hub_version" in row.keys() else None,
133
135
  enabled=bool(row["enabled"]),
136
+ always_apply=bool(row["always_apply"]) if "always_apply" in row.keys() else False,
137
+ injection_format=row["injection_format"]
138
+ if "injection_format" in row.keys()
139
+ else "summary",
134
140
  project_id=row["project_id"],
135
141
  created_at=row["created_at"],
136
142
  updated_at=row["updated_at"],
@@ -159,6 +165,8 @@ class Skill:
159
165
  "hub_slug": self.hub_slug,
160
166
  "hub_version": self.hub_version,
161
167
  "enabled": self.enabled,
168
+ "always_apply": self.always_apply,
169
+ "injection_format": self.injection_format,
162
170
  "project_id": self.project_id,
163
171
  "created_at": self.created_at,
164
172
  "updated_at": self.updated_at,
@@ -192,9 +200,13 @@ class Skill:
192
200
  def is_always_apply(self) -> bool:
193
201
  """Check if this is a core skill that should always be applied.
194
202
 
195
- Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
196
- Top-level takes precedence.
203
+ Reads from the always_apply column first (set during sync from frontmatter).
204
+ Falls back to metadata for backwards compatibility with older records.
197
205
  """
206
+ # Primary: read from column (set during sync)
207
+ if self.always_apply:
208
+ return True
209
+ # Fallback: check metadata for backwards compatibility
198
210
  if not self.metadata:
199
211
  return False
200
212
  # Check top-level first
@@ -407,6 +419,8 @@ class LocalSkillManager:
407
419
  hub_slug: str | None = None,
408
420
  hub_version: str | None = None,
409
421
  enabled: bool = True,
422
+ always_apply: bool = False,
423
+ injection_format: str = "summary",
410
424
  project_id: str | None = None,
411
425
  ) -> Skill:
412
426
  """Create a new skill.
@@ -427,6 +441,8 @@ class LocalSkillManager:
427
441
  hub_slug: Optional hub slug
428
442
  hub_version: Optional hub version
429
443
  enabled: Whether skill is active
444
+ always_apply: Whether skill should always be injected at session start
445
+ injection_format: How to inject skill (summary, full, content)
430
446
  project_id: Project scope (None for global)
431
447
 
432
448
  Returns:
@@ -457,8 +473,9 @@ class LocalSkillManager:
457
473
  id, name, description, content, version, license,
458
474
  compatibility, allowed_tools, metadata, source_path,
459
475
  source_type, source_ref, hub_name, hub_slug, hub_version,
460
- enabled, project_id, created_at, updated_at
461
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
476
+ enabled, always_apply, injection_format, project_id,
477
+ created_at, updated_at
478
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
462
479
  """,
463
480
  (
464
481
  skill_id,
@@ -477,6 +494,8 @@ class LocalSkillManager:
477
494
  hub_slug,
478
495
  hub_version,
479
496
  enabled,
497
+ always_apply,
498
+ injection_format,
480
499
  project_id,
481
500
  now,
482
501
  now,
@@ -559,6 +578,8 @@ class LocalSkillManager:
559
578
  hub_slug: str | None = _UNSET,
560
579
  hub_version: str | None = _UNSET,
561
580
  enabled: bool | None = None,
581
+ always_apply: bool | None = None,
582
+ injection_format: str | None = None,
562
583
  ) -> Skill:
563
584
  """Update an existing skill.
564
585
 
@@ -579,6 +600,8 @@ class LocalSkillManager:
579
600
  hub_slug: New hub slug (use _UNSET to leave unchanged, None to clear)
580
601
  hub_version: New hub version (use _UNSET to leave unchanged, None to clear)
581
602
  enabled: New enabled state (optional)
603
+ always_apply: New always_apply state (optional)
604
+ injection_format: New injection format (optional)
582
605
 
583
606
  Returns:
584
607
  The updated Skill
@@ -634,6 +657,12 @@ class LocalSkillManager:
634
657
  if enabled is not None:
635
658
  updates.append("enabled = ?")
636
659
  params.append(enabled)
660
+ if always_apply is not None:
661
+ updates.append("always_apply = ?")
662
+ params.append(always_apply)
663
+ if injection_format is not None:
664
+ updates.append("injection_format = ?")
665
+ params.append(injection_format)
637
666
 
638
667
  if not updates:
639
668
  return self.get_skill(skill_id)
@@ -770,7 +799,7 @@ class LocalSkillManager:
770
799
  return [Skill.from_row(row) for row in rows]
771
800
 
772
801
  def list_core_skills(self, project_id: str | None = None) -> list[Skill]:
773
- """List skills with alwaysApply=true.
802
+ """List skills with always_apply=true (efficiently via column query).
774
803
 
775
804
  Args:
776
805
  project_id: Optional project scope
@@ -778,8 +807,19 @@ class LocalSkillManager:
778
807
  Returns:
779
808
  List of core skills (always-apply skills)
780
809
  """
781
- skills = self.list_skills(project_id=project_id, enabled=True, limit=1000)
782
- return [s for s in skills if s.is_always_apply()]
810
+ query = "SELECT * FROM skills WHERE always_apply = 1 AND enabled = 1"
811
+ params: list[Any] = []
812
+
813
+ if project_id:
814
+ query += " AND (project_id = ? OR project_id IS NULL)"
815
+ params.append(project_id)
816
+ else:
817
+ query += " AND project_id IS NULL"
818
+
819
+ query += " ORDER BY name ASC"
820
+
821
+ rows = self.db.fetchall(query, tuple(params))
822
+ return [Skill.from_row(row) for row in rows]
783
823
 
784
824
  def skill_exists(self, skill_id: str) -> bool:
785
825
  """Check if a skill with the given ID exists.
@@ -97,7 +97,7 @@ def count_ready_tasks(
97
97
  -- Blocker is unresolved if not closed AND not in review without requiring user review
98
98
  AND NOT (
99
99
  blocker.status = 'closed'
100
- OR (blocker.status = 'review' AND blocker.requires_user_review = 0)
100
+ OR (blocker.status = 'needs_review' AND blocker.requires_user_review = 0)
101
101
  )
102
102
  -- Exclude ancestor blocked by any descendant (completion block, not work block)
103
103
  -- Check if t.id appears anywhere in blocker's ancestor chain
@@ -153,7 +153,7 @@ def count_blocked_tasks(
153
153
  -- Blocker is unresolved if not closed AND not in review without requiring user review
154
154
  AND NOT (
155
155
  blocker.status = 'closed'
156
- OR (blocker.status = 'review' AND blocker.requires_user_review = 0)
156
+ OR (blocker.status = 'needs_review' AND blocker.requires_user_review = 0)
157
157
  )
158
158
  -- Exclude ancestor blocked by any descendant (completion block, not work block)
159
159
  -- Check if t.id appears anywhere in blocker's ancestor chain
@@ -57,9 +57,9 @@ def close_task(
57
57
  f"Cannot close task {task_id}: has {len(open_children)} open child task(s): {child_list}"
58
58
  )
59
59
 
60
- # Check if task is being closed from review state (user acceptance)
60
+ # Check if task is being closed from needs_review state (user acceptance)
61
61
  current_task = get_task(db, task_id)
62
- accepted_by_user = current_task.status == "review" if current_task else False
62
+ accepted_by_user = current_task.status == "needs_review" if current_task else False
63
63
 
64
64
  now = datetime.now(UTC).isoformat()
65
65
  with db.transaction() as conn:
@@ -117,8 +117,8 @@ def reopen_task(
117
117
  ValueError: If task not found or not closed/review
118
118
  """
119
119
  task = get_task(db, task_id)
120
- if task.status not in ("closed", "review"):
121
- raise ValueError(f"Task {task_id} is not closed or in review (status: {task.status})")
120
+ if task.status not in ("closed", "needs_review"):
121
+ raise ValueError(f"Task {task_id} is not closed or in needs_review (status: {task.status})")
122
122
 
123
123
  now = datetime.now(UTC).isoformat()
124
124
 
@@ -82,7 +82,13 @@ class Task:
82
82
  project_id: str
83
83
  title: str
84
84
  status: Literal[
85
- "open", "in_progress", "review", "closed", "failed", "escalated", "needs_decomposition"
85
+ "open",
86
+ "in_progress",
87
+ "needs_review",
88
+ "closed",
89
+ "failed",
90
+ "escalated",
91
+ "needs_decomposition",
86
92
  ]
87
93
  priority: int
88
94
  task_type: str # bug, feature, task, epic, chore
@@ -156,7 +156,7 @@ def list_ready_tasks(
156
156
  -- Blocker is unresolved if not closed AND not in review without requiring user review
157
157
  AND NOT (
158
158
  blocker.status = 'closed'
159
- OR (blocker.status = 'review' AND blocker.requires_user_review = 0)
159
+ OR (blocker.status = 'needs_review' AND blocker.requires_user_review = 0)
160
160
  )
161
161
  -- Exclude ancestor blocked by any descendant (completion block, not work block)
162
162
  AND NOT EXISTS (
@@ -186,7 +186,7 @@ def list_ready_tasks(
186
186
  -- Blocker is unresolved if not closed AND not in review without requiring user review
187
187
  AND NOT (
188
188
  blocker.status = 'closed'
189
- OR (blocker.status = 'review' AND blocker.requires_user_review = 0)
189
+ OR (blocker.status = 'needs_review' AND blocker.requires_user_review = 0)
190
190
  )
191
191
  -- Exclude ancestor blocked by any descendant (completion block, not work block)
192
192
  AND NOT EXISTS (
@@ -266,7 +266,7 @@ def list_blocked_tasks(
266
266
  -- Blocker is unresolved if not closed AND not in review without requiring user review
267
267
  AND NOT (
268
268
  blocker.status = 'closed'
269
- OR (blocker.status = 'review' AND blocker.requires_user_review = 0)
269
+ OR (blocker.status = 'needs_review' AND blocker.requires_user_review = 0)
270
270
  )
271
271
  -- Exclude ancestor blocked by any descendant (completion block, not work block)
272
272
  AND NOT EXISTS (
gobby/sync/memories.py CHANGED
@@ -131,8 +131,8 @@ class MemoryBackupManager:
131
131
  from gobby.utils.project_context import get_project_context
132
132
 
133
133
  project_ctx = get_project_context()
134
- if project_ctx and project_ctx.get("path"):
135
- project_path = Path(project_ctx["path"]).expanduser().resolve()
134
+ if project_ctx and project_ctx.get("project_path"):
135
+ project_path = Path(project_ctx["project_path"]).expanduser().resolve()
136
136
  return project_path / self.export_path
137
137
  except Exception:
138
138
  pass # nosec B110 - fall back to cwd if project context unavailable
@@ -289,7 +289,8 @@ class MemoryBackupManager:
289
289
  return 0
290
290
 
291
291
  try:
292
- memories = self.memory_manager.list_memories()
292
+ # Use high limit to export all memories for backup (default is 50)
293
+ memories = self.memory_manager.list_memories(limit=10000)
293
294
 
294
295
  # Deduplicate by content before export
295
296
  unique_memories = self._deduplicate_memories(memories)
gobby/tasks/commits.py CHANGED
@@ -487,30 +487,49 @@ def extract_mentioned_symbols(task: dict[str, Any]) -> list[str]:
487
487
 
488
488
 
489
489
  # Task ID patterns to search for in commit messages
490
- # Supports #N format (e.g., #1, #47) - human-friendly task references
490
+ # Uses {project}-#N format to avoid GitHub auto-linking and match CLI display format
491
+ # Patterns capture both project name and task number for validation
491
492
  TASK_ID_PATTERNS = [
492
- # [#N] - bracket format
493
- r"\[#(\d+)\]",
494
- # #N: - hash-colon format (at start of line or after space)
495
- r"(?:^|\s)#(\d+):",
496
- # Implements/Fixes/Closes/Refs #N (supports multiple: #1, #2, #3)
497
- r"(?:implements|fixes|closes|refs)\s+#(\d+)",
498
- # Standalone #N after whitespace (with word boundary to avoid false positives)
499
- r"(?:^|\s)#(\d+)\b(?![\d.])",
493
+ # [project-#N] - bracket format (primary)
494
+ r"\[(\w+)-#(\d+)\]",
495
+ # project-#N - standalone format (word boundary before, after digits)
496
+ r"(?:^|\s)(\w+)-#(\d+)\b",
497
+ # Implements/Fixes/Closes/Refs project-#N
498
+ r"(?:implements|fixes|closes|refs)\s+(\w+)-#(\d+)",
500
499
  ]
501
500
 
502
501
 
503
- def extract_task_ids_from_message(message: str) -> list[str]:
502
+ def get_current_project_name() -> str | None:
503
+ """Get current project name from context.
504
+
505
+ Returns:
506
+ Project name or None if not in a project.
507
+ """
508
+ from gobby.utils.project_context import get_project_context
509
+
510
+ ctx = get_project_context()
511
+ if ctx and ctx.get("name"):
512
+ name: str = ctx["name"]
513
+ return name
514
+ return None
515
+
516
+
517
+ def extract_task_ids_from_message(
518
+ message: str,
519
+ project_name: str | None = None,
520
+ ) -> list[str]:
504
521
  """Extract task IDs from a commit message.
505
522
 
506
523
  Supports patterns:
507
- - [#N] - bracket format
508
- - #N: - hash-colon format (at start of message)
509
- - Implements/Fixes/Closes/Refs #N
510
- - Multiple references: #1, #2, #3
524
+ - [project-#N] - bracket format (primary)
525
+ - project-#N - standalone format
526
+ - Implements/Fixes/Closes/Refs project-#N
511
527
 
512
528
  Args:
513
529
  message: Commit message to parse.
530
+ project_name: Optional project name to filter matches. If provided,
531
+ only returns task IDs from commits referencing this project.
532
+ If None, returns all task IDs found regardless of project.
514
533
 
515
534
  Returns:
516
535
  List of unique task references found (e.g., ["#1", "#42"]).
@@ -520,8 +539,13 @@ def extract_task_ids_from_message(message: str) -> list[str]:
520
539
  for pattern in TASK_ID_PATTERNS:
521
540
  matches = re.findall(pattern, message, re.IGNORECASE | re.MULTILINE)
522
541
  for match in matches:
542
+ # match is a tuple: (project, task_number)
543
+ found_project, task_num = match
544
+ # Filter by project name if specified
545
+ if project_name and found_project.lower() != project_name.lower():
546
+ continue
523
547
  # Format as #N
524
- task_id = f"#{match}"
548
+ task_id = f"#{task_num}"
525
549
  task_ids.add(task_id)
526
550
 
527
551
  return list(task_ids)
@@ -547,6 +571,7 @@ def auto_link_commits(
547
571
  task_id: str | None = None,
548
572
  since: str | None = None,
549
573
  cwd: str | Path | None = None,
574
+ project_name: str | None = None,
550
575
  ) -> AutoLinkResult:
551
576
  """Auto-detect and link commits that mention task IDs.
552
577
 
@@ -558,12 +583,18 @@ def auto_link_commits(
558
583
  task_id: Optional specific task ID to filter for.
559
584
  since: Optional git --since parameter (e.g., "1 week ago", "2024-01-01").
560
585
  cwd: Working directory for git commands.
586
+ project_name: Optional project name to filter commits. If not provided,
587
+ auto-detects from current project context.
561
588
 
562
589
  Returns:
563
590
  AutoLinkResult with details of linked and skipped commits.
564
591
  """
565
592
  working_dir = Path(cwd) if cwd else Path.cwd()
566
593
 
594
+ # Get project name for filtering (auto-detect if not provided)
595
+ if project_name is None:
596
+ project_name = get_current_project_name()
597
+
567
598
  # Build git log command
568
599
  # Format: "sha|message" for easy parsing
569
600
  git_cmd = ["git", "log", "--pretty=format:%h|%s"]
@@ -590,8 +621,8 @@ def auto_link_commits(
590
621
 
591
622
  commit_sha, message = parts
592
623
 
593
- # Extract task IDs from message
594
- found_task_ids = extract_task_ids_from_message(message)
624
+ # Extract task IDs from message (filtered by project name)
625
+ found_task_ids = extract_task_ids_from_message(message, project_name)
595
626
 
596
627
  if not found_task_ids:
597
628
  continue
@@ -46,12 +46,7 @@ if TYPE_CHECKING:
46
46
  logger = logging.getLogger(__name__)
47
47
 
48
48
  # Default system prompt for external validators
49
- DEFAULT_EXTERNAL_SYSTEM_PROMPT = (
50
- "You are an objective QA validator reviewing code changes. "
51
- "You have no prior context about this task - evaluate purely based on "
52
- "the acceptance criteria and the changes provided. "
53
- "Be thorough but fair in your assessment."
54
- )
49
+
55
50
 
56
51
  # Module-level loader (initialized lazily)
57
52
  _loader: PromptLoader | None = None
@@ -62,10 +57,7 @@ def _get_loader(project_dir: Path | None = None) -> PromptLoader:
62
57
  global _loader
63
58
  if _loader is None:
64
59
  _loader = PromptLoader(project_dir=project_dir)
65
- # Register fallbacks for strangler fig pattern
66
- _loader.register_fallback(
67
- "external_validation/system", lambda: DEFAULT_EXTERNAL_SYSTEM_PROMPT
68
- )
60
+
69
61
  return _loader
70
62
 
71
63
 
@@ -218,13 +210,8 @@ async def _run_llm_validation(
218
210
  # Build the validation prompt
219
211
  prompt = _build_external_validation_prompt(task, changes_context)
220
212
 
221
- # System prompt emphasizing objectivity
222
- system_prompt = (
223
- "You are an objective QA validator reviewing code changes. "
224
- "You have no prior context about this task - evaluate purely based on "
225
- "the acceptance criteria and the changes provided. "
226
- "Be thorough but fair in your assessment."
227
- )
213
+ # Render system prompt
214
+ system_prompt = _get_loader().render("external_validation/system", {})
228
215
 
229
216
  try:
230
217
  provider = llm_service.get_provider(config.provider)
gobby/tasks/validation.py CHANGED
@@ -27,51 +27,6 @@ from gobby.utils.json_helpers import extract_json_object
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
30
- # Default prompts (fallbacks for strangler fig pattern)
31
- DEFAULT_VALIDATE_PROMPT = """Validate if the following changes satisfy the requirements.
32
-
33
- Task: {title}
34
- {category_section}{criteria_text}
35
-
36
- {changes_section}
37
- IMPORTANT: Return ONLY a JSON object, nothing else. No explanation, no preamble.
38
- Format: {{"status": "valid", "feedback": "..."}} or {{"status": "invalid", "feedback": "..."}}
39
- """
40
-
41
- DEFAULT_CRITERIA_PROMPT = """Generate validation criteria for this task.
42
-
43
- Task: {title}
44
- Description: {description}
45
-
46
- CRITICAL RULES - You MUST follow these:
47
- 1. **Only stated requirements** - Include ONLY requirements explicitly written in the title or description
48
- 2. **No invented values** - Do NOT invent specific numbers, timeouts, thresholds, or limits unless they appear in the task
49
- 3. **No invented edge cases** - Do NOT add edge cases, error scenarios, or boundary conditions beyond what's described
50
- 4. **Proportional detail** - Vague tasks get vague criteria; detailed tasks get detailed criteria
51
- 5. **When in doubt, leave it out** - If something isn't mentioned, don't include it
52
-
53
- For vague requirements like "fix X" or "add Y", use criteria like:
54
- - "X no longer produces the reported error/warning"
55
- - "Y functionality works as expected"
56
- - "Existing tests continue to pass"
57
- - "No regressions introduced"
58
-
59
- DO NOT generate criteria like:
60
- - "timeout defaults to 30 seconds" (unless 30 seconds is in the task description)
61
- - "handles edge case Z" (unless Z is mentioned in the task)
62
- - "logs with format X" (unless that format is specified)
63
-
64
- Format as markdown checkboxes:
65
- ## Deliverable
66
- - [ ] What the task explicitly asks for
67
-
68
- ## Functional Requirements
69
- - [ ] Only requirements stated in the description
70
-
71
- ## Verification
72
- - [ ] Tests pass (if applicable)
73
- - [ ] No regressions
74
- """
75
30
 
76
31
  # Default number of commits to look back when gathering context
77
32
  DEFAULT_COMMIT_WINDOW = 10
@@ -490,10 +445,6 @@ class TaskValidator:
490
445
  self.llm_service = llm_service
491
446
  self._loader = PromptLoader(project_dir=project_dir)
492
447
 
493
- # Register fallbacks for strangler fig pattern
494
- self._loader.register_fallback("validation/validate", lambda: DEFAULT_VALIDATE_PROMPT)
495
- self._loader.register_fallback("validation/criteria", lambda: DEFAULT_CRITERIA_PROMPT)
496
-
497
448
  async def gather_validation_context(self, file_paths: list[str]) -> str:
498
449
  """
499
450
  Gather context for validation from files.
@@ -588,35 +539,16 @@ class TaskValidator:
588
539
  else:
589
540
  category_section += "\n"
590
541
 
591
- # Build prompt using PromptLoader or legacy config
592
- if self.config.prompt_path:
593
- prompt_path = self.config.prompt_path
594
- template_context = {
595
- "title": title,
596
- "category_section": category_section,
597
- "criteria_text": criteria_text,
598
- "changes_section": changes_section,
599
- "file_context": file_context[:50000] if file_context else "",
600
- }
601
- try:
602
- prompt = self._loader.render(prompt_path, template_context)
603
- except FileNotFoundError:
604
- logger.debug(f"Prompt template '{prompt_path}' not found, using fallback")
605
- prompt = DEFAULT_VALIDATE_PROMPT.format(**template_context)
606
- if file_context:
607
- prompt += f"\nFile Context:\n{file_context[:50000]}\n"
608
- else:
609
- # Default behavior
610
- template_context = {
611
- "title": title,
612
- "category_section": category_section,
613
- "criteria_text": criteria_text,
614
- "changes_section": changes_section,
615
- "file_context": file_context[:50000] if file_context else "",
616
- }
617
- prompt = DEFAULT_VALIDATE_PROMPT.format(**template_context)
618
- if file_context:
619
- prompt += f"\nFile Context:\n{file_context[:50000]}\n"
542
+ # Build prompt using PromptLoader
543
+ prompt_path = self.config.prompt_path or "validation/validate"
544
+ template_context = {
545
+ "title": title,
546
+ "category_section": category_section,
547
+ "criteria_text": criteria_text,
548
+ "changes_section": changes_section,
549
+ "file_context": file_context[:50000] if file_context else "",
550
+ }
551
+ prompt = self._loader.render(prompt_path, template_context)
620
552
 
621
553
  try:
622
554
  provider = self.llm_service.get_provider(self.config.provider)
@@ -670,19 +602,13 @@ class TaskValidator:
670
602
  if not self.config.enabled:
671
603
  return None
672
604
 
673
- # Build prompt using PromptLoader or legacy config
605
+ # Use PromptLoader
606
+ prompt_path = self.config.criteria_prompt_path or "validation/criteria"
674
607
  template_context = {
675
608
  "title": title,
676
609
  "description": description or "(no description)",
677
610
  }
678
-
679
- # Use PromptLoader
680
- prompt_path = self.config.criteria_prompt_path or "validation/criteria"
681
- try:
682
- prompt = self._loader.render(prompt_path, template_context)
683
- except FileNotFoundError:
684
- logger.debug(f"Prompt template '{prompt_path}' not found, using fallback")
685
- prompt = DEFAULT_CRITERIA_PROMPT.format(**template_context)
611
+ prompt = self._loader.render(prompt_path, template_context)
686
612
 
687
613
  try:
688
614
  provider = self.llm_service.get_provider(self.config.provider)
gobby/tools/summarizer.py CHANGED
@@ -23,25 +23,6 @@ MAX_DESCRIPTION_LENGTH = 200
23
23
  _config: ToolSummarizerConfig | None = None
24
24
  _loader: PromptLoader | None = None
25
25
 
26
- DEFAULT_SUMMARY_PROMPT = """Summarize this MCP tool description in 180 characters or less.
27
- Keep it to three sentences or less. Be concise and preserve the key functionality.
28
- Do not add quotes, extra formatting, or code examples.
29
-
30
- Description: {description}
31
-
32
- Summary:"""
33
-
34
- DEFAULT_SUMMARY_SYSTEM_PROMPT = "You are a technical summarizer. Create concise tool descriptions."
35
-
36
- DEFAULT_SERVER_DESC_PROMPT = """Write a single concise sentence describing what the '{server_name}' MCP server does based on its tools.
37
-
38
- Tools:
39
- {tools_list}
40
-
41
- Description (1 sentence, try to keep under 100 characters):"""
42
-
43
- DEFAULT_SERVER_DESC_SYSTEM_PROMPT = "You write concise technical descriptions."
44
-
45
26
 
46
27
  def init_summarizer_config(config: ToolSummarizerConfig, project_dir: str | None = None) -> None:
47
28
  """Initialize the summarizer with configuration."""
@@ -50,13 +31,6 @@ def init_summarizer_config(config: ToolSummarizerConfig, project_dir: str | None
50
31
  global _config, _loader
51
32
  _config = config
52
33
  _loader = PromptLoader(project_dir=Path(project_dir) if project_dir else None)
53
- # Register fallbacks
54
- _loader.register_fallback("features/tool_summary", lambda: DEFAULT_SUMMARY_PROMPT)
55
- _loader.register_fallback("features/tool_summary_system", lambda: DEFAULT_SUMMARY_SYSTEM_PROMPT)
56
- _loader.register_fallback("features/server_description", lambda: DEFAULT_SERVER_DESC_PROMPT)
57
- _loader.register_fallback(
58
- "features/server_description_system", lambda: DEFAULT_SERVER_DESC_SYSTEM_PROMPT
59
- )
60
34
 
61
35
 
62
36
  def _get_config() -> ToolSummarizerConfig:
@@ -96,9 +70,9 @@ async def _summarize_description_with_claude(description: str) -> str:
96
70
  if _loader is None:
97
71
  raise RuntimeError("Summarizer not initialized")
98
72
  prompt = _loader.render(prompt_path, {"description": description})
99
- except (FileNotFoundError, OSError, KeyError, ValueError, RuntimeError) as e:
100
- logger.debug(f"Failed to load prompt from {prompt_path}: {e}, using default")
101
- prompt = DEFAULT_SUMMARY_PROMPT.format(description=description)
73
+ except (OSError, KeyError, ValueError, RuntimeError) as e:
74
+ logger.debug(f"Failed to load prompt from {prompt_path}: {e}")
75
+ raise
102
76
 
103
77
  # Get system prompt
104
78
  sys_prompt_path = config.system_prompt_path or "features/tool_summary_system"
@@ -106,9 +80,9 @@ async def _summarize_description_with_claude(description: str) -> str:
106
80
  if _loader is None:
107
81
  raise RuntimeError("Summarizer not initialized")
108
82
  system_prompt = _loader.render(sys_prompt_path, {})
109
- except (FileNotFoundError, OSError, KeyError, ValueError, RuntimeError) as e:
110
- logger.debug(f"Failed to load system prompt from {sys_prompt_path}: {e}, using default")
111
- system_prompt = DEFAULT_SUMMARY_SYSTEM_PROMPT
83
+ except (OSError, KeyError, ValueError, RuntimeError) as e:
84
+ logger.debug(f"Failed to load system prompt from {sys_prompt_path}: {e}")
85
+ system_prompt = "You are a technical summarizer."
112
86
 
113
87
  # Configure for single-turn completion
114
88
  options = ClaudeAgentOptions(
@@ -198,30 +172,23 @@ async def generate_server_description(
198
172
  "server_name": server_name,
199
173
  "tools_list": tools_list,
200
174
  }
201
- try:
202
- if _loader is None:
203
- _get_config() # force init
204
- if _loader is None:
205
- # Still None after _get_config, use default
206
- prompt = DEFAULT_SERVER_DESC_PROMPT.format(**context)
207
- else:
208
- prompt = _loader.render(prompt_path, context)
209
- except (FileNotFoundError, OSError, KeyError, ValueError, RuntimeError) as e:
210
- logger.debug(f"Failed to load prompt from {prompt_path}: {e}, using default")
211
- prompt = DEFAULT_SERVER_DESC_PROMPT.format(**context)
175
+ if _loader is None:
176
+ _get_config() # force init
177
+ if _loader is None:
178
+ # Still None after _get_config, use default
179
+ raise RuntimeError("Summarizer not initialized")
180
+ else:
181
+ prompt = _loader.render(prompt_path, context)
212
182
 
213
183
  # Get system prompt
214
184
  sys_prompt_path = (
215
185
  config.server_description_system_prompt_path or "features/server_description_system"
216
186
  )
217
- try:
218
- if _loader is None:
219
- system_prompt = DEFAULT_SERVER_DESC_SYSTEM_PROMPT
220
- else:
221
- system_prompt = _loader.render(sys_prompt_path, {})
222
- except (FileNotFoundError, OSError, KeyError, ValueError, RuntimeError) as e:
223
- logger.debug(f"Failed to load system prompt from {sys_prompt_path}: {e}, using default")
224
- system_prompt = DEFAULT_SERVER_DESC_SYSTEM_PROMPT
187
+
188
+ if _loader is None:
189
+ system_prompt = "You write concise technical descriptions."
190
+ else:
191
+ system_prompt = _loader.render(sys_prompt_path, {})
225
192
 
226
193
  # Configure for single-turn completion
227
194
  options = ClaudeAgentOptions(