gobby 0.2.5__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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +13 -4
- 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/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/runner.py +8 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -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/prompt_manager.py +125 -0
- gobby/cli/__init__.py +6 -0
- gobby/cli/clones.py +419 -0
- gobby/cli/conductor.py +266 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/antigravity.py +3 -9
- gobby/cli/installers/claude.py +15 -9
- gobby/cli/installers/codex.py +2 -8
- gobby/cli/installers/gemini.py +8 -8
- gobby/cli/installers/shared.py +175 -13
- gobby/cli/sessions.py +1 -1
- gobby/cli/skills.py +858 -0
- gobby/cli/tasks/ai.py +0 -440
- gobby/cli/tasks/crud.py +44 -6
- gobby/cli/tasks/main.py +0 -4
- gobby/cli/tui.py +2 -2
- gobby/cli/utils.py +12 -5
- gobby/clones/__init__.py +13 -0
- gobby/clones/git.py +547 -0
- gobby/conductor/__init__.py +16 -0
- gobby/conductor/alerts.py +135 -0
- gobby/conductor/loop.py +164 -0
- gobby/conductor/monitors/__init__.py +11 -0
- gobby/conductor/monitors/agents.py +116 -0
- gobby/conductor/monitors/tasks.py +155 -0
- gobby/conductor/pricing.py +234 -0
- gobby/conductor/token_tracker.py +160 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +69 -91
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/search.py +110 -0
- gobby/config/servers.py +1 -1
- gobby/config/skills.py +43 -0
- gobby/config/tasks.py +9 -41
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +188 -2
- gobby/hooks/hook_manager.py +50 -4
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/skill_manager.py +130 -0
- gobby/hooks/webhooks.py +1 -1
- gobby/install/claude/hooks/hook_dispatcher.py +4 -4
- gobby/install/codex/hooks/hook_dispatcher.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
- gobby/llm/claude.py +22 -34
- gobby/llm/claude_executor.py +46 -256
- gobby/llm/codex_executor.py +59 -291
- gobby/llm/executor.py +21 -0
- gobby/llm/gemini.py +134 -110
- gobby/llm/litellm_executor.py +143 -6
- gobby/llm/resolver.py +98 -35
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +56 -0
- gobby/mcp_proxy/models.py +15 -0
- gobby/mcp_proxy/registries.py +68 -8
- gobby/mcp_proxy/server.py +33 -3
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/services/tool_proxy.py +81 -1
- gobby/mcp_proxy/stdio.py +2 -1
- gobby/mcp_proxy/tools/__init__.py +0 -2
- gobby/mcp_proxy/tools/agent_messaging.py +317 -0
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +518 -0
- gobby/mcp_proxy/tools/memory.py +3 -26
- gobby/mcp_proxy/tools/metrics.py +65 -1
- gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
- gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -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 +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +616 -0
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/task_orchestration.py +7 -0
- gobby/mcp_proxy/tools/task_readiness.py +14 -0
- gobby/mcp_proxy/tools/task_sync.py +1 -1
- gobby/mcp_proxy/tools/tasks/_context.py +0 -20
- gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
- gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
- gobby/mcp_proxy/tools/workflows.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -338
- gobby/memory/backends/__init__.py +6 -1
- gobby/memory/backends/mem0.py +6 -1
- gobby/memory/extractor.py +477 -0
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +73 -285
- 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 +37 -16
- gobby/search/__init__.py +48 -6
- gobby/search/backends/__init__.py +159 -0
- gobby/search/backends/embedding.py +225 -0
- gobby/search/embeddings.py +238 -0
- gobby/search/models.py +148 -0
- gobby/search/unified.py +496 -0
- gobby/servers/http.py +24 -12
- gobby/servers/routes/admin.py +294 -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 +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1317
- gobby/servers/websocket.py +2 -2
- gobby/sessions/analyzer.py +2 -0
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +2 -0
- gobby/sessions/transcripts/claude.py +79 -10
- gobby/skills/__init__.py +91 -0
- gobby/skills/loader.py +685 -0
- gobby/skills/manager.py +384 -0
- gobby/skills/parser.py +286 -0
- gobby/skills/search.py +463 -0
- gobby/skills/sync.py +119 -0
- gobby/skills/updater.py +385 -0
- gobby/skills/validator.py +368 -0
- gobby/storage/clones.py +378 -0
- gobby/storage/database.py +1 -1
- gobby/storage/memories.py +43 -13
- gobby/storage/migrations.py +162 -201
- gobby/storage/sessions.py +116 -7
- gobby/storage/skills.py +782 -0
- gobby/storage/tasks/_crud.py +4 -4
- gobby/storage/tasks/_lifecycle.py +57 -7
- gobby/storage/tasks/_manager.py +14 -5
- gobby/storage/tasks/_models.py +8 -3
- gobby/sync/memories.py +40 -5
- gobby/sync/tasks.py +83 -6
- gobby/tasks/__init__.py +1 -2
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +46 -35
- gobby/tools/summarizer.py +91 -10
- gobby/tui/api_client.py +4 -7
- gobby/tui/app.py +5 -3
- gobby/tui/screens/orchestrator.py +1 -2
- gobby/tui/screens/tasks.py +2 -4
- gobby/tui/ws_client.py +1 -1
- gobby/utils/daemon_client.py +2 -2
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1135
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +93 -1
- gobby/workflows/detection_helpers.py +115 -31
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
- gobby/workflows/engine.py +13 -2
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/lifecycle_evaluator.py +29 -1
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/loader.py +19 -6
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +154 -0
- gobby/workflows/safe_evaluator.py +183 -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 +111 -1
- 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.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1292
- 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/install/codex/prompts/forget.md +0 -7
- gobby/install/codex/prompts/memories.md +0 -7
- gobby/install/codex/prompts/recall.md +0 -7
- gobby/install/codex/prompts/remember.md +0 -13
- gobby/llm/gemini_executor.py +0 -339
- gobby/mcp_proxy/tools/session_messages.py +0 -1056
- gobby/mcp_proxy/tools/task_expansion.py +0 -591
- 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/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/tasks/context.py +0 -747
- gobby/tasks/criteria.py +0 -342
- gobby/tasks/expansion.py +0 -626
- gobby/tasks/prompts/expand.py +0 -327
- gobby/tasks/research.py +0 -421
- gobby/tasks/tdd.py +0 -352
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Task enforcement actions for workflow engine.
|
|
1
|
+
"""Task policy enforcement for workflow engine.
|
|
3
2
|
|
|
4
|
-
Provides actions that enforce task tracking
|
|
5
|
-
and enforce task completion before allowing agent to stop.
|
|
3
|
+
Provides actions that enforce task tracking and scoping requirements.
|
|
6
4
|
"""
|
|
7
5
|
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
8
|
import logging
|
|
9
|
-
import subprocess # nosec B404 - subprocess needed for git commands
|
|
10
9
|
from typing import TYPE_CHECKING, Any
|
|
11
10
|
|
|
12
11
|
from gobby.mcp_proxy.tools.task_readiness import is_descendant_of
|
|
12
|
+
from gobby.workflows.git_utils import get_task_session_liveness
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from gobby.config.app import DaemonConfig
|
|
@@ -21,378 +21,13 @@ if TYPE_CHECKING:
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def _get_dirty_files(project_path: str | None = None) -> set[str]:
|
|
25
|
-
"""
|
|
26
|
-
Get the set of dirty files from git status --porcelain.
|
|
27
|
-
|
|
28
|
-
Excludes .gobby/ files from the result.
|
|
29
|
-
|
|
30
|
-
Args:
|
|
31
|
-
project_path: Path to the project directory
|
|
32
|
-
|
|
33
|
-
Returns:
|
|
34
|
-
Set of dirty file paths (relative to repo root)
|
|
35
|
-
"""
|
|
36
|
-
if project_path is None:
|
|
37
|
-
logger.warning(
|
|
38
|
-
"_get_dirty_files: project_path is None, git status will use daemon's cwd "
|
|
39
|
-
"which may not be the project directory"
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
try:
|
|
43
|
-
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
44
|
-
["git", "status", "--porcelain"],
|
|
45
|
-
cwd=project_path,
|
|
46
|
-
capture_output=True,
|
|
47
|
-
text=True,
|
|
48
|
-
timeout=10,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
if result.returncode != 0:
|
|
52
|
-
logger.warning(f"_get_dirty_files: git status failed: {result.stderr}")
|
|
53
|
-
return set()
|
|
54
|
-
|
|
55
|
-
dirty_files = set()
|
|
56
|
-
# Split by newline first, don't strip() the whole string as it removes
|
|
57
|
-
# the leading space from git status format (e.g., " M file.py")
|
|
58
|
-
for line in result.stdout.split("\n"):
|
|
59
|
-
line = line.rstrip() # Remove trailing whitespace only
|
|
60
|
-
if not line:
|
|
61
|
-
continue
|
|
62
|
-
# Format is "XY filename" or "XY filename -> newname" for renames
|
|
63
|
-
# Skip the status prefix (first 3 chars: 2 status chars + space)
|
|
64
|
-
filepath = line[3:].split(" -> ")[0] # Handle renames
|
|
65
|
-
# Exclude .gobby/ files
|
|
66
|
-
if not filepath.startswith(".gobby/"):
|
|
67
|
-
dirty_files.add(filepath)
|
|
68
|
-
|
|
69
|
-
return dirty_files
|
|
70
|
-
|
|
71
|
-
except subprocess.TimeoutExpired:
|
|
72
|
-
logger.warning("_get_dirty_files: git status timed out")
|
|
73
|
-
return set()
|
|
74
|
-
except FileNotFoundError:
|
|
75
|
-
logger.warning("_get_dirty_files: git not found")
|
|
76
|
-
return set()
|
|
77
|
-
except Exception as e:
|
|
78
|
-
logger.error(f"_get_dirty_files: Error running git status: {e}")
|
|
79
|
-
return set()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _get_task_session_liveness(
|
|
83
|
-
task_id: str,
|
|
84
|
-
session_task_manager: "SessionTaskManager | None",
|
|
85
|
-
session_manager: "LocalSessionManager | None",
|
|
86
|
-
exclude_session_id: str | None = None,
|
|
87
|
-
) -> bool:
|
|
88
|
-
"""
|
|
89
|
-
Check if a task is currently being worked on by an active session.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
task_id: The task ID to check
|
|
93
|
-
session_task_manager: Manager to look up session-task links
|
|
94
|
-
session_manager: Manager to check session status
|
|
95
|
-
exclude_session_id: ID of session to exclude from check (e.g. current one)
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
True if an active session (status='active') is linked to this task.
|
|
99
|
-
"""
|
|
100
|
-
if not session_task_manager or not session_manager:
|
|
101
|
-
return False
|
|
102
|
-
|
|
103
|
-
try:
|
|
104
|
-
# Get all sessions linked to this task
|
|
105
|
-
linked_sessions = session_task_manager.get_task_sessions(task_id)
|
|
106
|
-
|
|
107
|
-
for link in linked_sessions:
|
|
108
|
-
session_id = link.get("session_id")
|
|
109
|
-
if not session_id or session_id == exclude_session_id:
|
|
110
|
-
continue
|
|
111
|
-
|
|
112
|
-
# Check if session is truly active
|
|
113
|
-
session = session_manager.get(session_id)
|
|
114
|
-
if session and session.status == "active":
|
|
115
|
-
return True
|
|
116
|
-
|
|
117
|
-
return False
|
|
118
|
-
except Exception as e:
|
|
119
|
-
logger.warning(f"_get_task_session_liveness: Error checking liveness for {task_id}: {e}")
|
|
120
|
-
return False
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
async def capture_baseline_dirty_files(
|
|
124
|
-
workflow_state: "WorkflowState | None",
|
|
125
|
-
project_path: str | None = None,
|
|
126
|
-
) -> dict[str, Any] | None:
|
|
127
|
-
"""
|
|
128
|
-
Capture current dirty files as baseline for session-aware detection.
|
|
129
|
-
|
|
130
|
-
Called on session_start to record pre-existing dirty files. The
|
|
131
|
-
require_commit_before_stop action will compare against this baseline
|
|
132
|
-
to detect only NEW dirty files made during the session.
|
|
133
|
-
|
|
134
|
-
Args:
|
|
135
|
-
workflow_state: Workflow state to store baseline in
|
|
136
|
-
project_path: Path to the project directory for git status check
|
|
137
|
-
|
|
138
|
-
Returns:
|
|
139
|
-
Dict with captured baseline info, or None if no workflow_state
|
|
140
|
-
"""
|
|
141
|
-
if not workflow_state:
|
|
142
|
-
logger.debug("capture_baseline_dirty_files: No workflow_state, skipping")
|
|
143
|
-
return None
|
|
144
|
-
|
|
145
|
-
dirty_files = _get_dirty_files(project_path)
|
|
146
|
-
|
|
147
|
-
# Store as a list in workflow state (sets aren't JSON serializable)
|
|
148
|
-
workflow_state.variables["baseline_dirty_files"] = list(dirty_files)
|
|
149
|
-
|
|
150
|
-
# Log for debugging baseline capture issues
|
|
151
|
-
files_preview = list(dirty_files)[:5]
|
|
152
|
-
logger.info(
|
|
153
|
-
f"capture_baseline_dirty_files: project_path={project_path}, "
|
|
154
|
-
f"captured {len(dirty_files)} files: {files_preview}"
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
"baseline_captured": True,
|
|
159
|
-
"file_count": len(dirty_files),
|
|
160
|
-
"files": list(dirty_files),
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
async def require_commit_before_stop(
|
|
165
|
-
workflow_state: "WorkflowState | None",
|
|
166
|
-
project_path: str | None = None,
|
|
167
|
-
task_manager: "LocalTaskManager | None" = None,
|
|
168
|
-
) -> dict[str, Any] | None:
|
|
169
|
-
"""
|
|
170
|
-
Block stop if there's an in_progress task with uncommitted changes.
|
|
171
|
-
|
|
172
|
-
This action is designed for on_stop triggers to enforce that agents
|
|
173
|
-
commit their work and close tasks before stopping.
|
|
174
|
-
|
|
175
|
-
Args:
|
|
176
|
-
workflow_state: Workflow state with variables (claimed_task_id, etc.)
|
|
177
|
-
project_path: Path to the project directory for git status check
|
|
178
|
-
task_manager: LocalTaskManager to verify task status
|
|
179
|
-
|
|
180
|
-
Returns:
|
|
181
|
-
Dict with decision="block" and reason if task has uncommitted changes,
|
|
182
|
-
or None to allow the stop.
|
|
183
|
-
"""
|
|
184
|
-
if not workflow_state:
|
|
185
|
-
logger.debug("require_commit_before_stop: No workflow_state, allowing")
|
|
186
|
-
return None
|
|
187
|
-
|
|
188
|
-
claimed_task_id = workflow_state.variables.get("claimed_task_id")
|
|
189
|
-
if not claimed_task_id:
|
|
190
|
-
logger.debug("require_commit_before_stop: No claimed task, allowing")
|
|
191
|
-
return None
|
|
192
|
-
|
|
193
|
-
# Verify the task is actually still in_progress (not just cached in workflow state)
|
|
194
|
-
if task_manager:
|
|
195
|
-
task = task_manager.get_task(claimed_task_id)
|
|
196
|
-
if not task or task.status != "in_progress":
|
|
197
|
-
# Task was changed - clear the stale workflow state
|
|
198
|
-
logger.debug(
|
|
199
|
-
f"require_commit_before_stop: Task '{claimed_task_id}' is no longer "
|
|
200
|
-
f"in_progress (status={task.status if task else 'not found'}), clearing state"
|
|
201
|
-
)
|
|
202
|
-
workflow_state.variables["claimed_task_id"] = None
|
|
203
|
-
workflow_state.variables["task_claimed"] = False
|
|
204
|
-
return None
|
|
205
|
-
|
|
206
|
-
# Check for uncommitted changes using baseline-aware comparison
|
|
207
|
-
current_dirty = _get_dirty_files(project_path)
|
|
208
|
-
|
|
209
|
-
if not current_dirty:
|
|
210
|
-
logger.debug("require_commit_before_stop: No uncommitted changes, allowing")
|
|
211
|
-
return None
|
|
212
|
-
|
|
213
|
-
# Get baseline dirty files captured at session start
|
|
214
|
-
baseline_dirty = set(workflow_state.variables.get("baseline_dirty_files", []))
|
|
215
|
-
|
|
216
|
-
# Calculate NEW dirty files (not in baseline)
|
|
217
|
-
new_dirty = current_dirty - baseline_dirty
|
|
218
|
-
|
|
219
|
-
if not new_dirty:
|
|
220
|
-
logger.debug(
|
|
221
|
-
f"require_commit_before_stop: All {len(current_dirty)} dirty files were pre-existing "
|
|
222
|
-
f"(in baseline), allowing"
|
|
223
|
-
)
|
|
224
|
-
return None
|
|
225
|
-
|
|
226
|
-
logger.debug(
|
|
227
|
-
f"require_commit_before_stop: Found {len(new_dirty)} new dirty files "
|
|
228
|
-
f"(baseline had {len(baseline_dirty)}, current has {len(current_dirty)})"
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
# Track how many times we've blocked to prevent infinite loops
|
|
232
|
-
block_count = workflow_state.variables.get("_commit_block_count", 0)
|
|
233
|
-
if block_count >= 3:
|
|
234
|
-
logger.warning(
|
|
235
|
-
f"require_commit_before_stop: Reached max block count ({block_count}), allowing"
|
|
236
|
-
)
|
|
237
|
-
return None
|
|
238
|
-
|
|
239
|
-
workflow_state.variables["_commit_block_count"] = block_count + 1
|
|
240
|
-
|
|
241
|
-
# Block - agent needs to commit and close
|
|
242
|
-
logger.info(
|
|
243
|
-
f"require_commit_before_stop: Blocking stop - task '{claimed_task_id}' "
|
|
244
|
-
f"has {len(new_dirty)} uncommitted changes"
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
# Build list of new dirty files for the message (limit to 10 for readability)
|
|
248
|
-
new_dirty_list = sorted(new_dirty)[:10]
|
|
249
|
-
files_display = "\n".join(f" - {f}" for f in new_dirty_list)
|
|
250
|
-
if len(new_dirty) > 10:
|
|
251
|
-
files_display += f"\n ... and {len(new_dirty) - 10} more files"
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
"decision": "block",
|
|
255
|
-
"reason": (
|
|
256
|
-
f"Task '{claimed_task_id}' is in_progress with {len(new_dirty)} uncommitted "
|
|
257
|
-
f"changes made during this session:\n{files_display}\n\n"
|
|
258
|
-
f"Before stopping, commit your changes and close the task:\n"
|
|
259
|
-
f"1. Commit with [{claimed_task_id}] in the message\n"
|
|
260
|
-
f'2. Close the task: close_task(task_id="{claimed_task_id}", commit_sha="...")'
|
|
261
|
-
),
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
async def require_task_review_or_close_before_stop(
|
|
266
|
-
workflow_state: "WorkflowState | None",
|
|
267
|
-
task_manager: "LocalTaskManager | None" = None,
|
|
268
|
-
project_id: str | None = None,
|
|
269
|
-
**kwargs: Any,
|
|
270
|
-
) -> dict[str, Any] | None:
|
|
271
|
-
"""Block stop if session has an in_progress task.
|
|
272
|
-
|
|
273
|
-
Agents must close their task (or send to review) before stopping.
|
|
274
|
-
The close_task() validation already requires a commit, so we don't
|
|
275
|
-
need to check for uncommitted changes here - that's handled by
|
|
276
|
-
require_commit_before_stop if needed.
|
|
277
|
-
|
|
278
|
-
Checks both:
|
|
279
|
-
1. claimed_task_id - task explicitly claimed via update_task(status="in_progress")
|
|
280
|
-
2. session_task - task(s) assigned via set_variable (fallback if no claimed_task_id)
|
|
281
|
-
|
|
282
|
-
Args:
|
|
283
|
-
workflow_state: Workflow state with variables (claimed_task_id, etc.)
|
|
284
|
-
task_manager: LocalTaskManager to verify task status
|
|
285
|
-
project_id: Project ID for resolving task references (#N, N formats)
|
|
286
|
-
**kwargs: Accepts additional kwargs for compatibility
|
|
287
|
-
|
|
288
|
-
Returns:
|
|
289
|
-
Dict with decision="block" and reason if task is still in_progress,
|
|
290
|
-
or None to allow the stop.
|
|
291
|
-
"""
|
|
292
|
-
if not workflow_state:
|
|
293
|
-
logger.debug("require_task_review_or_close_before_stop: No workflow_state, allowing")
|
|
294
|
-
return None
|
|
295
|
-
|
|
296
|
-
# 1. Check claimed_task_id first (existing behavior)
|
|
297
|
-
claimed_task_id = workflow_state.variables.get("claimed_task_id")
|
|
298
|
-
|
|
299
|
-
# 2. If no claimed task, fall back to session_task
|
|
300
|
-
if not claimed_task_id and task_manager:
|
|
301
|
-
session_task = workflow_state.variables.get("session_task")
|
|
302
|
-
if session_task and session_task != "*":
|
|
303
|
-
# Normalize to list
|
|
304
|
-
task_ids = [session_task] if isinstance(session_task, str) else session_task
|
|
305
|
-
|
|
306
|
-
if isinstance(task_ids, list):
|
|
307
|
-
for task_id in task_ids:
|
|
308
|
-
try:
|
|
309
|
-
task = task_manager.get_task(task_id, project_id=project_id)
|
|
310
|
-
except ValueError:
|
|
311
|
-
continue
|
|
312
|
-
if task and task.status == "in_progress":
|
|
313
|
-
claimed_task_id = task_id
|
|
314
|
-
logger.debug(
|
|
315
|
-
f"require_task_review_or_close_before_stop: Found in_progress "
|
|
316
|
-
f"session_task '{task_id}'"
|
|
317
|
-
)
|
|
318
|
-
break
|
|
319
|
-
# Also check subtasks
|
|
320
|
-
if task:
|
|
321
|
-
subtasks = task_manager.list_tasks(parent_task_id=task.id)
|
|
322
|
-
for subtask in subtasks:
|
|
323
|
-
if subtask.status == "in_progress":
|
|
324
|
-
claimed_task_id = subtask.id
|
|
325
|
-
logger.debug(
|
|
326
|
-
f"require_task_review_or_close_before_stop: Found in_progress "
|
|
327
|
-
f"subtask '{subtask.id}' under session_task '{task_id}'"
|
|
328
|
-
)
|
|
329
|
-
break
|
|
330
|
-
if claimed_task_id:
|
|
331
|
-
break
|
|
332
|
-
|
|
333
|
-
if not claimed_task_id:
|
|
334
|
-
logger.debug("require_task_review_or_close_before_stop: No claimed task, allowing")
|
|
335
|
-
return None
|
|
336
|
-
|
|
337
|
-
if not task_manager:
|
|
338
|
-
logger.debug("require_task_review_or_close_before_stop: No task_manager, allowing")
|
|
339
|
-
return None
|
|
340
|
-
|
|
341
|
-
try:
|
|
342
|
-
task = task_manager.get_task(claimed_task_id, project_id=project_id)
|
|
343
|
-
if not task:
|
|
344
|
-
# Task not found - clear stale workflow state and allow
|
|
345
|
-
logger.debug(
|
|
346
|
-
f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' not found, "
|
|
347
|
-
f"clearing state"
|
|
348
|
-
)
|
|
349
|
-
workflow_state.variables["claimed_task_id"] = None
|
|
350
|
-
workflow_state.variables["task_claimed"] = False
|
|
351
|
-
return None
|
|
352
|
-
|
|
353
|
-
if task.status != "in_progress":
|
|
354
|
-
# Task is closed or in review - allow stop
|
|
355
|
-
logger.debug(
|
|
356
|
-
f"require_task_review_or_close_before_stop: Task '{claimed_task_id}' "
|
|
357
|
-
f"status={task.status}, allowing"
|
|
358
|
-
)
|
|
359
|
-
# Clear stale workflow state
|
|
360
|
-
workflow_state.variables["claimed_task_id"] = None
|
|
361
|
-
workflow_state.variables["task_claimed"] = False
|
|
362
|
-
return None
|
|
363
|
-
|
|
364
|
-
# Task is still in_progress - block the stop
|
|
365
|
-
logger.info(
|
|
366
|
-
f"require_task_review_or_close_before_stop: Blocking stop - task "
|
|
367
|
-
f"'{claimed_task_id}' is still in_progress"
|
|
368
|
-
)
|
|
369
|
-
|
|
370
|
-
return {
|
|
371
|
-
"decision": "block",
|
|
372
|
-
"reason": (
|
|
373
|
-
f"Task '{claimed_task_id}' is still in_progress. "
|
|
374
|
-
f"Close it with close_task() before stopping, or set to review "
|
|
375
|
-
f"if user intervention is needed."
|
|
376
|
-
),
|
|
377
|
-
"task_id": claimed_task_id,
|
|
378
|
-
"task_status": task.status,
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
except Exception as e:
|
|
382
|
-
logger.warning(
|
|
383
|
-
f"require_task_review_or_close_before_stop: Failed to check task status: {e}"
|
|
384
|
-
)
|
|
385
|
-
# Allow stop if we can't check - don't block on errors
|
|
386
|
-
return None
|
|
387
|
-
|
|
388
|
-
|
|
389
24
|
async def require_task_complete(
|
|
390
|
-
task_manager:
|
|
25
|
+
task_manager: LocalTaskManager | None,
|
|
391
26
|
session_id: str,
|
|
392
27
|
task_ids: list[str] | None,
|
|
393
28
|
event_data: dict[str, Any] | None = None,
|
|
394
29
|
project_id: str | None = None,
|
|
395
|
-
workflow_state:
|
|
30
|
+
workflow_state: WorkflowState | None = None,
|
|
396
31
|
) -> dict[str, Any] | None:
|
|
397
32
|
"""
|
|
398
33
|
Block agent from stopping until task(s) (and their subtasks) are complete.
|
|
@@ -406,10 +41,10 @@ async def require_task_complete(
|
|
|
406
41
|
- Wildcard mode handled by caller (passes ready tasks as list)
|
|
407
42
|
|
|
408
43
|
Logic per task:
|
|
409
|
-
1. If task has incomplete subtasks and agent has no claimed task
|
|
410
|
-
2. If task has incomplete subtasks and agent has claimed task
|
|
411
|
-
3. If all subtasks done but task not closed
|
|
412
|
-
4. If task is closed
|
|
44
|
+
1. If task has incomplete subtasks and agent has no claimed task -> suggest next subtask
|
|
45
|
+
2. If task has incomplete subtasks and agent has claimed task -> remind to finish it
|
|
46
|
+
3. If all subtasks done but task not closed -> remind to close the task
|
|
47
|
+
4. If task is closed -> move to next task in list
|
|
413
48
|
|
|
414
49
|
Args:
|
|
415
50
|
task_manager: LocalTaskManager for querying tasks
|
|
@@ -571,14 +206,14 @@ async def require_task_complete(
|
|
|
571
206
|
|
|
572
207
|
|
|
573
208
|
async def require_active_task(
|
|
574
|
-
task_manager:
|
|
209
|
+
task_manager: LocalTaskManager | None,
|
|
575
210
|
session_id: str,
|
|
576
|
-
config:
|
|
211
|
+
config: DaemonConfig | None,
|
|
577
212
|
event_data: dict[str, Any] | None,
|
|
578
213
|
project_id: str | None = None,
|
|
579
|
-
workflow_state:
|
|
580
|
-
session_manager:
|
|
581
|
-
session_task_manager:
|
|
214
|
+
workflow_state: WorkflowState | None = None,
|
|
215
|
+
session_manager: LocalSessionManager | None = None,
|
|
216
|
+
session_task_manager: SessionTaskManager | None = None,
|
|
582
217
|
) -> dict[str, Any] | None:
|
|
583
218
|
"""
|
|
584
219
|
Check if an active task exists before allowing protected tools.
|
|
@@ -708,7 +343,7 @@ async def require_active_task(
|
|
|
708
343
|
)
|
|
709
344
|
|
|
710
345
|
# Check liveness of the candidate task
|
|
711
|
-
is_live =
|
|
346
|
+
is_live = get_task_session_liveness(
|
|
712
347
|
task.id, session_task_manager, session_manager, exclude_session_id=session_id
|
|
713
348
|
)
|
|
714
349
|
|
|
@@ -748,10 +383,14 @@ async def require_active_task(
|
|
|
748
383
|
if error_already_shown:
|
|
749
384
|
return {
|
|
750
385
|
"decision": "block",
|
|
751
|
-
"reason":
|
|
386
|
+
"reason": (
|
|
387
|
+
"No task claimed. See previous **Task Required** error for instructions.\n"
|
|
388
|
+
"See skill: **claiming-tasks** for help."
|
|
389
|
+
),
|
|
752
390
|
"inject_context": (
|
|
753
391
|
f"**Task Required**: `{tool_name}` blocked. "
|
|
754
|
-
f"Create or claim a task before editing files (see previous error for details)
|
|
392
|
+
f"Create or claim a task before editing files (see previous error for details).\n"
|
|
393
|
+
f'For detailed guidance: `get_skill(name="claiming-tasks")`'
|
|
755
394
|
f"{project_task_hint}"
|
|
756
395
|
),
|
|
757
396
|
}
|
|
@@ -764,7 +403,8 @@ async def require_active_task(
|
|
|
764
403
|
f"- Create a task: call_tool(server_name='gobby-tasks', tool_name='create_task', arguments={{...}})\n"
|
|
765
404
|
f"- Claim an existing task: call_tool(server_name='gobby-tasks', tool_name='update_task', "
|
|
766
405
|
f"arguments={{'task_id': '...', 'status': 'in_progress'}})"
|
|
767
|
-
f"{project_task_hint}"
|
|
406
|
+
f"{project_task_hint}\n\n"
|
|
407
|
+
f"See skill: **claiming-tasks** for detailed guidance."
|
|
768
408
|
),
|
|
769
409
|
"inject_context": (
|
|
770
410
|
f"**Task Required**: The `{tool_name}` tool is blocked until you claim a task for this session.\n\n"
|
|
@@ -772,14 +412,15 @@ async def require_active_task(
|
|
|
772
412
|
f'1. **Create a new task**: `create_task(title="...", description="...")`\n'
|
|
773
413
|
f'2. **Claim an existing task**: `update_task(task_id="...", status="in_progress")`\n\n'
|
|
774
414
|
f"Use `list_ready_tasks()` to see available tasks."
|
|
775
|
-
f"{project_task_hint}"
|
|
415
|
+
f"{project_task_hint}\n\n"
|
|
416
|
+
f'For detailed guidance: `get_skill(name="claiming-tasks")`'
|
|
776
417
|
),
|
|
777
418
|
}
|
|
778
419
|
|
|
779
420
|
|
|
780
421
|
async def validate_session_task_scope(
|
|
781
|
-
task_manager:
|
|
782
|
-
workflow_state:
|
|
422
|
+
task_manager: LocalTaskManager | None,
|
|
423
|
+
workflow_state: WorkflowState | None,
|
|
783
424
|
event_data: dict[str, Any] | None = None,
|
|
784
425
|
) -> dict[str, Any] | None:
|
|
785
426
|
"""
|
gobby/workflows/engine.py
CHANGED
|
@@ -15,7 +15,12 @@ from .audit_helpers import (
|
|
|
15
15
|
log_transition,
|
|
16
16
|
)
|
|
17
17
|
from .definitions import WorkflowDefinition, WorkflowState
|
|
18
|
-
from .detection_helpers import
|
|
18
|
+
from .detection_helpers import (
|
|
19
|
+
detect_mcp_call,
|
|
20
|
+
detect_plan_mode,
|
|
21
|
+
detect_plan_mode_from_context,
|
|
22
|
+
detect_task_claim,
|
|
23
|
+
)
|
|
19
24
|
from .evaluator import ConditionEvaluator
|
|
20
25
|
from .lifecycle_evaluator import (
|
|
21
26
|
evaluate_all_lifecycle_workflows as _evaluate_all_lifecycle_workflows,
|
|
@@ -375,6 +380,7 @@ class WorkflowEngine:
|
|
|
375
380
|
evaluator=self.evaluator,
|
|
376
381
|
detect_task_claim_fn=self._detect_task_claim,
|
|
377
382
|
detect_plan_mode_fn=self._detect_plan_mode,
|
|
383
|
+
detect_plan_mode_from_context_fn=self._detect_plan_mode_from_context,
|
|
378
384
|
check_premature_stop_fn=self._check_premature_stop,
|
|
379
385
|
context_data=context_data,
|
|
380
386
|
)
|
|
@@ -474,12 +480,17 @@ class WorkflowEngine:
|
|
|
474
480
|
def _detect_task_claim(self, event: HookEvent, state: WorkflowState) -> None:
|
|
475
481
|
"""Detect gobby-tasks calls that claim or release a task for this session."""
|
|
476
482
|
session_task_manager = getattr(self.action_executor, "session_task_manager", None)
|
|
477
|
-
|
|
483
|
+
task_manager = getattr(self.action_executor, "task_manager", None)
|
|
484
|
+
detect_task_claim(event, state, session_task_manager, task_manager)
|
|
478
485
|
|
|
479
486
|
def _detect_plan_mode(self, event: HookEvent, state: WorkflowState) -> None:
|
|
480
487
|
"""Detect Claude Code plan mode entry/exit and set workflow variable."""
|
|
481
488
|
detect_plan_mode(event, state)
|
|
482
489
|
|
|
490
|
+
def _detect_plan_mode_from_context(self, event: HookEvent, state: WorkflowState) -> None:
|
|
491
|
+
"""Detect plan mode from system reminders in user prompt."""
|
|
492
|
+
detect_plan_mode_from_context(event, state)
|
|
493
|
+
|
|
483
494
|
def _detect_mcp_call(self, event: HookEvent, state: WorkflowState) -> None:
|
|
484
495
|
"""Track MCP tool calls by server/tool for workflow conditions."""
|
|
485
496
|
detect_mcp_call(event, state)
|
gobby/workflows/git_utils.py
CHANGED
|
@@ -4,8 +4,15 @@ Extracted from actions.py as part of strangler fig decomposition.
|
|
|
4
4
|
These are pure utility functions with no ActionContext dependency.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
7
9
|
import logging
|
|
8
10
|
import subprocess # nosec B404 - subprocess needed for git commands
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from gobby.storage.session_tasks import SessionTaskManager
|
|
15
|
+
from gobby.storage.sessions import LocalSessionManager
|
|
9
16
|
|
|
10
17
|
logger = logging.getLogger(__name__)
|
|
11
18
|
|
|
@@ -94,3 +101,102 @@ def get_file_changes() -> str:
|
|
|
94
101
|
|
|
95
102
|
except Exception:
|
|
96
103
|
return "Unable to determine file changes"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_dirty_files(project_path: str | None = None) -> set[str]:
|
|
107
|
+
"""
|
|
108
|
+
Get the set of dirty files from git status --porcelain.
|
|
109
|
+
|
|
110
|
+
Excludes .gobby/ files from the result.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
project_path: Path to the project directory
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Set of dirty file paths (relative to repo root)
|
|
117
|
+
"""
|
|
118
|
+
if project_path is None:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"get_dirty_files: project_path is None, git status will use daemon's cwd "
|
|
121
|
+
"which may not be the project directory"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run( # nosec B603 B607 - hardcoded git command
|
|
126
|
+
["git", "status", "--porcelain"],
|
|
127
|
+
cwd=project_path,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
timeout=10,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
logger.warning(f"get_dirty_files: git status failed: {result.stderr}")
|
|
135
|
+
return set()
|
|
136
|
+
|
|
137
|
+
dirty_files = set()
|
|
138
|
+
# Split by newline first, don't strip() the whole string as it removes
|
|
139
|
+
# the leading space from git status format (e.g., " M file.py")
|
|
140
|
+
for line in result.stdout.split("\n"):
|
|
141
|
+
line = line.rstrip() # Remove trailing whitespace only
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
# Format is "XY filename" or "XY filename -> newname" for renames
|
|
145
|
+
# Skip the status prefix (first 3 chars: 2 status chars + space)
|
|
146
|
+
filepath = line[3:].split(" -> ")[0] # Handle renames
|
|
147
|
+
# Exclude .gobby/ files
|
|
148
|
+
if not filepath.startswith(".gobby/"):
|
|
149
|
+
dirty_files.add(filepath)
|
|
150
|
+
|
|
151
|
+
return dirty_files
|
|
152
|
+
|
|
153
|
+
except subprocess.TimeoutExpired:
|
|
154
|
+
logger.warning("get_dirty_files: git status timed out")
|
|
155
|
+
return set()
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
logger.warning("get_dirty_files: git not found")
|
|
158
|
+
return set()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logger.error(f"get_dirty_files: Error running git status: {e}")
|
|
161
|
+
return set()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_task_session_liveness(
|
|
165
|
+
task_id: str,
|
|
166
|
+
session_task_manager: SessionTaskManager | None,
|
|
167
|
+
session_manager: LocalSessionManager | None,
|
|
168
|
+
exclude_session_id: str | None = None,
|
|
169
|
+
) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Check if a task is currently being worked on by an active session.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
task_id: The task ID to check
|
|
175
|
+
session_task_manager: Manager to look up session-task links
|
|
176
|
+
session_manager: Manager to check session status
|
|
177
|
+
exclude_session_id: ID of session to exclude from check (e.g. current one)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if an active session (status='active') is linked to this task.
|
|
181
|
+
"""
|
|
182
|
+
if not session_task_manager or not session_manager:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Get all sessions linked to this task
|
|
187
|
+
linked_sessions = session_task_manager.get_task_sessions(task_id)
|
|
188
|
+
|
|
189
|
+
for link in linked_sessions:
|
|
190
|
+
session_id = link.get("session_id")
|
|
191
|
+
if not session_id or session_id == exclude_session_id:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Check if session is truly active
|
|
195
|
+
session = session_manager.get(session_id)
|
|
196
|
+
if session and session.status == "active":
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
return False
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.warning(f"get_task_session_liveness: Error checking liveness for {task_id}: {e}")
|
|
202
|
+
return False
|