gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
gobby/storage/sessions.py CHANGED
@@ -52,6 +52,8 @@ class Session:
52
52
  terminal_context: dict[str, Any] | None = None
53
53
  # Global sequence number
54
54
  seq_num: int | None = None
55
+ # Edit history tracking
56
+ had_edits: bool = False
55
57
 
56
58
  @classmethod
57
59
  def from_row(cls, row: Any) -> Session:
@@ -86,6 +88,7 @@ class Session:
86
88
  model=row["model"] if "model" in row.keys() else None,
87
89
  terminal_context=cls._parse_terminal_context(row["terminal_context"]),
88
90
  seq_num=row["seq_num"] if "seq_num" in row.keys() else None,
91
+ had_edits=bool(row["had_edits"]) if "had_edits" in row.keys() else False,
89
92
  )
90
93
 
91
94
  @classmethod
@@ -135,6 +138,7 @@ class Session:
135
138
  "usage_cache_read_tokens": self.usage_cache_read_tokens,
136
139
  "usage_total_cost_usd": self.usage_total_cost_usd,
137
140
  "terminal_context": self.terminal_context,
141
+ "had_edits": self.had_edits,
138
142
  "created_at": self.created_at,
139
143
  "updated_at": self.updated_at,
140
144
  "seq_num": self.seq_num,
@@ -162,6 +166,7 @@ class LocalSessionManager:
162
166
  agent_depth: int = 0,
163
167
  spawned_by_agent_id: str | None = None,
164
168
  terminal_context: dict[str, Any] | None = None,
169
+ workflow_name: str | None = None,
165
170
  ) -> Session:
166
171
  """
167
172
  Register a new session or return existing one.
@@ -224,8 +229,11 @@ class LocalSessionManager:
224
229
  max_retries = 3
225
230
  for attempt in range(max_retries):
226
231
  try:
227
- # Get next seq_num (global)
228
- max_seq_row = self.db.fetchone("SELECT MAX(seq_num) as max_seq FROM sessions")
232
+ # Get next seq_num (per-project)
233
+ max_seq_row = self.db.fetchone(
234
+ "SELECT MAX(seq_num) as max_seq FROM sessions WHERE project_id = ?",
235
+ (project_id,),
236
+ )
229
237
  next_seq_num = ((max_seq_row["max_seq"] if max_seq_row else None) or 0) + 1
230
238
 
231
239
  self.db.execute(
@@ -234,9 +242,9 @@ class LocalSessionManager:
234
242
  id, external_id, machine_id, source, project_id, title,
235
243
  jsonl_path, git_branch, parent_session_id,
236
244
  agent_depth, spawned_by_agent_id, terminal_context,
237
- status, created_at, updated_at, seq_num
245
+ workflow_name, status, created_at, updated_at, seq_num, had_edits
238
246
  )
239
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 0)
240
248
  """,
241
249
  (
242
250
  session_id,
@@ -251,6 +259,7 @@ class LocalSessionManager:
251
259
  agent_depth,
252
260
  spawned_by_agent_id,
253
261
  json.dumps(terminal_context) if terminal_context else None,
262
+ workflow_name,
254
263
  now,
255
264
  now,
256
265
  next_seq_num,
@@ -279,18 +288,20 @@ class LocalSessionManager:
279
288
  row = self.db.fetchone("SELECT * FROM sessions WHERE id = ?", (session_id,))
280
289
  return Session.from_row(row) if row else None
281
290
 
282
- def resolve_session_reference(self, ref: str) -> str:
291
+ def resolve_session_reference(self, ref: str, project_id: str | None = None) -> str:
283
292
  """
284
293
  Resolve a session reference to a UUID.
285
294
 
286
295
  Supports:
287
- - #N: Global Sequence Number (e.g., #1)
296
+ - #N: Project-scoped Sequence Number (e.g., #1) - requires project_id
288
297
  - N: Integer string treated as #N (e.g., "1")
289
298
  - UUID: Full UUID
290
299
  - Prefix: UUID prefix (must be unambiguous)
291
300
 
292
301
  Args:
293
302
  ref: Session reference string
303
+ project_id: Project ID for project-scoped #N lookup.
304
+ If not provided, falls back to global lookup for backwards compat.
294
305
 
295
306
  Returns:
296
307
  Resolved Session UUID
@@ -308,7 +319,15 @@ class LocalSessionManager:
308
319
 
309
320
  if seq_num_ref.isdigit():
310
321
  seq_num = int(seq_num_ref)
311
- row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
322
+ if project_id:
323
+ # Project-scoped lookup
324
+ row = self.db.fetchone(
325
+ "SELECT id FROM sessions WHERE project_id = ? AND seq_num = ?",
326
+ (project_id, seq_num),
327
+ )
328
+ else:
329
+ # Fallback to global lookup for backwards compat
330
+ row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
312
331
  if not row:
313
332
  raise ValueError(f"Session #{seq_num} not found")
314
333
  return str(row["id"])
@@ -426,6 +445,15 @@ class LocalSessionManager:
426
445
  )
427
446
  return self.get(session_id)
428
447
 
448
+ def mark_had_edits(self, session_id: str) -> Session | None:
449
+ """Mark session as having edits."""
450
+ now = datetime.now(UTC).isoformat()
451
+ self.db.execute(
452
+ "UPDATE sessions SET had_edits = 1, updated_at = ? WHERE id = ?",
453
+ (now, session_id),
454
+ )
455
+ return self.get(session_id)
456
+
429
457
  def update_title(self, session_id: str, title: str) -> Session | None:
430
458
  """Update session title."""
431
459
  now = datetime.now(UTC).isoformat()
@@ -435,6 +463,16 @@ class LocalSessionManager:
435
463
  )
436
464
  return self.get(session_id)
437
465
 
466
+ def update_model(self, session_id: str, model: str) -> Session | None:
467
+ """Update session model (LLM model used)."""
468
+ now = datetime.now(UTC).isoformat()
469
+ with self.db.transaction():
470
+ self.db.execute(
471
+ "UPDATE sessions SET model = ?, updated_at = ? WHERE id = ?",
472
+ (model, now, session_id),
473
+ )
474
+ return self.get(session_id)
475
+
438
476
  def update_summary(
439
477
  self,
440
478
  session_id: str,
gobby/storage/skills.py CHANGED
@@ -52,6 +52,11 @@ class Skill:
52
52
  - source_type: 'local', 'github', 'url', 'zip', 'filesystem'
53
53
  - source_ref: Git ref for updates (branch/tag/commit)
54
54
 
55
+ Hub Tracking:
56
+ - hub_name: Name of the hub the skill originated from
57
+ - hub_slug: Slug of the hub the skill originated from
58
+ - hub_version: Version of the skill as reported by the hub
59
+
55
60
  Gobby-specific:
56
61
  - enabled: Toggle skill on/off without removing
57
62
  - project_id: NULL for global, else project-scoped
@@ -79,6 +84,11 @@ class Skill:
79
84
  source_type: SkillSourceType | None = None
80
85
  source_ref: str | None = None
81
86
 
87
+ # Hub Tracking
88
+ hub_name: str | None = None
89
+ hub_slug: str | None = None
90
+ hub_version: str | None = None
91
+
82
92
  # Gobby-specific
83
93
  enabled: bool = True
84
94
  project_id: str | None = None
@@ -117,6 +127,9 @@ class Skill:
117
127
  source_path=row["source_path"],
118
128
  source_type=row["source_type"],
119
129
  source_ref=row["source_ref"],
130
+ hub_name=row["hub_name"] if "hub_name" in row.keys() else None,
131
+ hub_slug=row["hub_slug"] if "hub_slug" in row.keys() else None,
132
+ hub_version=row["hub_version"] if "hub_version" in row.keys() else None,
120
133
  enabled=bool(row["enabled"]),
121
134
  project_id=row["project_id"],
122
135
  created_at=row["created_at"],
@@ -142,6 +155,9 @@ class Skill:
142
155
  "source_path": self.source_path,
143
156
  "source_type": self.source_type,
144
157
  "source_ref": self.source_ref,
158
+ "hub_name": self.hub_name,
159
+ "hub_slug": self.hub_slug,
160
+ "hub_version": self.hub_version,
145
161
  "enabled": self.enabled,
146
162
  "project_id": self.project_id,
147
163
  "created_at": self.created_at,
@@ -149,9 +165,18 @@ class Skill:
149
165
  }
150
166
 
151
167
  def get_category(self) -> str | None:
152
- """Get the skill category from metadata.skillport.category."""
168
+ """Get the skill category from top-level or metadata.skillport.category.
169
+
170
+ Supports both top-level category and nested metadata.skillport.category.
171
+ Top-level takes precedence.
172
+ """
153
173
  if not self.metadata:
154
174
  return None
175
+ # Check top-level first
176
+ result = self.metadata.get("category")
177
+ if result is not None:
178
+ return str(result)
179
+ # Fall back to nested skillport.category
155
180
  skillport = self.metadata.get("skillport", {})
156
181
  result = skillport.get("category")
157
182
  return str(result) if result is not None else None
@@ -165,9 +190,18 @@ class Skill:
165
190
  return list(tags) if isinstance(tags, list) else []
166
191
 
167
192
  def is_always_apply(self) -> bool:
168
- """Check if this is a core skill that should always be applied."""
193
+ """Check if this is a core skill that should always be applied.
194
+
195
+ Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
196
+ Top-level takes precedence.
197
+ """
169
198
  if not self.metadata:
170
199
  return False
200
+ # Check top-level first
201
+ top_level = self.metadata.get("alwaysApply")
202
+ if top_level is not None:
203
+ return bool(top_level)
204
+ # Fall back to nested skillport.alwaysApply
171
205
  skillport = self.metadata.get("skillport", {})
172
206
  return bool(skillport.get("alwaysApply", False))
173
207
 
@@ -369,6 +403,9 @@ class LocalSkillManager:
369
403
  source_path: str | None = None,
370
404
  source_type: SkillSourceType | None = None,
371
405
  source_ref: str | None = None,
406
+ hub_name: str | None = None,
407
+ hub_slug: str | None = None,
408
+ hub_version: str | None = None,
372
409
  enabled: bool = True,
373
410
  project_id: str | None = None,
374
411
  ) -> Skill:
@@ -386,6 +423,9 @@ class LocalSkillManager:
386
423
  source_path: Original file path or URL
387
424
  source_type: Source type ('local', 'github', 'url', 'zip', 'filesystem')
388
425
  source_ref: Git ref for updates
426
+ hub_name: Optional hub name
427
+ hub_slug: Optional hub slug
428
+ hub_version: Optional hub version
389
429
  enabled: Whether skill is active
390
430
  project_id: Project scope (None for global)
391
431
 
@@ -416,9 +456,9 @@ class LocalSkillManager:
416
456
  INSERT INTO skills (
417
457
  id, name, description, content, version, license,
418
458
  compatibility, allowed_tools, metadata, source_path,
419
- source_type, source_ref, enabled, project_id,
420
- created_at, updated_at
421
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
459
+ source_type, source_ref, hub_name, hub_slug, hub_version,
460
+ enabled, project_id, created_at, updated_at
461
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
422
462
  """,
423
463
  (
424
464
  skill_id,
@@ -433,6 +473,9 @@ class LocalSkillManager:
433
473
  source_path,
434
474
  source_type,
435
475
  source_ref,
476
+ hub_name,
477
+ hub_slug,
478
+ hub_version,
436
479
  enabled,
437
480
  project_id,
438
481
  now,
@@ -465,21 +508,32 @@ class LocalSkillManager:
465
508
  self,
466
509
  name: str,
467
510
  project_id: str | None = None,
511
+ include_global: bool = True,
468
512
  ) -> Skill | None:
469
513
  """Get a skill by name within a project scope.
470
514
 
471
515
  Args:
472
516
  name: The skill name
473
517
  project_id: Project scope (None for global)
518
+ include_global: Include global skills when project_id is set.
519
+ When True and project_id is set, first looks for
520
+ project-scoped skill, then falls back to global.
474
521
 
475
522
  Returns:
476
523
  The Skill if found, None otherwise
477
524
  """
478
525
  if project_id:
526
+ # First try project-scoped skill
479
527
  row = self.db.fetchone(
480
528
  "SELECT * FROM skills WHERE name = ? AND project_id = ?",
481
529
  (name, project_id),
482
530
  )
531
+ # If not found and include_global, try global
532
+ if row is None and include_global:
533
+ row = self.db.fetchone(
534
+ "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
535
+ (name,),
536
+ )
483
537
  else:
484
538
  row = self.db.fetchone(
485
539
  "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
@@ -501,6 +555,9 @@ class LocalSkillManager:
501
555
  source_path: str | None = _UNSET,
502
556
  source_type: SkillSourceType | None = _UNSET,
503
557
  source_ref: str | None = _UNSET,
558
+ hub_name: str | None = _UNSET,
559
+ hub_slug: str | None = _UNSET,
560
+ hub_version: str | None = _UNSET,
504
561
  enabled: bool | None = None,
505
562
  ) -> Skill:
506
563
  """Update an existing skill.
@@ -518,6 +575,9 @@ class LocalSkillManager:
518
575
  source_path: New source path (use _UNSET to leave unchanged, None to clear)
519
576
  source_type: New source type (use _UNSET to leave unchanged, None to clear)
520
577
  source_ref: New source ref (use _UNSET to leave unchanged, None to clear)
578
+ hub_name: New hub name (use _UNSET to leave unchanged, None to clear)
579
+ hub_slug: New hub slug (use _UNSET to leave unchanged, None to clear)
580
+ hub_version: New hub version (use _UNSET to leave unchanged, None to clear)
521
581
  enabled: New enabled state (optional)
522
582
 
523
583
  Returns:
@@ -562,6 +622,15 @@ class LocalSkillManager:
562
622
  if source_ref is not _UNSET:
563
623
  updates.append("source_ref = ?")
564
624
  params.append(source_ref)
625
+ if hub_name is not _UNSET:
626
+ updates.append("hub_name = ?")
627
+ params.append(hub_name)
628
+ if hub_slug is not _UNSET:
629
+ updates.append("hub_slug = ?")
630
+ params.append(hub_slug)
631
+ if hub_version is not _UNSET:
632
+ updates.append("hub_version = ?")
633
+ params.append(hub_version)
565
634
  if enabled is not None:
566
635
  updates.append("enabled = ?")
567
636
  params.append(enabled)
@@ -649,9 +718,13 @@ class LocalSkillManager:
649
718
  params.append(enabled)
650
719
 
651
720
  # Filter by category using JSON extraction in SQL to avoid under-filled results
721
+ # Check both top-level $.category and nested $.skillport.category
652
722
  if category:
653
- query += " AND json_extract(metadata, '$.skillport.category') = ?"
654
- params.append(category)
723
+ query += """ AND (
724
+ json_extract(metadata, '$.category') = ?
725
+ OR json_extract(metadata, '$.skillport.category') = ?
726
+ )"""
727
+ params.extend([category, category])
655
728
 
656
729
  query += " ORDER BY name ASC LIMIT ? OFFSET ?"
657
730
  params.extend([limit, offset])
@@ -275,7 +275,11 @@ def unlink_commit(
275
275
 
276
276
 
277
277
  def delete_task(
278
- db: DatabaseProtocol, task_id: str, cascade: bool = False, unlink: bool = False
278
+ db: DatabaseProtocol,
279
+ task_id: str,
280
+ cascade: bool = False,
281
+ unlink: bool = False,
282
+ _visited: set[str] | None = None,
279
283
  ) -> bool:
280
284
  """Delete a task.
281
285
 
@@ -285,6 +289,8 @@ def delete_task(
285
289
  cascade: If True, delete children AND dependent tasks recursively
286
290
  unlink: If True, remove dependency links but preserve dependent tasks
287
291
  (ignored if cascade=True)
292
+ _visited: Internal parameter to track visited tasks and prevent infinite recursion
293
+ when a parent task depends on its children (circular dependency)
288
294
 
289
295
  Returns:
290
296
  True if task was deleted, False if task not found.
@@ -292,6 +298,15 @@ def delete_task(
292
298
  Raises:
293
299
  ValueError: If task has children or dependents and neither cascade nor unlink is True.
294
300
  """
301
+ # Initialize visited set on first call to prevent infinite recursion
302
+ if _visited is None:
303
+ _visited = set()
304
+
305
+ # Skip if already being deleted (prevents cycles when parent depends on children)
306
+ if task_id in _visited:
307
+ return True
308
+ _visited.add(task_id)
309
+
295
310
  # Check if task exists first
296
311
  existing = db.fetchone("SELECT 1 FROM tasks WHERE id = ?", (task_id,))
297
312
  if not existing:
@@ -326,7 +341,7 @@ def delete_task(
326
341
  # Recursive delete children
327
342
  children = db.fetchall("SELECT id FROM tasks WHERE parent_task_id = ?", (task_id,))
328
343
  for child in children:
329
- delete_task(db, child["id"], cascade=True)
344
+ delete_task(db, child["id"], cascade=True, _visited=_visited)
330
345
 
331
346
  # Delete tasks that depend on this task (only 'blocks' dependencies)
332
347
  dependents = db.fetchall(
@@ -336,7 +351,7 @@ def delete_task(
336
351
  (task_id,),
337
352
  )
338
353
  for dep in dependents:
339
- delete_task(db, dep["id"], cascade=True)
354
+ delete_task(db, dep["id"], cascade=True, _visited=_visited)
340
355
 
341
356
  # Note: if unlink=True, dependency links are removed by ON DELETE CASCADE
342
357
  # when the task is deleted - no explicit action needed
gobby/sync/memories.py CHANGED
@@ -29,7 +29,7 @@ __all__ = [
29
29
  # TODO: Rename MemorySyncConfig to MemoryBackupConfig in gobby.config.persistence
30
30
  # for consistency with MemoryBackupManager naming. Keeping current name for now
31
31
  # to minimize breaking changes across the codebase.
32
- from gobby.config.app import MemorySyncConfig
32
+ from gobby.config.persistence import MemorySyncConfig
33
33
  from gobby.memory.manager import MemoryManager
34
34
  from gobby.storage.database import DatabaseProtocol
35
35
 
@@ -14,7 +14,7 @@ from dataclasses import dataclass, field
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING, Any
16
16
 
17
- from gobby.config.app import TaskValidationConfig
17
+ from gobby.config.tasks import TaskValidationConfig
18
18
  from gobby.llm import LLMService
19
19
  from gobby.prompts import PromptLoader
20
20
  from gobby.tasks.commits import (
gobby/tasks/validation.py CHANGED
@@ -20,7 +20,7 @@ from dataclasses import dataclass
20
20
  from pathlib import Path
21
21
  from typing import Literal
22
22
 
23
- from gobby.config.app import TaskValidationConfig
23
+ from gobby.config.tasks import TaskValidationConfig
24
24
  from gobby.llm import LLMService
25
25
  from gobby.prompts import PromptLoader
26
26
  from gobby.utils.json_helpers import extract_json_object
@@ -589,14 +589,8 @@ class TaskValidator:
589
589
  category_section += "\n"
590
590
 
591
591
  # Build prompt using PromptLoader or legacy config
592
- if self.config.prompt:
593
- # Legacy inline config (deprecated)
594
- prompt = self.config.prompt
595
- if file_context:
596
- prompt += f"\nFile Context:\n{file_context[:50000]}\n"
597
- else:
598
- # Use PromptLoader
599
- prompt_path = self.config.prompt_path or "validation/validate"
592
+ if self.config.prompt_path:
593
+ prompt_path = self.config.prompt_path
600
594
  template_context = {
601
595
  "title": title,
602
596
  "category_section": category_section,
@@ -611,6 +605,18 @@ class TaskValidator:
611
605
  prompt = DEFAULT_VALIDATE_PROMPT.format(**template_context)
612
606
  if file_context:
613
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"
614
620
 
615
621
  try:
616
622
  provider = self.llm_service.get_provider(self.config.provider)
@@ -670,17 +676,13 @@ class TaskValidator:
670
676
  "description": description or "(no description)",
671
677
  }
672
678
 
673
- if self.config.criteria_prompt:
674
- # Legacy inline config (deprecated)
675
- prompt = self.config.criteria_prompt.format(**template_context)
676
- else:
677
- # Use PromptLoader
678
- prompt_path = self.config.criteria_prompt_path or "validation/criteria"
679
- try:
680
- prompt = self._loader.render(prompt_path, template_context)
681
- except FileNotFoundError:
682
- logger.debug(f"Prompt template '{prompt_path}' not found, using fallback")
683
- prompt = DEFAULT_CRITERIA_PROMPT.format(**template_context)
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)
684
686
 
685
687
  try:
686
688
  provider = self.llm_service.get_provider(self.config.provider)
gobby/tools/summarizer.py CHANGED
@@ -11,7 +11,8 @@ import logging
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
13
  if TYPE_CHECKING:
14
- from gobby.config.app import ToolSummarizerConfig
14
+ from gobby.config.features import ToolSummarizerConfig
15
+ from gobby.prompts import PromptLoader
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -20,12 +21,42 @@ MAX_DESCRIPTION_LENGTH = 200
20
21
 
21
22
  # Module-level config reference (set by init_summarizer_config)
22
23
  _config: ToolSummarizerConfig | None = None
24
+ _loader: PromptLoader | None = None
23
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.
24
29
 
25
- def init_summarizer_config(config: ToolSummarizerConfig) -> None:
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
+
46
+ def init_summarizer_config(config: ToolSummarizerConfig, project_dir: str | None = None) -> None:
26
47
  """Initialize the summarizer with configuration."""
27
- global _config
48
+ from pathlib import Path
49
+
50
+ global _config, _loader
28
51
  _config = config
52
+ _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
+ )
29
60
 
30
61
 
31
62
  def _get_config() -> ToolSummarizerConfig:
@@ -33,7 +64,12 @@ def _get_config() -> ToolSummarizerConfig:
33
64
  if _config is not None:
34
65
  return _config
35
66
  # Import here to avoid circular imports
36
- from gobby.config.app import ToolSummarizerConfig
67
+ from gobby.config.features import ToolSummarizerConfig
68
+
69
+ # Ensure loader defaults exist even if init wasn't called
70
+ global _loader
71
+ if _loader is None:
72
+ _loader = PromptLoader()
37
73
 
38
74
  return ToolSummarizerConfig()
39
75
 
@@ -53,11 +89,30 @@ async def _summarize_description_with_claude(description: str) -> str:
53
89
  try:
54
90
  from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
55
91
 
56
- prompt = config.prompt.format(description=description)
92
+ # Get summary prompt
93
+ prompt_path = config.prompt_path or "features/tool_summary"
94
+ try:
95
+ # We assume _loader is initialized by _get_config() logic or init
96
+ if _loader is None:
97
+ raise RuntimeError("Summarizer not initialized")
98
+ 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)
102
+
103
+ # Get system prompt
104
+ sys_prompt_path = config.system_prompt_path or "features/tool_summary_system"
105
+ try:
106
+ if _loader is None:
107
+ raise RuntimeError("Summarizer not initialized")
108
+ 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
57
112
 
58
113
  # Configure for single-turn completion
59
114
  options = ClaudeAgentOptions(
60
- system_prompt=config.system_prompt,
115
+ system_prompt=system_prompt,
61
116
  max_turns=1,
62
117
  model=config.model,
63
118
  allowed_tools=[],
@@ -137,14 +192,40 @@ async def generate_server_description(
137
192
  # Build tools list for prompt
138
193
  tools_list = "\n".join([f"- {t['name']}: {t['description']}" for t in tool_summaries])
139
194
 
140
- prompt = config.server_description_prompt.format(
141
- server_name=server_name,
142
- tools_list=tools_list,
195
+ # Build prompt
196
+ prompt_path = config.server_description_prompt_path or "features/server_description"
197
+ context = {
198
+ "server_name": server_name,
199
+ "tools_list": tools_list,
200
+ }
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)
212
+
213
+ # Get system prompt
214
+ sys_prompt_path = (
215
+ config.server_description_system_prompt_path or "features/server_description_system"
143
216
  )
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
144
225
 
145
226
  # Configure for single-turn completion
146
227
  options = ClaudeAgentOptions(
147
- system_prompt=config.server_description_system_prompt,
228
+ system_prompt=system_prompt,
148
229
  max_turns=1,
149
230
  model=config.model,
150
231
  allowed_tools=[],
@@ -10,8 +10,7 @@ from pathlib import Path
10
10
  from typing import TYPE_CHECKING, Any, cast
11
11
 
12
12
  if TYPE_CHECKING:
13
- from gobby.config.app import ProjectVerificationConfig
14
- from gobby.config.features import HooksConfig
13
+ from gobby.config.features import HooksConfig, ProjectVerificationConfig
15
14
 
16
15
  logger = logging.getLogger(__name__)
17
16
 
@@ -138,7 +137,7 @@ def get_verification_config(cwd: Path | None = None) -> ProjectVerificationConfi
138
137
  Returns:
139
138
  ProjectVerificationConfig if verification section exists, None otherwise.
140
139
  """
141
- from gobby.config.app import ProjectVerificationConfig
140
+ from gobby.config.features import ProjectVerificationConfig
142
141
 
143
142
  context = get_project_context(cwd)
144
143
  if not context: