gobby 0.2.8__py3-none-any.whl → 0.2.9__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 (63) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +3 -26
  3. gobby/app_context.py +59 -0
  4. gobby/cli/utils.py +5 -17
  5. gobby/config/features.py +0 -20
  6. gobby/config/tasks.py +4 -0
  7. gobby/hooks/event_handlers/__init__.py +155 -0
  8. gobby/hooks/event_handlers/_agent.py +175 -0
  9. gobby/hooks/event_handlers/_base.py +87 -0
  10. gobby/hooks/event_handlers/_misc.py +66 -0
  11. gobby/hooks/event_handlers/_session.py +573 -0
  12. gobby/hooks/event_handlers/_tool.py +196 -0
  13. gobby/hooks/hook_manager.py +2 -0
  14. gobby/llm/claude.py +377 -42
  15. gobby/mcp_proxy/importer.py +4 -41
  16. gobby/mcp_proxy/manager.py +13 -3
  17. gobby/mcp_proxy/registries.py +14 -0
  18. gobby/mcp_proxy/services/recommendation.py +2 -28
  19. gobby/mcp_proxy/tools/artifacts.py +3 -3
  20. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  21. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  22. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  23. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  24. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  25. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  26. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  27. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  28. gobby/memory/components/__init__.py +0 -0
  29. gobby/memory/components/ingestion.py +98 -0
  30. gobby/memory/components/search.py +108 -0
  31. gobby/memory/manager.py +16 -25
  32. gobby/paths.py +51 -0
  33. gobby/prompts/loader.py +1 -35
  34. gobby/runner.py +23 -10
  35. gobby/servers/http.py +186 -149
  36. gobby/servers/routes/admin.py +12 -0
  37. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  38. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  39. gobby/sessions/analyzer.py +2 -2
  40. gobby/skills/parser.py +23 -0
  41. gobby/skills/sync.py +5 -4
  42. gobby/storage/artifacts.py +19 -0
  43. gobby/storage/migrations.py +25 -2
  44. gobby/storage/skills.py +47 -7
  45. gobby/tasks/external_validator.py +4 -17
  46. gobby/tasks/validation.py +13 -87
  47. gobby/tools/summarizer.py +18 -51
  48. gobby/utils/status.py +13 -0
  49. gobby/workflows/actions.py +5 -0
  50. gobby/workflows/context_actions.py +21 -24
  51. gobby/workflows/enforcement/__init__.py +11 -1
  52. gobby/workflows/enforcement/blocking.py +96 -0
  53. gobby/workflows/enforcement/handlers.py +35 -1
  54. gobby/workflows/engine.py +6 -3
  55. gobby/workflows/lifecycle_evaluator.py +2 -1
  56. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  57. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/RECORD +61 -45
  58. gobby/hooks/event_handlers.py +0 -1008
  59. gobby/mcp_proxy/tools/workflows.py +0 -1023
  60. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  61. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  62. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  63. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
@@ -255,17 +255,17 @@ async def refresh_mcp_tools(
255
255
  try:
256
256
  session = await server.mcp_manager.ensure_connected(server_name)
257
257
  tools_result = await session.list_tools()
258
- for t in tools_result.tools:
258
+ for mcp_tool in tools_result.tools:
259
259
  schema = None
260
- if hasattr(t, "inputSchema"):
261
- if hasattr(t.inputSchema, "model_dump"):
262
- schema = t.inputSchema.model_dump()
263
- elif isinstance(t.inputSchema, dict):
264
- schema = t.inputSchema
260
+ if hasattr(mcp_tool, "inputSchema"):
261
+ if hasattr(mcp_tool.inputSchema, "model_dump"):
262
+ schema = mcp_tool.inputSchema.model_dump()
263
+ elif isinstance(mcp_tool.inputSchema, dict):
264
+ schema = mcp_tool.inputSchema
265
265
  tools.append(
266
266
  {
267
- "name": getattr(t, "name", ""),
268
- "description": getattr(t, "description", ""),
267
+ "name": getattr(mcp_tool, "name", ""),
268
+ "description": getattr(mcp_tool, "description", ""),
269
269
  "inputSchema": schema,
270
270
  }
271
271
  )
@@ -32,8 +32,8 @@ class HandoffContext:
32
32
  key_decisions: list[str] | None = None
33
33
  active_worktree: dict[str, Any] | None = None
34
34
  """Worktree context if session is operating in a worktree."""
35
- active_skills: list[str] = field(default_factory=list)
36
- """List of skill names that were active/injected during the session."""
35
+ # Note: active_skills field removed - redundant with _build_skill_injection_context()
36
+ # which already handles skill restoration on session start
37
37
 
38
38
 
39
39
  class TranscriptAnalyzer:
gobby/skills/parser.py CHANGED
@@ -64,6 +64,8 @@ class ParsedSkill:
64
64
  scripts: List of script file paths (relative to skill dir)
65
65
  references: List of reference file paths (relative to skill dir)
66
66
  assets: List of asset file paths (relative to skill dir)
67
+ always_apply: Whether skill should always be injected at session start
68
+ injection_format: How to inject skill (summary, full, content)
67
69
  """
68
70
 
69
71
  name: str
@@ -80,6 +82,8 @@ class ParsedSkill:
80
82
  scripts: list[str] | None = None
81
83
  references: list[str] | None = None
82
84
  assets: list[str] | None = None
85
+ always_apply: bool = False
86
+ injection_format: str = "summary"
83
87
 
84
88
  def get_category(self) -> str | None:
85
89
  """Get category from top-level or metadata.skillport.category."""
@@ -139,6 +143,8 @@ class ParsedSkill:
139
143
  "scripts": self.scripts,
140
144
  "references": self.references,
141
145
  "assets": self.assets,
146
+ "always_apply": self.always_apply,
147
+ "injection_format": self.injection_format,
142
148
  }
143
149
 
144
150
 
@@ -232,6 +238,7 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
232
238
  # This allows both top-level and nested formats to work
233
239
  top_level_always_apply = frontmatter.get("alwaysApply")
234
240
  top_level_category = frontmatter.get("category")
241
+ top_level_injection_format = frontmatter.get("injectionFormat")
235
242
 
236
243
  if top_level_always_apply is not None or top_level_category is not None:
237
244
  if metadata is None:
@@ -251,6 +258,20 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
251
258
  if version is not None:
252
259
  version = str(version)
253
260
 
261
+ # Extract always_apply: check top-level first, then metadata.skillport.alwaysApply
262
+ always_apply = False
263
+ if top_level_always_apply is not None:
264
+ always_apply = bool(top_level_always_apply)
265
+ elif metadata and isinstance(metadata, dict):
266
+ skillport = metadata.get("skillport", {})
267
+ if isinstance(skillport, dict) and skillport.get("alwaysApply"):
268
+ always_apply = bool(skillport["alwaysApply"])
269
+
270
+ # Extract injection_format: check top-level first, default to "summary"
271
+ injection_format = "summary"
272
+ if top_level_injection_format is not None:
273
+ injection_format = str(top_level_injection_format)
274
+
254
275
  return ParsedSkill(
255
276
  name=name,
256
277
  description=description,
@@ -261,6 +282,8 @@ def parse_skill_text(text: str, source_path: str | None = None) -> ParsedSkill:
261
282
  allowed_tools=allowed_tools,
262
283
  metadata=metadata,
263
284
  source_path=source_path,
285
+ always_apply=always_apply,
286
+ injection_format=injection_format,
264
287
  )
265
288
 
266
289
 
gobby/skills/sync.py CHANGED
@@ -23,10 +23,9 @@ def get_bundled_skills_path() -> Path:
23
23
  Returns:
24
24
  Path to src/gobby/install/shared/skills/
25
25
  """
26
- # Navigate from this file to install/shared/skills/
27
- # This file: src/gobby/skills/sync.py
28
- # Target: src/gobby/install/shared/skills/
29
- return Path(__file__).parent.parent / "install" / "shared" / "skills"
26
+ from gobby.paths import get_install_dir
27
+
28
+ return get_install_dir() / "shared" / "skills"
30
29
 
31
30
 
32
31
  def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
@@ -101,6 +100,8 @@ def sync_bundled_skills(db: DatabaseProtocol) -> dict[str, Any]:
101
100
  source_ref=None,
102
101
  project_id=None, # Global scope
103
102
  enabled=True,
103
+ always_apply=parsed.always_apply,
104
+ injection_format=parsed.injection_format,
104
105
  )
105
106
 
106
107
  logger.info(f"Synced bundled skill: {parsed.name}")
@@ -283,3 +283,22 @@ class LocalArtifactManager:
283
283
 
284
284
  rows = self.db.fetchall(sql, tuple(params))
285
285
  return [Artifact.from_row(row) for row in rows]
286
+
287
+ def count_artifacts(self, session_id: str | None = None) -> int:
288
+ """Count total artifacts, optionally filtered by session.
289
+
290
+ Args:
291
+ session_id: Optional session ID to filter by
292
+
293
+ Returns:
294
+ Total artifact count
295
+ """
296
+ if session_id:
297
+ row = self.db.fetchone(
298
+ "SELECT COUNT(*) FROM session_artifacts WHERE session_id = ?",
299
+ (session_id,),
300
+ )
301
+ else:
302
+ row = self.db.fetchone("SELECT COUNT(*) FROM session_artifacts")
303
+
304
+ return row[0] if row else 0
@@ -43,9 +43,9 @@ class MigrationUnsupportedError(Exception):
43
43
  # Migration can be SQL string or a callable that takes LocalDatabase
44
44
  MigrationAction = str | Callable[[LocalDatabase], None]
45
45
 
46
- # Baseline version - the schema state at v78 (flattened)
46
+ # Baseline version - the schema state at v79 (flattened)
47
47
  # This is applied for new databases directly
48
- BASELINE_VERSION = 78
48
+ BASELINE_VERSION = 79
49
49
 
50
50
  # Baseline schema - flattened from v78 production state, includes hub tracking fields
51
51
  # This is applied for new databases directly
@@ -587,6 +587,8 @@ CREATE TABLE skills (
587
587
  hub_slug TEXT,
588
588
  hub_version TEXT,
589
589
  enabled INTEGER DEFAULT 1,
590
+ always_apply INTEGER DEFAULT 0,
591
+ injection_format TEXT DEFAULT 'summary',
590
592
  project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
591
593
  created_at TEXT NOT NULL,
592
594
  updated_at TEXT NOT NULL
@@ -594,6 +596,7 @@ CREATE TABLE skills (
594
596
  CREATE INDEX idx_skills_name ON skills(name);
595
597
  CREATE INDEX idx_skills_project_id ON skills(project_id);
596
598
  CREATE INDEX idx_skills_enabled ON skills(enabled);
599
+ CREATE INDEX idx_skills_always_apply ON skills(always_apply);
597
600
  CREATE UNIQUE INDEX idx_skills_name_project ON skills(name, project_id);
598
601
  CREATE UNIQUE INDEX idx_skills_name_global ON skills(name) WHERE project_id IS NULL;
599
602
 
@@ -709,6 +712,24 @@ def _migrate_add_hub_tracking_to_skills(db: LocalDatabase) -> None:
709
712
  logger.info("Added hub tracking fields to skills table")
710
713
 
711
714
 
715
+ def _migrate_add_skill_injection_columns(db: LocalDatabase) -> None:
716
+ """Add always_apply and injection_format columns to skills table.
717
+
718
+ These columns enable per-skill control over:
719
+ - always_apply: Whether skill should always be injected at session start
720
+ - injection_format: How to inject the skill (summary, full, content)
721
+
722
+ The values are extracted from SKILL.md frontmatter during sync and stored
723
+ as columns for efficient querying.
724
+ """
725
+ with db.transaction() as conn:
726
+ conn.execute("ALTER TABLE skills ADD COLUMN always_apply INTEGER DEFAULT 0")
727
+ conn.execute("ALTER TABLE skills ADD COLUMN injection_format TEXT DEFAULT 'summary'")
728
+ conn.execute("CREATE INDEX idx_skills_always_apply ON skills(always_apply)")
729
+
730
+ logger.info("Added always_apply and injection_format columns to skills table")
731
+
732
+
712
733
  MIGRATIONS: list[tuple[int, str, MigrationAction]] = [
713
734
  # Project-scoped session refs: Change seq_num index from global to project-scoped
714
735
  (76, "Make sessions.seq_num project-scoped", _migrate_session_seq_num_project_scoped),
@@ -716,6 +737,8 @@ MIGRATIONS: list[tuple[int, str, MigrationAction]] = [
716
737
  (77, "Backfill sessions.seq_num per project", _migrate_backfill_session_seq_num_per_project),
717
738
  # Hub tracking: Add hub_name, hub_slug, hub_version to skills table
718
739
  (78, "Add hub tracking fields to skills", _migrate_add_hub_tracking_to_skills),
740
+ # Skill injection: Add always_apply and injection_format columns
741
+ (79, "Add skill injection columns", _migrate_add_skill_injection_columns),
719
742
  ]
720
743
 
721
744
 
gobby/storage/skills.py CHANGED
@@ -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.
@@ -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(