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
@@ -5,7 +5,10 @@ These functions handle MCP tool calls from workflows.
5
5
  """
6
6
 
7
7
  import logging
8
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from gobby.workflows.actions import ActionContext
9
12
 
10
13
  logger = logging.getLogger(__name__)
11
14
 
@@ -58,3 +61,19 @@ async def call_mcp_tool(
58
61
  except Exception as e:
59
62
  logger.error(f"call_mcp_tool: Failed: {e}")
60
63
  return {"error": str(e)}
64
+
65
+
66
+ # --- ActionHandler-compatible wrappers ---
67
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
68
+
69
+
70
+ async def handle_call_mcp_tool(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
71
+ """ActionHandler wrapper for call_mcp_tool."""
72
+ return await call_mcp_tool(
73
+ mcp_manager=context.mcp_manager,
74
+ state=context.state,
75
+ server_name=kwargs.get("server_name"),
76
+ tool_name=kwargs.get("tool_name"),
77
+ arguments=kwargs.get("arguments"),
78
+ output_as=kwargs.get("as"),
79
+ )
@@ -344,3 +344,83 @@ async def memory_extract(
344
344
  except Exception as e:
345
345
  logger.error(f"memory_extract: Failed: {e}", exc_info=True)
346
346
  return {"error": str(e)}
347
+
348
+
349
+ # --- ActionHandler-compatible wrappers ---
350
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
351
+
352
+ if __name__ != "__main__":
353
+ from typing import TYPE_CHECKING
354
+
355
+ if TYPE_CHECKING:
356
+ from gobby.workflows.actions import ActionContext
357
+
358
+
359
+ async def handle_memory_sync_import(
360
+ context: "ActionContext", **kwargs: Any
361
+ ) -> dict[str, Any] | None:
362
+ """ActionHandler wrapper for memory_sync_import."""
363
+ return await memory_sync_import(context.memory_sync_manager)
364
+
365
+
366
+ async def handle_memory_sync_export(
367
+ context: "ActionContext", **kwargs: Any
368
+ ) -> dict[str, Any] | None:
369
+ """ActionHandler wrapper for memory_sync_export."""
370
+ return await memory_sync_export(context.memory_sync_manager)
371
+
372
+
373
+ async def handle_memory_save(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
374
+ """ActionHandler wrapper for memory_save."""
375
+ return await memory_save(
376
+ memory_manager=context.memory_manager,
377
+ session_manager=context.session_manager,
378
+ session_id=context.session_id,
379
+ content=kwargs.get("content"),
380
+ memory_type=kwargs.get("memory_type", "fact"),
381
+ importance=kwargs.get("importance", 0.5),
382
+ tags=kwargs.get("tags"),
383
+ project_id=kwargs.get("project_id"),
384
+ )
385
+
386
+
387
+ async def handle_memory_recall_relevant(
388
+ context: "ActionContext", **kwargs: Any
389
+ ) -> dict[str, Any] | None:
390
+ """ActionHandler wrapper for memory_recall_relevant."""
391
+ prompt_text = None
392
+ if context.event_data:
393
+ # Check both "prompt" (from hook event) and "prompt_text" (legacy/alternative)
394
+ prompt_text = context.event_data.get("prompt") or context.event_data.get("prompt_text")
395
+
396
+ return await memory_recall_relevant(
397
+ memory_manager=context.memory_manager,
398
+ session_manager=context.session_manager,
399
+ session_id=context.session_id,
400
+ prompt_text=prompt_text,
401
+ project_id=kwargs.get("project_id"),
402
+ limit=kwargs.get("limit", 5),
403
+ min_importance=kwargs.get("min_importance", 0.3),
404
+ state=context.state,
405
+ )
406
+
407
+
408
+ async def handle_reset_memory_injection_tracking(
409
+ context: "ActionContext", **kwargs: Any
410
+ ) -> dict[str, Any] | None:
411
+ """ActionHandler wrapper for reset_memory_injection_tracking."""
412
+ return reset_memory_injection_tracking(state=context.state)
413
+
414
+
415
+ async def handle_memory_extract(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
416
+ """ActionHandler wrapper for memory_extract."""
417
+ return await memory_extract(
418
+ session_manager=context.session_manager,
419
+ session_id=context.session_id,
420
+ llm_service=context.llm_service,
421
+ memory_manager=context.memory_manager,
422
+ transcript_processor=context.transcript_processor,
423
+ min_importance=kwargs.get("min_importance", 0.7),
424
+ max_memories=kwargs.get("max_memories", 5),
425
+ dry_run=kwargs.get("dry_run", False),
426
+ )
@@ -0,0 +1,183 @@
1
+ """Safe expression evaluation utilities.
2
+
3
+ Provides AST-based expression evaluation without using eval(),
4
+ and lazy boolean evaluation for deferred computation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import operator
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ __all__ = ["LazyBool", "SafeExpressionEvaluator"]
15
+
16
+
17
+ class LazyBool:
18
+ """Lazy boolean that defers computation until first access.
19
+
20
+ Used to avoid expensive operations (git status, DB queries) when
21
+ evaluating block_tools conditions that don't reference certain values.
22
+
23
+ The computation is triggered when the value is used in a boolean context
24
+ (e.g., `if lazy_val:` or `not lazy_val`), which happens during eval().
25
+ """
26
+
27
+ __slots__ = ("_thunk", "_computed", "_value")
28
+
29
+ def __init__(self, thunk: Callable[[], bool]) -> None:
30
+ self._thunk = thunk
31
+ self._computed = False
32
+ self._value = False
33
+
34
+ def __bool__(self) -> bool:
35
+ if not self._computed:
36
+ self._value = self._thunk()
37
+ self._computed = True
38
+ return self._value
39
+
40
+ def __repr__(self) -> str:
41
+ if self._computed:
42
+ return f"LazyBool({self._value})"
43
+ return "LazyBool(<not computed>)"
44
+
45
+
46
+ class SafeExpressionEvaluator(ast.NodeVisitor):
47
+ """Safe expression evaluator using AST.
48
+
49
+ Evaluates simple Python expressions without using eval().
50
+ Supports boolean operations, comparisons, attribute access, subscripts,
51
+ and a limited set of allowed function calls.
52
+ """
53
+
54
+ # Comparison operators mapping
55
+ CMP_OPS: dict[type[ast.cmpop], Callable[[Any, Any], bool]] = {
56
+ ast.Eq: operator.eq,
57
+ ast.NotEq: operator.ne,
58
+ ast.Lt: operator.lt,
59
+ ast.LtE: operator.le,
60
+ ast.Gt: operator.gt,
61
+ ast.GtE: operator.ge,
62
+ ast.Is: operator.is_,
63
+ ast.IsNot: operator.is_not,
64
+ ast.In: lambda a, b: a in b,
65
+ ast.NotIn: lambda a, b: a not in b,
66
+ }
67
+
68
+ def __init__(
69
+ self, context: dict[str, Any], allowed_funcs: dict[str, Callable[..., Any]]
70
+ ) -> None:
71
+ self.context = context
72
+ self.allowed_funcs = allowed_funcs
73
+
74
+ def evaluate(self, expr: str) -> bool:
75
+ """Evaluate expression and return boolean result."""
76
+ try:
77
+ tree = ast.parse(expr, mode="eval")
78
+ return bool(self.visit(tree.body))
79
+ except Exception as e:
80
+ raise ValueError(f"Invalid expression: {e}") from e
81
+
82
+ def visit_BoolOp(self, node: ast.BoolOp) -> bool:
83
+ """Handle 'and' / 'or' operations."""
84
+ if isinstance(node.op, ast.And):
85
+ return all(self.visit(v) for v in node.values)
86
+ elif isinstance(node.op, ast.Or):
87
+ return any(self.visit(v) for v in node.values)
88
+ raise ValueError(f"Unsupported boolean operator: {type(node.op).__name__}")
89
+
90
+ def visit_Compare(self, node: ast.Compare) -> bool:
91
+ """Handle comparison operations (==, !=, <, >, in, not in, etc.)."""
92
+ left = self.visit(node.left)
93
+ for op, comparator in zip(node.ops, node.comparators, strict=False):
94
+ right = self.visit(comparator)
95
+ op_func = self.CMP_OPS.get(type(op))
96
+ if op_func is None:
97
+ raise ValueError(f"Unsupported comparison: {type(op).__name__}")
98
+ if not op_func(left, right):
99
+ return False
100
+ left = right
101
+ return True
102
+
103
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
104
+ """Handle unary operations (not, -, +)."""
105
+ operand = self.visit(node.operand)
106
+ if isinstance(node.op, ast.Not):
107
+ return not operand
108
+ elif isinstance(node.op, ast.USub):
109
+ return -operand
110
+ elif isinstance(node.op, ast.UAdd):
111
+ return +operand
112
+ raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
113
+
114
+ def visit_Name(self, node: ast.Name) -> Any:
115
+ """Handle variable names."""
116
+ name = node.id
117
+ # Built-in constants
118
+ if name == "True":
119
+ return True
120
+ if name == "False":
121
+ return False
122
+ if name == "None":
123
+ return None
124
+ # Context variables
125
+ if name in self.context:
126
+ return self.context[name]
127
+ raise ValueError(f"Unknown variable: {name}")
128
+
129
+ def visit_Constant(self, node: ast.Constant) -> Any:
130
+ """Handle literal values (strings, numbers, booleans, None)."""
131
+ return node.value
132
+
133
+ def visit_Call(self, node: ast.Call) -> Any:
134
+ """Handle function calls (only allowed functions)."""
135
+ # Get function name
136
+ if isinstance(node.func, ast.Name):
137
+ func_name = node.func.id
138
+ elif isinstance(node.func, ast.Attribute):
139
+ # Handle method calls like tool_input.get('key')
140
+ obj = self.visit(node.func.value)
141
+ method_name = node.func.attr
142
+ if method_name == "get" and isinstance(obj, dict):
143
+ args = [self.visit(arg) for arg in node.args]
144
+ return obj.get(*args)
145
+ raise ValueError(f"Unsupported method call: {method_name}")
146
+ else:
147
+ raise ValueError(f"Unsupported call type: {type(node.func).__name__}")
148
+
149
+ # Check if function is allowed
150
+ if func_name not in self.allowed_funcs:
151
+ raise ValueError(f"Function not allowed: {func_name}")
152
+
153
+ # Evaluate arguments
154
+ args = [self.visit(arg) for arg in node.args]
155
+ kwargs = {kw.arg: self.visit(kw.value) for kw in node.keywords if kw.arg}
156
+
157
+ return self.allowed_funcs[func_name](*args, **kwargs)
158
+
159
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
160
+ """Handle attribute access (e.g., obj.attr)."""
161
+ obj = self.visit(node.value)
162
+ attr = node.attr
163
+ if isinstance(obj, dict):
164
+ # Allow dict-style attribute access for convenience
165
+ if attr in obj:
166
+ return obj[attr]
167
+ raise ValueError(f"Key not found: {attr}")
168
+ if hasattr(obj, attr):
169
+ return getattr(obj, attr)
170
+ raise ValueError(f"Attribute not found: {attr}")
171
+
172
+ def visit_Subscript(self, node: ast.Subscript) -> Any:
173
+ """Handle subscript access (e.g., obj['key'] or obj[0])."""
174
+ obj = self.visit(node.value)
175
+ key = self.visit(node.slice)
176
+ try:
177
+ return obj[key]
178
+ except (KeyError, IndexError, TypeError) as e:
179
+ raise ValueError(f"Subscript access failed: {e}") from e
180
+
181
+ def generic_visit(self, node: ast.AST) -> Any:
182
+ """Reject any unsupported AST nodes."""
183
+ raise ValueError(f"Unsupported expression type: {type(node).__name__}")
@@ -137,3 +137,47 @@ def switch_mode(mode: str | None = None) -> dict[str, Any]:
137
137
  )
138
138
 
139
139
  return {"inject_context": message, "mode_switch": mode}
140
+
141
+
142
+ # --- ActionHandler-compatible wrappers ---
143
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
144
+
145
+ if __name__ != "__main__":
146
+ from typing import TYPE_CHECKING
147
+
148
+ if TYPE_CHECKING:
149
+ from gobby.workflows.actions import ActionContext
150
+
151
+
152
+ async def handle_start_new_session(
153
+ context: "ActionContext", **kwargs: Any
154
+ ) -> dict[str, Any] | None:
155
+ """ActionHandler wrapper for start_new_session."""
156
+ import asyncio
157
+
158
+ return await asyncio.to_thread(
159
+ start_new_session,
160
+ session_manager=context.session_manager,
161
+ session_id=context.session_id,
162
+ command=kwargs.get("command"),
163
+ args=kwargs.get("args"),
164
+ prompt=kwargs.get("prompt"),
165
+ cwd=kwargs.get("cwd"),
166
+ )
167
+
168
+
169
+ async def handle_mark_session_status(
170
+ context: "ActionContext", **kwargs: Any
171
+ ) -> dict[str, Any] | None:
172
+ """ActionHandler wrapper for mark_session_status."""
173
+ return mark_session_status(
174
+ session_manager=context.session_manager,
175
+ session_id=context.session_id,
176
+ status=kwargs.get("status"),
177
+ target=kwargs.get("target", "current_session"),
178
+ )
179
+
180
+
181
+ async def handle_switch_mode(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
182
+ """ActionHandler wrapper for switch_mode."""
183
+ return switch_mode(kwargs.get("mode"))
@@ -4,8 +4,12 @@ Extracted from actions.py as part of strangler fig decomposition.
4
4
  These functions handle workflow state persistence and variable management.
5
5
  """
6
6
 
7
+ import asyncio
7
8
  import logging
8
- from typing import Any
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from gobby.workflows.actions import ActionContext
9
13
 
10
14
  logger = logging.getLogger(__name__)
11
15
 
@@ -121,3 +125,58 @@ def mark_loop_complete(state: Any) -> dict[str, Any]:
121
125
  state.variables = {}
122
126
  state.variables["stop_reason"] = "completed"
123
127
  return {"loop_marked_complete": True}
128
+
129
+
130
+ # --- ActionHandler-compatible wrappers ---
131
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
132
+
133
+
134
+ async def handle_load_workflow_state(
135
+ context: "ActionContext", **kwargs: Any
136
+ ) -> dict[str, Any] | None:
137
+ """ActionHandler wrapper for load_workflow_state."""
138
+ return await asyncio.to_thread(
139
+ load_workflow_state, context.db, context.session_id, context.state
140
+ )
141
+
142
+
143
+ async def handle_save_workflow_state(
144
+ context: "ActionContext", **kwargs: Any
145
+ ) -> dict[str, Any] | None:
146
+ """ActionHandler wrapper for save_workflow_state."""
147
+ return await asyncio.to_thread(save_workflow_state, context.db, context.state)
148
+
149
+
150
+ async def handle_set_variable(context: "ActionContext", **kwargs: Any) -> dict[str, Any] | None:
151
+ """ActionHandler wrapper for set_variable.
152
+
153
+ Values containing Jinja2 templates ({{ ... }}) are rendered before setting.
154
+ """
155
+ value = kwargs.get("value")
156
+
157
+ # Render template if value contains Jinja2 syntax
158
+ if isinstance(value, str) and "{{" in value:
159
+ template_context = {
160
+ "variables": context.state.variables or {},
161
+ "state": context.state,
162
+ }
163
+ if context.template_engine:
164
+ value = context.template_engine.render(value, template_context)
165
+ else:
166
+ logger.warning("handle_set_variable: template_engine is None, skipping template render")
167
+
168
+ return set_variable(context.state, kwargs.get("name"), value)
169
+
170
+
171
+ async def handle_increment_variable(
172
+ context: "ActionContext", **kwargs: Any
173
+ ) -> dict[str, Any] | None:
174
+ """ActionHandler wrapper for increment_variable."""
175
+ return increment_variable(context.state, kwargs.get("name"), kwargs.get("amount", 1))
176
+
177
+
178
+ async def handle_mark_loop_complete(
179
+ context: "ActionContext", **kwargs: Any
180
+ ) -> dict[str, Any] | None:
181
+ """ActionHandler wrapper for mark_loop_complete."""
182
+ return mark_loop_complete(context.state)
@@ -161,3 +161,58 @@ def clear_stop_signal(
161
161
 
162
162
  cleared = stop_registry.clear(session_id)
163
163
  return {"success": True, "cleared": cleared}
164
+
165
+
166
+ # --- ActionHandler factory functions ---
167
+ # These create ActionHandler-compatible wrappers that close over the stop_registry.
168
+ # The ActionExecutor calls these factories in _register_defaults() to create handlers
169
+ # that have access to the executor's stop_registry instance.
170
+
171
+
172
+ def make_handle_check_stop_signal(
173
+ stop_registry: "StopRegistry | None",
174
+ ) -> Any:
175
+ """Factory that creates a check_stop_signal handler with access to stop_registry."""
176
+
177
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
178
+ """ActionHandler for check_stop_signal."""
179
+ return check_stop_signal(
180
+ stop_registry=stop_registry,
181
+ session_id=context.session_id,
182
+ state=context.state,
183
+ acknowledge=kwargs.get("acknowledge", False),
184
+ )
185
+
186
+ return handler
187
+
188
+
189
+ def make_handle_request_stop(
190
+ stop_registry: "StopRegistry | None",
191
+ ) -> Any:
192
+ """Factory that creates a request_stop handler with access to stop_registry."""
193
+
194
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
195
+ """ActionHandler for request_stop."""
196
+ return request_stop(
197
+ stop_registry=stop_registry,
198
+ session_id=kwargs.get("session_id", context.session_id),
199
+ source=kwargs.get("source", "workflow"),
200
+ reason=kwargs.get("reason"),
201
+ )
202
+
203
+ return handler
204
+
205
+
206
+ def make_handle_clear_stop_signal(
207
+ stop_registry: "StopRegistry | None",
208
+ ) -> Any:
209
+ """Factory that creates a clear_stop_signal handler with access to stop_registry."""
210
+
211
+ async def handler(context: "Any", **kwargs: Any) -> dict[str, Any] | None:
212
+ """ActionHandler for clear_stop_signal."""
213
+ return clear_stop_signal(
214
+ stop_registry=stop_registry,
215
+ session_id=kwargs.get("session_id", context.session_id),
216
+ )
217
+
218
+ return handler
@@ -9,10 +9,13 @@ from __future__ import annotations
9
9
  import json
10
10
  import logging
11
11
  from pathlib import Path
12
- from typing import Any, Literal
12
+ from typing import TYPE_CHECKING, Any, Literal
13
13
 
14
14
  from gobby.workflows.git_utils import get_file_changes, get_git_status
15
15
 
16
+ if TYPE_CHECKING:
17
+ from gobby.workflows.actions import ActionContext
18
+
16
19
  logger = logging.getLogger(__name__)
17
20
 
18
21
 
@@ -359,3 +362,93 @@ async def generate_handoff(
359
362
  return {"error": "Failed to generate summary"}
360
363
 
361
364
  return {"handoff_created": True, "summary_length": summary_result.get("summary_length", 0)}
365
+
366
+
367
+ # --- ActionHandler-compatible wrappers ---
368
+ # These match the ActionHandler protocol: (context: ActionContext, **kwargs) -> dict | None
369
+
370
+
371
+ async def handle_synthesize_title(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
372
+ """ActionHandler wrapper for synthesize_title."""
373
+ # Extract prompt from event data (UserPromptSubmit hook)
374
+ prompt = None
375
+ if context.event_data:
376
+ prompt = context.event_data.get("prompt")
377
+
378
+ return await synthesize_title(
379
+ session_manager=context.session_manager,
380
+ session_id=context.session_id,
381
+ llm_service=context.llm_service,
382
+ transcript_processor=context.transcript_processor,
383
+ template_engine=context.template_engine,
384
+ template=kwargs.get("template"),
385
+ prompt=prompt,
386
+ )
387
+
388
+
389
+ async def handle_generate_summary(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
390
+ """ActionHandler wrapper for generate_summary."""
391
+ return await generate_summary(
392
+ session_manager=context.session_manager,
393
+ session_id=context.session_id,
394
+ llm_service=context.llm_service,
395
+ transcript_processor=context.transcript_processor,
396
+ template=kwargs.get("template"),
397
+ mode=kwargs.get("mode", "clear"),
398
+ previous_summary=kwargs.get("previous_summary"),
399
+ )
400
+
401
+
402
+ async def handle_generate_handoff(context: ActionContext, **kwargs: Any) -> dict[str, Any] | None:
403
+ """ActionHandler wrapper for generate_handoff.
404
+
405
+ Handles mode detection from event_data and previous summary fetching for compact mode.
406
+ Also supports loading templates from prompts collection via 'prompt' parameter.
407
+ """
408
+ # Detect mode from kwargs or event data
409
+ mode = kwargs.get("mode", "clear")
410
+
411
+ # Check if this is a compact event based on event_data
412
+ COMPACT_EVENT_TYPES = {"pre_compact", "compact"}
413
+ if context.event_data:
414
+ raw_event_type = context.event_data.get("event_type") or ""
415
+ normalized_event_type = str(raw_event_type).strip().lower()
416
+ if normalized_event_type in COMPACT_EVENT_TYPES:
417
+ mode = "compact"
418
+
419
+ # For compact mode, fetch previous summary for cumulative compression
420
+ previous_summary = None
421
+ if mode == "compact":
422
+ current_session = context.session_manager.get(context.session_id)
423
+ if current_session:
424
+ previous_summary = getattr(current_session, "summary_markdown", None)
425
+ if previous_summary:
426
+ logger.debug(
427
+ f"Compact mode: using previous summary ({len(previous_summary)} chars) "
428
+ f"for cumulative compression"
429
+ )
430
+
431
+ # Load template from prompts collection if 'prompt' parameter provided
432
+ template = kwargs.get("template")
433
+ prompt_path = kwargs.get("prompt")
434
+ if prompt_path and not template:
435
+ try:
436
+ from gobby.prompts.loader import PromptLoader
437
+
438
+ loader = PromptLoader()
439
+ prompt_template = loader.load(prompt_path)
440
+ template = prompt_template.content
441
+ logger.debug(f"Loaded prompt template from: {prompt_path}")
442
+ except Exception as e:
443
+ logger.warning(f"Failed to load prompt from {prompt_path}: {e}")
444
+ # Fall back to inline template or default
445
+
446
+ return await generate_handoff(
447
+ session_manager=context.session_manager,
448
+ session_id=context.session_id,
449
+ llm_service=context.llm_service,
450
+ transcript_processor=context.transcript_processor,
451
+ template=template,
452
+ previous_summary=previous_summary,
453
+ mode=mode,
454
+ )