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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {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 (
|
|
228
|
-
max_seq_row = self.db.fetchone(
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
654
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
593
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
prompt_path
|
|
679
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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=
|
|
228
|
+
system_prompt=system_prompt,
|
|
148
229
|
max_turns=1,
|
|
149
230
|
model=config.model,
|
|
150
231
|
allowed_tools=[],
|
gobby/utils/project_context.py
CHANGED
|
@@ -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.
|
|
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.
|
|
140
|
+
from gobby.config.features import ProjectVerificationConfig
|
|
142
141
|
|
|
143
142
|
context = get_project_context(cwd)
|
|
144
143
|
if not context:
|