caudate-cli 0.1.0__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 (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,255 @@
1
+ """FindAnywhere — unified filesystem search.
2
+
3
+ Combines glob (filename matching), grep (content matching), and
4
+ metadata filtering (size / mtime) into one tool. Replaces the common
5
+ "chain three tools to find that file" pattern.
6
+
7
+ Returns a ranked list of matches: each match has the file path plus
8
+ optional snippet showing the content match (when `content` was given).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from core.schemas import ToolResult
20
+ from execution.tools.base import BaseTool
21
+
22
+
23
+ _DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset({
24
+ ".git", ".hg", ".svn", "__pycache__", "node_modules", ".venv",
25
+ "venv", ".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
26
+ ".cache", ".idea", ".vscode",
27
+ })
28
+
29
+ _MAX_FILE_BYTES = 5 * 1024 * 1024 # skip files >5MB for content match
30
+ _MAX_RESULTS_DEFAULT = 50
31
+ _DEFAULT_ROOT = "/home/raveuk/cognos"
32
+
33
+
34
+ def _parse_age(value: str | None) -> float | None:
35
+ """Parse a human age string like '7d', '24h', '30m' → seconds."""
36
+ if not value:
37
+ return None
38
+ s = value.strip().lower()
39
+ if not s:
40
+ return None
41
+ try:
42
+ if s.endswith("d"):
43
+ return float(s[:-1]) * 86400
44
+ if s.endswith("h"):
45
+ return float(s[:-1]) * 3600
46
+ if s.endswith("m"):
47
+ return float(s[:-1]) * 60
48
+ if s.endswith("s"):
49
+ return float(s[:-1])
50
+ return float(s) # bare seconds
51
+ except ValueError:
52
+ return None
53
+
54
+
55
+ def _parse_size(value: str | int | None) -> int | None:
56
+ """Parse a size like '1MB', '50KB', '1024' → bytes."""
57
+ if value is None:
58
+ return None
59
+ if isinstance(value, int):
60
+ return value
61
+ s = str(value).strip().upper()
62
+ try:
63
+ if s.endswith("KB") or s.endswith("K"):
64
+ return int(float(s.rstrip("KB").rstrip("K")) * 1024)
65
+ if s.endswith("MB") or s.endswith("M"):
66
+ return int(float(s.rstrip("MB").rstrip("M")) * 1024 * 1024)
67
+ if s.endswith("GB") or s.endswith("G"):
68
+ return int(float(s.rstrip("GB").rstrip("G")) * 1024 * 1024 * 1024)
69
+ if s.endswith("B"):
70
+ return int(s.rstrip("B"))
71
+ return int(s)
72
+ except ValueError:
73
+ return None
74
+
75
+
76
+ class FindAnywhereTool(BaseTool):
77
+ name = "FindAnywhere"
78
+ description = (
79
+ "Unified filesystem search: combine filename pattern (glob), "
80
+ "content match (regex), and metadata filters (size, age) in one "
81
+ "call. Returns ranked matches with optional content snippets. "
82
+ "Use this when you'd otherwise chain Glob + Grep + ls."
83
+ )
84
+
85
+ @property
86
+ def input_schema(self) -> dict[str, Any]:
87
+ return {
88
+ "type": "object",
89
+ "properties": {
90
+ "name": {
91
+ "type": "string",
92
+ "description": (
93
+ "Filename pattern (glob, e.g. '*.py', '**/main.*'). "
94
+ "Optional — omit to search all files."
95
+ ),
96
+ },
97
+ "content": {
98
+ "type": "string",
99
+ "description": (
100
+ "Regex to match inside file contents. Optional. "
101
+ "Files are matched if their content matches."
102
+ ),
103
+ },
104
+ "path": {
105
+ "type": "string",
106
+ "description": (
107
+ f"Root directory to search. Defaults to {_DEFAULT_ROOT}."
108
+ ),
109
+ },
110
+ "ignore_case": {
111
+ "type": "boolean",
112
+ "description": "Case-insensitive content matching. Default true.",
113
+ "default": True,
114
+ },
115
+ "max_size": {
116
+ "type": "string",
117
+ "description": "Skip files larger than this (e.g. '1MB', '500KB').",
118
+ },
119
+ "modified_within": {
120
+ "type": "string",
121
+ "description": "Only include files modified within this age (e.g. '7d', '24h').",
122
+ },
123
+ "max_results": {
124
+ "type": "integer",
125
+ "description": f"Cap on results (default {_MAX_RESULTS_DEFAULT}).",
126
+ "default": _MAX_RESULTS_DEFAULT,
127
+ },
128
+ },
129
+ "required": [],
130
+ }
131
+
132
+ async def execute(
133
+ self,
134
+ name: str = "",
135
+ content: str = "",
136
+ path: str = _DEFAULT_ROOT,
137
+ ignore_case: bool = True,
138
+ max_size: str | None = None,
139
+ modified_within: str | None = None,
140
+ max_results: int = _MAX_RESULTS_DEFAULT,
141
+ **_: Any,
142
+ ) -> ToolResult:
143
+ try:
144
+ return self._run(
145
+ name_pattern=name or None,
146
+ content_pattern=content or None,
147
+ root=path or _DEFAULT_ROOT,
148
+ ignore_case=bool(ignore_case),
149
+ max_size_bytes=_parse_size(max_size),
150
+ modified_within_seconds=_parse_age(modified_within),
151
+ max_results=max(1, int(max_results)),
152
+ )
153
+ except Exception as e:
154
+ return ToolResult(tool_name=self.name, status="error", error=str(e))
155
+
156
+ def _run(
157
+ self, *,
158
+ name_pattern: str | None,
159
+ content_pattern: str | None,
160
+ root: str,
161
+ ignore_case: bool,
162
+ max_size_bytes: int | None,
163
+ modified_within_seconds: float | None,
164
+ max_results: int,
165
+ ) -> ToolResult:
166
+ root_path = Path(root).expanduser().resolve()
167
+ if not root_path.exists():
168
+ return ToolResult(
169
+ tool_name=self.name, status="error",
170
+ error=f"path does not exist: {root_path}",
171
+ )
172
+
173
+ flags = re.IGNORECASE if ignore_case else 0
174
+ content_re = re.compile(content_pattern, flags) if content_pattern else None
175
+ cutoff_mtime = (time.time() - modified_within_seconds
176
+ if modified_within_seconds else None)
177
+
178
+ results: list[dict[str, Any]] = []
179
+ for candidate in self._walk(root_path):
180
+ if len(results) >= max_results:
181
+ break
182
+
183
+ # Filename pattern
184
+ if name_pattern:
185
+ if not candidate.match(name_pattern):
186
+ continue
187
+
188
+ # Metadata
189
+ try:
190
+ stat = candidate.stat()
191
+ except OSError:
192
+ continue
193
+ if max_size_bytes is not None and stat.st_size > max_size_bytes:
194
+ continue
195
+ if cutoff_mtime is not None and stat.st_mtime < cutoff_mtime:
196
+ continue
197
+
198
+ entry: dict[str, Any] = {
199
+ "path": str(candidate),
200
+ "size_bytes": stat.st_size,
201
+ "modified_iso": time.strftime(
202
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)
203
+ ),
204
+ }
205
+
206
+ # Content match
207
+ if content_re is not None:
208
+ if stat.st_size > _MAX_FILE_BYTES:
209
+ continue
210
+ snippet = self._first_match(candidate, content_re)
211
+ if snippet is None:
212
+ continue
213
+ entry["snippet"] = snippet
214
+ results.append(entry)
215
+
216
+ # Format human-readable output
217
+ if not results:
218
+ return ToolResult(
219
+ tool_name=self.name, status="success",
220
+ output=f"No matches under {root_path}",
221
+ )
222
+
223
+ lines = [f"Found {len(results)} match(es) under {root_path}:"]
224
+ for r in results:
225
+ line = f" {r['path']} ({r['size_bytes']}B {r['modified_iso']})"
226
+ if "snippet" in r:
227
+ line += f"\n > {r['snippet']}"
228
+ lines.append(line)
229
+ return ToolResult(
230
+ tool_name=self.name, status="success",
231
+ output="\n".join(lines),
232
+ metadata={"matches": results},
233
+ )
234
+
235
+ @staticmethod
236
+ def _walk(root: Path):
237
+ for dirpath, dirnames, filenames in os.walk(root):
238
+ # Prune ignored dirs in place
239
+ dirnames[:] = [d for d in dirnames if d not in _DEFAULT_IGNORE_DIRS]
240
+ for fn in filenames:
241
+ yield Path(dirpath) / fn
242
+
243
+ @staticmethod
244
+ def _first_match(file: Path, regex: re.Pattern) -> str | None:
245
+ try:
246
+ with file.open("r", errors="replace") as f:
247
+ for i, line in enumerate(f, start=1):
248
+ if regex.search(line):
249
+ snippet = line.rstrip()
250
+ if len(snippet) > 200:
251
+ snippet = snippet[:200] + "…"
252
+ return f"L{i}: {snippet}"
253
+ except (OSError, UnicodeDecodeError):
254
+ return None
255
+ return None
@@ -0,0 +1,377 @@
1
+ """Forge feature-CRUD tools — let the agent manage its own backlog mid-run.
2
+
3
+ Ported from LocalForge's ``lib/agent/feature-crud-tools.ts``. Seven
4
+ tools (list / create / update / delete / add_dependency /
5
+ remove_dependency / set_dependencies) all scoped to whichever Forge
6
+ project the orchestrator has bound for this session.
7
+
8
+ Project binding uses a ``ContextVar`` set by ``planning/orchestrator.py``
9
+ before it calls ``agent.chat(prompt)``. Outside of a forge session the
10
+ context var is unset and the tools refuse — they have no business
11
+ running in a regular chat.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from contextlib import contextmanager
18
+ from contextvars import ContextVar
19
+ from typing import Any, Iterator
20
+
21
+ from core.schemas import ToolResult
22
+ from execution.tools.base import BaseTool
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ _active_project: ContextVar[int | None] = ContextVar(
28
+ "forge_active_project_id", default=None,
29
+ )
30
+
31
+
32
+ @contextmanager
33
+ def forge_project_scope(project_id: int) -> Iterator[None]:
34
+ """Bind ``project_id`` for any Forge tool calls inside the block."""
35
+ token = _active_project.set(project_id)
36
+ try:
37
+ yield
38
+ finally:
39
+ _active_project.reset(token)
40
+
41
+
42
+ def _current_project_id() -> int | None:
43
+ return _active_project.get()
44
+
45
+
46
+ # ────────────────────────── Helpers ─────────────────────────────────
47
+
48
+
49
+ def _assert_in_project(feature_id: int, project_id: int) -> None:
50
+ """Raise ValueError if feature does not belong to project."""
51
+ from planning.forge_models import get_feature
52
+ row = get_feature(feature_id)
53
+ if row is None:
54
+ raise ValueError(f"Feature {feature_id} not found")
55
+ if row["project_id"] != project_id:
56
+ raise ValueError(
57
+ f"Feature {feature_id} belongs to a different project"
58
+ )
59
+
60
+
61
+ def _no_project_error(tool_name: str) -> ToolResult:
62
+ return ToolResult(
63
+ tool_name=tool_name, status="error",
64
+ error="no Forge project bound for this session — this tool is "
65
+ "only callable when the orchestrator runs the agent on a "
66
+ "feature.",
67
+ )
68
+
69
+
70
+ # ────────────────────────── Tools ───────────────────────────────────
71
+
72
+
73
+ class ForgeListFeaturesTool(BaseTool):
74
+ name = "forge_list_features"
75
+ description = (
76
+ "List every feature in this Forge project's backlog. Call this "
77
+ "FIRST so you know which features already exist, what ids they "
78
+ "have, and so you do not create duplicates."
79
+ )
80
+ mutates = False
81
+
82
+ @property
83
+ def input_schema(self) -> dict[str, Any]:
84
+ return {"type": "object", "properties": {}, "required": []}
85
+
86
+ async def execute(self, **_: Any) -> ToolResult:
87
+ pid = _current_project_id()
88
+ if pid is None:
89
+ return _no_project_error(self.name)
90
+ try:
91
+ from planning.forge_models import list_features
92
+ rows = list_features(pid)
93
+ except Exception as e:
94
+ return self._error(f"{type(e).__name__}: {e}")
95
+ # Slim payload — agent doesn't need acceptance_criteria etc here
96
+ slim = [
97
+ {
98
+ "id": r["id"],
99
+ "title": r["title"],
100
+ "description": r["description"],
101
+ "category": r["category"],
102
+ "status": r["status"],
103
+ "priority": r["priority"],
104
+ "depends_on": r.get("depends_on", []),
105
+ }
106
+ for r in rows
107
+ ]
108
+ return self._success(slim)
109
+
110
+
111
+ class ForgeCreateFeatureTool(BaseTool):
112
+ name = "forge_create_feature"
113
+ description = (
114
+ "Create a new feature in this Forge project. Returns the new id; "
115
+ "use it with forge_add_dependency / forge_set_dependencies to wire "
116
+ "prerequisites."
117
+ )
118
+ mutates = True
119
+
120
+ @property
121
+ def input_schema(self) -> dict[str, Any]:
122
+ return {
123
+ "type": "object",
124
+ "properties": {
125
+ "title": {
126
+ "type": "string", "minLength": 1, "maxLength": 200,
127
+ "description": "Short imperative title.",
128
+ },
129
+ "description": {
130
+ "type": "string",
131
+ "description": "What done looks like for this feature.",
132
+ },
133
+ "acceptance_criteria": {
134
+ "type": "string",
135
+ "description": "Bullet list of testable conditions.",
136
+ },
137
+ "category": {
138
+ "type": "string", "enum": ["functional", "style"],
139
+ "default": "functional",
140
+ },
141
+ "priority": {"type": "integer", "default": 0},
142
+ "verify_command": {"type": "string"},
143
+ "depends_on": {
144
+ "type": "array", "items": {"type": "integer"},
145
+ "description": "Optional ids of prerequisites.",
146
+ },
147
+ },
148
+ "required": ["title"],
149
+ }
150
+
151
+ async def execute(self, **kwargs: Any) -> ToolResult:
152
+ pid = _current_project_id()
153
+ if pid is None:
154
+ return _no_project_error(self.name)
155
+ try:
156
+ from planning.forge_models import (
157
+ create_feature, add_dependency, get_feature,
158
+ )
159
+ fid = create_feature(
160
+ project_id=pid,
161
+ title=kwargs["title"],
162
+ description=kwargs.get("description"),
163
+ acceptance_criteria=kwargs.get("acceptance_criteria"),
164
+ priority=int(kwargs.get("priority", 0)),
165
+ category=kwargs.get("category", "functional"),
166
+ verify_command=kwargs.get("verify_command"),
167
+ )
168
+ linked: list[int] = []
169
+ skipped: list[dict[str, Any]] = []
170
+ for dep in (kwargs.get("depends_on") or []):
171
+ try:
172
+ _assert_in_project(int(dep), pid)
173
+ add_dependency(fid, int(dep))
174
+ linked.append(int(dep))
175
+ except Exception as e:
176
+ skipped.append({"id": int(dep), "reason": str(e)})
177
+ row = get_feature(fid) or {}
178
+ return self._success({
179
+ "id": fid,
180
+ "title": row.get("title"),
181
+ "linked_dependencies": linked,
182
+ "skipped_dependencies": skipped,
183
+ })
184
+ except Exception as e:
185
+ return self._error(f"{type(e).__name__}: {e}")
186
+
187
+
188
+ class ForgeUpdateFeatureTool(BaseTool):
189
+ name = "forge_update_feature"
190
+ description = (
191
+ "Edit fields of an existing feature in this project. Pass only the "
192
+ "fields you want to change."
193
+ )
194
+ mutates = True
195
+
196
+ @property
197
+ def input_schema(self) -> dict[str, Any]:
198
+ return {
199
+ "type": "object",
200
+ "properties": {
201
+ "id": {"type": "integer", "minimum": 1},
202
+ "title": {"type": "string", "minLength": 1, "maxLength": 200},
203
+ "description": {"type": "string"},
204
+ "acceptance_criteria": {"type": "string"},
205
+ "category": {"type": "string", "enum": ["functional", "style"]},
206
+ "priority": {
207
+ "type": "integer",
208
+ "description": "Lower = higher up in backlog. 0 = top.",
209
+ },
210
+ "verify_command": {"type": "string"},
211
+ },
212
+ "required": ["id"],
213
+ }
214
+
215
+ async def execute(self, **kwargs: Any) -> ToolResult:
216
+ pid = _current_project_id()
217
+ if pid is None:
218
+ return _no_project_error(self.name)
219
+ try:
220
+ fid = int(kwargs.pop("id"))
221
+ _assert_in_project(fid, pid)
222
+ from planning.forge_models import update_feature
223
+ row = update_feature(fid, **kwargs)
224
+ if row is None:
225
+ return self._error(f"Feature {fid} not found.")
226
+ return self._success({
227
+ "id": row["id"], "title": row["title"],
228
+ "priority": row["priority"], "category": row["category"],
229
+ "status": row["status"],
230
+ })
231
+ except Exception as e:
232
+ return self._error(f"{type(e).__name__}: {e}")
233
+
234
+
235
+ class ForgeDeleteFeatureTool(BaseTool):
236
+ name = "forge_delete_feature"
237
+ description = "Remove a feature from this project's backlog."
238
+ mutates = True
239
+
240
+ @property
241
+ def input_schema(self) -> dict[str, Any]:
242
+ return {
243
+ "type": "object",
244
+ "properties": {"id": {"type": "integer", "minimum": 1}},
245
+ "required": ["id"],
246
+ }
247
+
248
+ async def execute(self, **kwargs: Any) -> ToolResult:
249
+ pid = _current_project_id()
250
+ if pid is None:
251
+ return _no_project_error(self.name)
252
+ try:
253
+ fid = int(kwargs["id"])
254
+ _assert_in_project(fid, pid)
255
+ from planning.forge_models import delete_feature
256
+ ok = delete_feature(fid)
257
+ return self._success(f"Deleted feature {fid}." if ok
258
+ else f"Feature {fid} not found.")
259
+ except Exception as e:
260
+ return self._error(f"{type(e).__name__}: {e}")
261
+
262
+
263
+ class ForgeAddDependencyTool(BaseTool):
264
+ name = "forge_add_dependency"
265
+ description = (
266
+ "Declare that one feature depends on another. Both must belong "
267
+ "to this project. Rejected if it would create a cycle."
268
+ )
269
+ mutates = True
270
+
271
+ @property
272
+ def input_schema(self) -> dict[str, Any]:
273
+ return {
274
+ "type": "object",
275
+ "properties": {
276
+ "feature_id": {"type": "integer", "minimum": 1},
277
+ "depends_on_feature_id": {"type": "integer", "minimum": 1},
278
+ },
279
+ "required": ["feature_id", "depends_on_feature_id"],
280
+ }
281
+
282
+ async def execute(self, **kwargs: Any) -> ToolResult:
283
+ pid = _current_project_id()
284
+ if pid is None:
285
+ return _no_project_error(self.name)
286
+ try:
287
+ fid = int(kwargs["feature_id"])
288
+ did = int(kwargs["depends_on_feature_id"])
289
+ _assert_in_project(fid, pid)
290
+ _assert_in_project(did, pid)
291
+ from planning.forge_models import add_dependency
292
+ add_dependency(fid, did)
293
+ return self._success(f"Feature {fid} now depends on {did}.")
294
+ except Exception as e:
295
+ return self._error(f"{type(e).__name__}: {e}")
296
+
297
+
298
+ class ForgeRemoveDependencyTool(BaseTool):
299
+ name = "forge_remove_dependency"
300
+ description = "Remove a dependency edge between two features."
301
+ mutates = True
302
+
303
+ @property
304
+ def input_schema(self) -> dict[str, Any]:
305
+ return {
306
+ "type": "object",
307
+ "properties": {
308
+ "feature_id": {"type": "integer", "minimum": 1},
309
+ "depends_on_feature_id": {"type": "integer", "minimum": 1},
310
+ },
311
+ "required": ["feature_id", "depends_on_feature_id"],
312
+ }
313
+
314
+ async def execute(self, **kwargs: Any) -> ToolResult:
315
+ pid = _current_project_id()
316
+ if pid is None:
317
+ return _no_project_error(self.name)
318
+ try:
319
+ fid = int(kwargs["feature_id"])
320
+ did = int(kwargs["depends_on_feature_id"])
321
+ _assert_in_project(fid, pid)
322
+ from planning.forge_models import remove_dependency
323
+ ok = remove_dependency(fid, did)
324
+ return self._success(
325
+ f"Removed {fid} -> {did}." if ok else "No such dependency."
326
+ )
327
+ except Exception as e:
328
+ return self._error(f"{type(e).__name__}: {e}")
329
+
330
+
331
+ class ForgeSetDependenciesTool(BaseTool):
332
+ name = "forge_set_dependencies"
333
+ description = (
334
+ "Replace the full dependency list for a feature in one call. "
335
+ "Pass depends_on=[] to clear all dependencies."
336
+ )
337
+ mutates = True
338
+
339
+ @property
340
+ def input_schema(self) -> dict[str, Any]:
341
+ return {
342
+ "type": "object",
343
+ "properties": {
344
+ "feature_id": {"type": "integer", "minimum": 1},
345
+ "depends_on": {
346
+ "type": "array", "items": {"type": "integer"},
347
+ },
348
+ },
349
+ "required": ["feature_id", "depends_on"],
350
+ }
351
+
352
+ async def execute(self, **kwargs: Any) -> ToolResult:
353
+ pid = _current_project_id()
354
+ if pid is None:
355
+ return _no_project_error(self.name)
356
+ try:
357
+ fid = int(kwargs["feature_id"])
358
+ deps = [int(x) for x in (kwargs.get("depends_on") or [])]
359
+ _assert_in_project(fid, pid)
360
+ for d in deps:
361
+ _assert_in_project(d, pid)
362
+ from planning.forge_models import set_dependencies
363
+ out = set_dependencies(fid, deps)
364
+ return self._success(out)
365
+ except Exception as e:
366
+ return self._error(f"{type(e).__name__}: {e}")
367
+
368
+
369
+ FORGE_FEATURE_TOOLS: list[type[BaseTool]] = [
370
+ ForgeListFeaturesTool,
371
+ ForgeCreateFeatureTool,
372
+ ForgeUpdateFeatureTool,
373
+ ForgeDeleteFeatureTool,
374
+ ForgeAddDependencyTool,
375
+ ForgeRemoveDependencyTool,
376
+ ForgeSetDependenciesTool,
377
+ ]
@@ -0,0 +1,59 @@
1
+ """Glob tool — find files by glob pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from core.schemas import ToolResult
9
+ from execution.tools.base import BaseTool
10
+
11
+
12
+ class GlobTool(BaseTool):
13
+ name = "Glob"
14
+ description = (
15
+ "Find files matching a glob pattern (e.g. '**/*.py', 'src/**/*.ts'). "
16
+ "Returns a newline-separated list of paths, sorted by modification time."
17
+ )
18
+
19
+ @property
20
+ def input_schema(self) -> dict:
21
+ return {
22
+ "type": "object",
23
+ "properties": {
24
+ "pattern": {
25
+ "type": "string",
26
+ "description": "Glob pattern, e.g. '**/*.py'",
27
+ },
28
+ "path": {
29
+ "type": "string",
30
+ "description": "Root directory to search in. Defaults to cwd.",
31
+ },
32
+ "limit": {
33
+ "type": "integer",
34
+ "description": "Maximum results (default 200)",
35
+ "default": 200,
36
+ },
37
+ },
38
+ "required": ["pattern"],
39
+ }
40
+
41
+ async def execute(self, **kwargs: Any) -> ToolResult:
42
+ pattern = kwargs.get("pattern", "")
43
+ if not pattern:
44
+ return self._error("No pattern provided")
45
+
46
+ root = Path(kwargs.get("path") or ".").resolve()
47
+ limit = int(kwargs.get("limit", 200))
48
+
49
+ try:
50
+ matches = [p for p in root.glob(pattern) if p.is_file()]
51
+ except Exception as e:
52
+ return self._error(str(e))
53
+
54
+ if not matches:
55
+ return self._success(f"No matches for {pattern} in {root}")
56
+
57
+ matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
58
+ matches = matches[:limit]
59
+ return self._success("\n".join(str(p) for p in matches))