gobby 0.2.6__py3-none-any.whl → 0.2.7__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.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,
@@ -224,8 +228,11 @@ class LocalSessionManager:
224
228
  max_retries = 3
225
229
  for attempt in range(max_retries):
226
230
  try:
227
- # Get next seq_num (global)
228
- max_seq_row = self.db.fetchone("SELECT MAX(seq_num) as max_seq FROM sessions")
231
+ # Get next seq_num (per-project)
232
+ max_seq_row = self.db.fetchone(
233
+ "SELECT MAX(seq_num) as max_seq FROM sessions WHERE project_id = ?",
234
+ (project_id,),
235
+ )
229
236
  next_seq_num = ((max_seq_row["max_seq"] if max_seq_row else None) or 0) + 1
230
237
 
231
238
  self.db.execute(
@@ -234,9 +241,9 @@ class LocalSessionManager:
234
241
  id, external_id, machine_id, source, project_id, title,
235
242
  jsonl_path, git_branch, parent_session_id,
236
243
  agent_depth, spawned_by_agent_id, terminal_context,
237
- status, created_at, updated_at, seq_num
244
+ status, created_at, updated_at, seq_num, had_edits
238
245
  )
239
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
246
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 0)
240
247
  """,
241
248
  (
242
249
  session_id,
@@ -279,18 +286,20 @@ class LocalSessionManager:
279
286
  row = self.db.fetchone("SELECT * FROM sessions WHERE id = ?", (session_id,))
280
287
  return Session.from_row(row) if row else None
281
288
 
282
- def resolve_session_reference(self, ref: str) -> str:
289
+ def resolve_session_reference(self, ref: str, project_id: str | None = None) -> str:
283
290
  """
284
291
  Resolve a session reference to a UUID.
285
292
 
286
293
  Supports:
287
- - #N: Global Sequence Number (e.g., #1)
294
+ - #N: Project-scoped Sequence Number (e.g., #1) - requires project_id
288
295
  - N: Integer string treated as #N (e.g., "1")
289
296
  - UUID: Full UUID
290
297
  - Prefix: UUID prefix (must be unambiguous)
291
298
 
292
299
  Args:
293
300
  ref: Session reference string
301
+ project_id: Project ID for project-scoped #N lookup.
302
+ If not provided, falls back to global lookup for backwards compat.
294
303
 
295
304
  Returns:
296
305
  Resolved Session UUID
@@ -308,7 +317,15 @@ class LocalSessionManager:
308
317
 
309
318
  if seq_num_ref.isdigit():
310
319
  seq_num = int(seq_num_ref)
311
- row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
320
+ if project_id:
321
+ # Project-scoped lookup
322
+ row = self.db.fetchone(
323
+ "SELECT id FROM sessions WHERE project_id = ? AND seq_num = ?",
324
+ (project_id, seq_num),
325
+ )
326
+ else:
327
+ # Fallback to global lookup for backwards compat
328
+ row = self.db.fetchone("SELECT id FROM sessions WHERE seq_num = ?", (seq_num,))
312
329
  if not row:
313
330
  raise ValueError(f"Session #{seq_num} not found")
314
331
  return str(row["id"])
@@ -426,6 +443,15 @@ class LocalSessionManager:
426
443
  )
427
444
  return self.get(session_id)
428
445
 
446
+ def mark_had_edits(self, session_id: str) -> Session | None:
447
+ """Mark session as having edits."""
448
+ now = datetime.now(UTC).isoformat()
449
+ self.db.execute(
450
+ "UPDATE sessions SET had_edits = 1, updated_at = ? WHERE id = ?",
451
+ (now, session_id),
452
+ )
453
+ return self.get(session_id)
454
+
429
455
  def update_title(self, session_id: str, title: str) -> Session | None:
430
456
  """Update session title."""
431
457
  now = datetime.now(UTC).isoformat()
@@ -435,6 +461,16 @@ class LocalSessionManager:
435
461
  )
436
462
  return self.get(session_id)
437
463
 
464
+ def update_model(self, session_id: str, model: str) -> Session | None:
465
+ """Update session model (LLM model used)."""
466
+ now = datetime.now(UTC).isoformat()
467
+ with self.db.transaction():
468
+ self.db.execute(
469
+ "UPDATE sessions SET model = ?, updated_at = ? WHERE id = ?",
470
+ (model, now, session_id),
471
+ )
472
+ return self.get(session_id)
473
+
438
474
  def update_summary(
439
475
  self,
440
476
  session_id: str,
gobby/storage/skills.py CHANGED
@@ -149,9 +149,18 @@ class Skill:
149
149
  }
150
150
 
151
151
  def get_category(self) -> str | None:
152
- """Get the skill category from metadata.skillport.category."""
152
+ """Get the skill category from top-level or metadata.skillport.category.
153
+
154
+ Supports both top-level category and nested metadata.skillport.category.
155
+ Top-level takes precedence.
156
+ """
153
157
  if not self.metadata:
154
158
  return None
159
+ # Check top-level first
160
+ result = self.metadata.get("category")
161
+ if result is not None:
162
+ return str(result)
163
+ # Fall back to nested skillport.category
155
164
  skillport = self.metadata.get("skillport", {})
156
165
  result = skillport.get("category")
157
166
  return str(result) if result is not None else None
@@ -165,9 +174,18 @@ class Skill:
165
174
  return list(tags) if isinstance(tags, list) else []
166
175
 
167
176
  def is_always_apply(self) -> bool:
168
- """Check if this is a core skill that should always be applied."""
177
+ """Check if this is a core skill that should always be applied.
178
+
179
+ Supports both top-level alwaysApply and nested metadata.skillport.alwaysApply.
180
+ Top-level takes precedence.
181
+ """
169
182
  if not self.metadata:
170
183
  return False
184
+ # Check top-level first
185
+ top_level = self.metadata.get("alwaysApply")
186
+ if top_level is not None:
187
+ return bool(top_level)
188
+ # Fall back to nested skillport.alwaysApply
171
189
  skillport = self.metadata.get("skillport", {})
172
190
  return bool(skillport.get("alwaysApply", False))
173
191
 
@@ -465,21 +483,32 @@ class LocalSkillManager:
465
483
  self,
466
484
  name: str,
467
485
  project_id: str | None = None,
486
+ include_global: bool = True,
468
487
  ) -> Skill | None:
469
488
  """Get a skill by name within a project scope.
470
489
 
471
490
  Args:
472
491
  name: The skill name
473
492
  project_id: Project scope (None for global)
493
+ include_global: Include global skills when project_id is set.
494
+ When True and project_id is set, first looks for
495
+ project-scoped skill, then falls back to global.
474
496
 
475
497
  Returns:
476
498
  The Skill if found, None otherwise
477
499
  """
478
500
  if project_id:
501
+ # First try project-scoped skill
479
502
  row = self.db.fetchone(
480
503
  "SELECT * FROM skills WHERE name = ? AND project_id = ?",
481
504
  (name, project_id),
482
505
  )
506
+ # If not found and include_global, try global
507
+ if row is None and include_global:
508
+ row = self.db.fetchone(
509
+ "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
510
+ (name,),
511
+ )
483
512
  else:
484
513
  row = self.db.fetchone(
485
514
  "SELECT * FROM skills WHERE name = ? AND project_id IS NULL",
@@ -649,9 +678,13 @@ class LocalSkillManager:
649
678
  params.append(enabled)
650
679
 
651
680
  # Filter by category using JSON extraction in SQL to avoid under-filled results
681
+ # Check both top-level $.category and nested $.skillport.category
652
682
  if category:
653
- query += " AND json_extract(metadata, '$.skillport.category') = ?"
654
- params.append(category)
683
+ query += """ AND (
684
+ json_extract(metadata, '$.category') = ?
685
+ OR json_extract(metadata, '$.skillport.category') = ?
686
+ )"""
687
+ params.extend([category, category])
655
688
 
656
689
  query += " ORDER BY name ASC LIMIT ? OFFSET ?"
657
690
  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:
gobby/utils/status.py CHANGED
@@ -79,6 +79,11 @@ def fetch_rich_status(http_port: int, timeout: float = 2.0) -> dict[str, Any]:
79
79
  status_kwargs["memories_count"] = memory.get("count", 0)
80
80
  status_kwargs["memories_avg_importance"] = memory.get("avg_importance", 0.0)
81
81
 
82
+ # Skills
83
+ skills_data = data.get("skills", {})
84
+ if skills_data:
85
+ status_kwargs["skills_total"] = skills_data.get("total", 0)
86
+
82
87
  except (httpx.ConnectError, httpx.TimeoutException):
83
88
  # Daemon not responding - return empty
84
89
  pass
@@ -117,6 +122,8 @@ def format_status_message(
117
122
  # Memory
118
123
  memories_count: int | None = None,
119
124
  memories_avg_importance: float | None = None,
125
+ # Skills
126
+ skills_total: int | None = None,
120
127
  **kwargs: Any,
121
128
  ) -> str:
122
129
  """
@@ -202,6 +209,12 @@ def format_status_message(
202
209
  lines.append(f" Unhealthy: {unhealthy_str}")
203
210
  lines.append("")
204
211
 
212
+ # Skills section (only show if we have data)
213
+ if skills_total is not None:
214
+ lines.append("Skills:")
215
+ lines.append(f" Loaded: {skills_total}")
216
+ lines.append("")
217
+
205
218
  # Sessions section (only show if we have data)
206
219
  if sessions_active is not None or sessions_paused is not None:
207
220
  lines.append("Sessions:")