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.
- api/__init__.py +5 -0
- api/anthropic_compat.py +1518 -0
- api/artifact_viewer.py +366 -0
- api/caudate_middleware.py +618 -0
- api/forge_bootstrapper_routes.py +377 -0
- api/forge_routes.py +630 -0
- api/forge_system_routes.py +294 -0
- api/openai_compat.py +1993 -0
- api/server.py +667 -0
- api/storyboard_page.py +677 -0
- caudate_cli-0.1.0.dist-info/METADATA +354 -0
- caudate_cli-0.1.0.dist-info/RECORD +153 -0
- caudate_cli-0.1.0.dist-info/WHEEL +5 -0
- caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
- caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
- cognos_mcp/__init__.py +4 -0
- cognos_mcp/bridge.py +41 -0
- cognos_mcp/client.py +70 -0
- cognos_mcp/config.py +49 -0
- cognos_mcp/server.py +66 -0
- config.py +82 -0
- core/__init__.py +0 -0
- core/agent.py +468 -0
- core/agentic_loop.py +731 -0
- core/anthropic_auth.py +91 -0
- core/background.py +113 -0
- core/banner.py +134 -0
- core/bootstrap.py +292 -0
- core/citations.py +131 -0
- core/compaction.py +109 -0
- core/constitution.py +198 -0
- core/diff_viewer.py +87 -0
- core/export.py +85 -0
- core/file_refs.py +119 -0
- core/files.py +199 -0
- core/hooks.py +209 -0
- core/image.py +599 -0
- core/input.py +91 -0
- core/loop.py +238 -0
- core/memory_md.py +147 -0
- core/notifications.py +99 -0
- core/ownership.py +181 -0
- core/paste.py +81 -0
- core/permissions.py +210 -0
- core/plan_mode.py +215 -0
- core/sandbox_prompt.py +185 -0
- core/scheduler.py +195 -0
- core/schemas.py +202 -0
- core/session.py +90 -0
- core/settings.py +132 -0
- core/skills.py +398 -0
- core/slash_commands.py +977 -0
- core/statusline.py +61 -0
- core/subagent.py +300 -0
- core/thinking.py +50 -0
- core/updater.py +122 -0
- core/usage.py +109 -0
- core/worktree.py +93 -0
- execution/__init__.py +0 -0
- execution/executor.py +329 -0
- execution/plugins.py +108 -0
- execution/tools/__init__.py +0 -0
- execution/tools/agent_tool.py +107 -0
- execution/tools/agentic_tool.py +297 -0
- execution/tools/artifact_tool.py +191 -0
- execution/tools/ask_user_question_tool.py +137 -0
- execution/tools/base.py +81 -0
- execution/tools/calculator_tool.py +137 -0
- execution/tools/cognos_card_tool.py +124 -0
- execution/tools/cron_tool.py +215 -0
- execution/tools/datetime_tool.py +215 -0
- execution/tools/describe_image_tool.py +161 -0
- execution/tools/draw_tool.py +164 -0
- execution/tools/edit_image_tool.py +262 -0
- execution/tools/edit_tool.py +245 -0
- execution/tools/file_tool.py +90 -0
- execution/tools/find_anywhere_tool.py +255 -0
- execution/tools/forge_feature_tools.py +377 -0
- execution/tools/glob_tool.py +59 -0
- execution/tools/grep_tool.py +89 -0
- execution/tools/http_request_tool.py +224 -0
- execution/tools/load_skill_tool.py +104 -0
- execution/tools/longcat_avatar_tool.py +384 -0
- execution/tools/mcp_tool.py +100 -0
- execution/tools/notebook_tool.py +279 -0
- execution/tools/openapi_tool.py +440 -0
- execution/tools/plan_mode_tool.py +95 -0
- execution/tools/push_notification_tool.py +157 -0
- execution/tools/python_tool.py +61 -0
- execution/tools/respond_tool.py +40 -0
- execution/tools/sandbox_tool.py +378 -0
- execution/tools/search_tool.py +153 -0
- execution/tools/semantic_search_tool.py +106 -0
- execution/tools/shell_tool.py +283 -0
- execution/tools/speak_tool.py +134 -0
- execution/tools/storyboard_tool.py +727 -0
- execution/tools/system_info_tool.py +212 -0
- execution/tools/task_tool.py +323 -0
- execution/tools/think_tool.py +49 -0
- execution/tools/transcribe_audio_tool.py +86 -0
- execution/tools/update_memory_tool.py +92 -0
- execution/tools/web_fetch_tool.py +82 -0
- execution/tools/worktree_tool.py +174 -0
- llm/__init__.py +0 -0
- llm/fallback.py +116 -0
- llm/models.py +320 -0
- llm/provider.py +1356 -0
- llm/router.py +373 -0
- main.py +1889 -0
- memory/__init__.py +0 -0
- memory/episodic.py +99 -0
- memory/procedural.py +145 -0
- memory/semantic.py +71 -0
- memory/working.py +64 -0
- nn/__init__.py +43 -0
- nn/auto_evolve.py +245 -0
- nn/caudate.py +136 -0
- nn/config.py +141 -0
- nn/consolidator.py +81 -0
- nn/data.py +1635 -0
- nn/encoder.py +258 -0
- nn/forge_advisor.py +303 -0
- nn/format.py +235 -0
- nn/heads.py +432 -0
- nn/observer.py +994 -0
- nn/policy.py +214 -0
- nn/runtime.py +343 -0
- nn/scorer.py +175 -0
- nn/trainer.py +515 -0
- nn/vision.py +352 -0
- personality/__init__.py +23 -0
- personality/engine.py +129 -0
- personality/identity.py +144 -0
- personality/inner_voice.py +100 -0
- personality/mood.py +205 -0
- planning/__init__.py +0 -0
- planning/dev_server.py +221 -0
- planning/forge_models.py +718 -0
- planning/orchestrator.py +1363 -0
- planning/planner.py +451 -0
- planning/task_graph.py +61 -0
- reflection/__init__.py +0 -0
- reflection/meta_learner.py +156 -0
- reflection/reflector.py +127 -0
- ui/__init__.py +5 -0
- ui/display.py +88 -0
- voice/__init__.py +0 -0
- voice/conversation.py +125 -0
- voice/listener.py +111 -0
- voice/speaker.py +59 -0
- voice/stt.py +126 -0
- 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))
|