aru-code 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.
aru/tools/gitignore.py ADDED
@@ -0,0 +1,109 @@
1
+ """Gitignore-aware file filtering for codebase operations."""
2
+
3
+ import os
4
+ from typing import Iterator
5
+
6
+ import pathspec
7
+
8
+
9
+ def normalize_path(path: str) -> str:
10
+ """Convert backslashes to forward slashes and remove trailing slashes."""
11
+ return path.replace("\\", "/").rstrip("/")
12
+
13
+ # Hardcoded fallback patterns (always excluded even without .gitignore)
14
+ _FALLBACK_PATTERNS = [
15
+ ".git",
16
+ "node_modules",
17
+ "__pycache__",
18
+ "venv",
19
+ ".venv",
20
+ ".aru",
21
+ "*.pyc",
22
+ "*.pyo",
23
+ ]
24
+
25
+ # Cache: {(root_dir, gitignore_mtime): PathSpec}
26
+ _cache: dict[tuple[str, float], pathspec.PathSpec] = {}
27
+
28
+
29
+ def _find_git_root(start: str) -> str | None:
30
+ """Walk up from start directory to find the git root (directory containing .git)."""
31
+ current = os.path.abspath(start)
32
+ while True:
33
+ if os.path.isdir(os.path.join(current, ".git")):
34
+ return current
35
+ parent = os.path.dirname(current)
36
+ if parent == current:
37
+ return None
38
+ current = parent
39
+
40
+
41
+ def load_gitignore(root_dir: str) -> pathspec.PathSpec:
42
+ """Parse .gitignore from root_dir combined with hardcoded fallback patterns.
43
+
44
+ Results are cached by root_dir and .gitignore mtime.
45
+ """
46
+ root_dir = os.path.abspath(root_dir)
47
+ gitignore_path = os.path.join(root_dir, ".gitignore")
48
+
49
+ mtime = 0.0
50
+ if os.path.isfile(gitignore_path):
51
+ mtime = os.path.getmtime(gitignore_path)
52
+
53
+ cache_key = (root_dir, mtime)
54
+ if cache_key in _cache:
55
+ return _cache[cache_key]
56
+
57
+ # Clear old entries for this root_dir
58
+ _cache.pop(next((k for k in _cache if k[0] == root_dir), (None, None)), None)
59
+
60
+ patterns = list(_FALLBACK_PATTERNS)
61
+ if os.path.isfile(gitignore_path):
62
+ with open(gitignore_path, "r", encoding="utf-8", errors="ignore") as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ if line and not line.startswith("#"):
66
+ patterns.append(line)
67
+
68
+ spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
69
+ _cache[cache_key] = spec
70
+ return spec
71
+
72
+
73
+ def is_ignored(path: str, root_dir: str) -> bool:
74
+ """Check if a relative path should be ignored based on .gitignore rules.
75
+
76
+ Args:
77
+ path: Relative path to check (forward slashes preferred).
78
+ root_dir: Project root directory containing .gitignore.
79
+ """
80
+ spec = load_gitignore(root_dir)
81
+ # Normalize to forward slashes for pathspec
82
+ normalized = path.replace("\\", "/")
83
+ return spec.match_file(normalized)
84
+
85
+
86
+ def walk_filtered(directory: str) -> Iterator[tuple[str, list[str], list[str]]]:
87
+ """Walk directory tree, filtering out gitignored files and directories.
88
+
89
+ Drop-in replacement for os.walk() that respects .gitignore rules.
90
+ Finds the git root (or uses the directory itself) to load ignore patterns.
91
+ """
92
+ directory = os.path.abspath(directory)
93
+ root_dir = _find_git_root(directory) or directory
94
+ spec = load_gitignore(root_dir)
95
+
96
+ for dirpath, dirs, files in os.walk(directory):
97
+ # Filter directories in-place to prevent descending into ignored dirs
98
+ dirs[:] = [
99
+ d for d in dirs
100
+ if not spec.match_file(os.path.relpath(os.path.join(dirpath, d), root_dir).replace("\\", "/") + "/")
101
+ ]
102
+
103
+ # Filter files
104
+ filtered_files = [
105
+ f for f in files
106
+ if not spec.match_file(os.path.relpath(os.path.join(dirpath, f), root_dir).replace("\\", "/"))
107
+ ]
108
+
109
+ yield dirpath, dirs, filtered_files
@@ -0,0 +1,156 @@
1
+ """Model Context Protocol (MCP) client manager and tool generation."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from contextlib import AsyncExitStack
7
+
8
+ from agno.tools import Function
9
+ from mcp.client.stdio import stdio_client, StdioServerParameters
10
+ from mcp.client.session import ClientSession
11
+
12
+
13
+ class McpSessionManager:
14
+ """Manages MCP server subprocesses and active client sessions."""
15
+
16
+ def __init__(self, config_path: str = "arc.mcp.json"):
17
+ self.config_path = config_path
18
+ self._exit_stack = AsyncExitStack()
19
+ self.sessions: dict[str, ClientSession] = {}
20
+
21
+ async def initialize(self):
22
+ """Read config and spawn all MCP servers concurrently."""
23
+ if not os.path.exists(self.config_path):
24
+ return
25
+
26
+ with open(self.config_path, "r", encoding="utf-8") as f:
27
+ try:
28
+ config = json.load(f)
29
+ except json.JSONDecodeError:
30
+ print(f"[Warning] Failed to parse {self.config_path}")
31
+ return
32
+
33
+ servers = config.get("mcpServers", {})
34
+ tasks = []
35
+ for name, svr_config in servers.items():
36
+ cmd = svr_config.get("command")
37
+ if not cmd:
38
+ continue
39
+ tasks.append(self._start_server(name, svr_config))
40
+
41
+ if tasks:
42
+ await asyncio.gather(*tasks)
43
+
44
+ async def _start_server(self, name: str, svr_config: dict):
45
+ """Start a single MCP server and register its session."""
46
+ cmd = svr_config.get("command")
47
+ args = svr_config.get("args", [])
48
+ env = svr_config.get("env", None)
49
+
50
+ server_params = StdioServerParameters(
51
+ command=cmd,
52
+ args=args,
53
+ env={**os.environ.copy(), **env} if env else None
54
+ )
55
+
56
+ try:
57
+ read_stream, write_stream = await self._exit_stack.enter_async_context(
58
+ stdio_client(server_params)
59
+ )
60
+ session = await self._exit_stack.enter_async_context(
61
+ ClientSession(read_stream, write_stream)
62
+ )
63
+
64
+ await session.initialize()
65
+ self.sessions[name] = session
66
+ except Exception as e:
67
+ print(f"[Warning] Failed to start MCP server '{name}': {e}")
68
+
69
+ async def get_tools(self) -> list[Function]:
70
+ """Fetch all tools from connected servers concurrently and convert to Agno Functions."""
71
+
72
+ async def _fetch(server_name: str, session: ClientSession) -> list[Function]:
73
+ try:
74
+ result = await session.list_tools()
75
+ return [self._create_agno_function(server_name, session, tool) for tool in result.tools]
76
+ except Exception as e:
77
+ print(f"[Warning] Failed to fetch tools from MCP server '{server_name}': {e}")
78
+ return []
79
+
80
+ results = await asyncio.gather(
81
+ *[_fetch(name, sess) for name, sess in self.sessions.items()]
82
+ )
83
+ return [tool for tools in results for tool in tools]
84
+
85
+ def _create_agno_function(self, server_name: str, session: ClientSession, tool) -> Function:
86
+ """Dynamically create an Agno Function that routes to the remote MCP tool."""
87
+
88
+ # We need to capture 'session' and 'tool.name' cleanly.
89
+ # Python's default arguments trick captures loop variables.
90
+ async def mcp_caller(**kwargs) -> str:
91
+ try:
92
+ result = await session.call_tool(tool.name, arguments=kwargs)
93
+ # Parse MCP ToolResultContent
94
+ output = []
95
+ for content in result.content:
96
+ if hasattr(content, "text"):
97
+ output.append(content.text)
98
+ if result.isError:
99
+ return f"Error from {tool.name}: " + "\n".join(output)
100
+ return "\n".join(output)
101
+ except Exception as e:
102
+ return f"Error executing {tool.name} on {server_name}: {e}"
103
+
104
+ # Assign __name__ to the callable for Agno's internal representation
105
+ safe_name = f"{server_name}__{tool.name}".replace("-", "_")
106
+ mcp_caller.__name__ = safe_name
107
+
108
+ return Function(
109
+ name=safe_name,
110
+ description=f"[{server_name}] {tool.description or ''}",
111
+ parameters=tool.inputSchema,
112
+ entrypoint=mcp_caller
113
+ )
114
+
115
+ async def cleanup(self):
116
+ """Close all active MCP client sessions and terminate server subprocesses."""
117
+ try:
118
+ await self._exit_stack.aclose()
119
+ except (RuntimeError, Exception):
120
+ pass
121
+
122
+
123
+ # Global Singleton manager to be used entirely inside aru's async loops
124
+ _manager: McpSessionManager | None = None
125
+
126
+ async def init_mcp() -> list[Function]:
127
+ """Initialize MCP servers and return the loaded Agno functions."""
128
+ global _manager
129
+ if _manager is None:
130
+ config_path = None
131
+ for path in [
132
+ ".aru/mcp_servers.json",
133
+ "aru.mcp.json",
134
+ ".mcp.json",
135
+ "mcp.json"
136
+ ]:
137
+ if os.path.exists(path):
138
+ config_path = path
139
+ break
140
+
141
+ if config_path:
142
+ _manager = McpSessionManager(config_path=config_path)
143
+ await _manager.initialize()
144
+ else:
145
+ # Create an empty manager so cleanup doesn't fail, but return no tools
146
+ _manager = McpSessionManager(config_path="")
147
+ return []
148
+
149
+ return await _manager.get_tools()
150
+
151
+ async def cleanup_mcp():
152
+ """Cleanup global manager."""
153
+ global _manager
154
+ if _manager:
155
+ await _manager.cleanup()
156
+ _manager = None
aru/tools/ranker.py ADDED
@@ -0,0 +1,220 @@
1
+ """Multi-factor file relevance ranking for task-driven context selection."""
2
+
3
+ import fnmatch
4
+ import os
5
+ import re
6
+
7
+ from aru.tools.gitignore import walk_filtered
8
+
9
+ # Weights for each ranking signal (sum to 1.0)
10
+ WEIGHT_NAME = 0.50
11
+ WEIGHT_STRUCTURAL = 0.30
12
+ WEIGHT_RECENCY = 0.20
13
+
14
+
15
+ def _get_project_files(root_dir: str) -> list[str]:
16
+ """Get all project files using gitignore-aware walk."""
17
+ files = []
18
+ for dirpath, _, filenames in walk_filtered(root_dir):
19
+ for filename in filenames:
20
+ filepath = os.path.join(dirpath, filename)
21
+ rel_path = os.path.relpath(filepath, root_dir).replace("\\", "/")
22
+ files.append(rel_path)
23
+ return files
24
+
25
+
26
+ def _score_name_match(file_path: str, keywords: list[str]) -> float:
27
+ """Score based on how many task keywords appear in the file path/name."""
28
+ if not keywords:
29
+ return 0.0
30
+
31
+ path_lower = file_path.lower()
32
+ # Split path into components for matching
33
+ path_parts = re.split(r"[/\\_.\-]", path_lower)
34
+
35
+ matches = 0
36
+ for keyword in keywords:
37
+ kw = keyword.lower()
38
+ if len(kw) < 3: # Skip very short words
39
+ continue
40
+ # Exact match in path component
41
+ if kw in path_parts:
42
+ matches += 2
43
+ # Partial match in full path
44
+ elif kw in path_lower:
45
+ matches += 1
46
+ # Check if any path component is a substring of the keyword (e.g., "auth" in "authentication")
47
+ else:
48
+ for part in path_parts:
49
+ if len(part) >= 3 and part in kw:
50
+ matches += 1.5 # Higher than partial match, lower than exact
51
+ break
52
+
53
+ return min(matches / max(len(keywords), 1), 1.0)
54
+
55
+
56
+ def _extract_keywords(task: str) -> list[str]:
57
+ """Extract meaningful keywords from a task description."""
58
+ # Common stop words to filter out
59
+ stop_words = {
60
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
61
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
62
+ "should", "may", "might", "can", "shall", "to", "of", "in", "for",
63
+ "on", "with", "at", "by", "from", "as", "into", "through", "during",
64
+ "before", "after", "above", "below", "between", "out", "off", "over",
65
+ "under", "again", "further", "then", "once", "here", "there", "when",
66
+ "where", "why", "how", "all", "each", "every", "both", "few", "more",
67
+ "most", "other", "some", "such", "no", "nor", "not", "only", "own",
68
+ "same", "so", "than", "too", "very", "just", "but", "and", "or",
69
+ "if", "it", "its", "this", "that", "these", "those", "i", "me", "my",
70
+ "we", "our", "you", "your", "he", "she", "they", "them", "what",
71
+ "which", "who", "whom", "add", "create", "make", "build", "implement",
72
+ "fix", "update", "change", "modify", "remove", "delete", "get", "set",
73
+ "use", "new", "file", "files", "code", "function", "method",
74
+ }
75
+
76
+ # Tokenize and filter
77
+ words = re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", task)
78
+ keywords = [w for w in words if w.lower() not in stop_words and len(w) >= 3]
79
+ return keywords
80
+
81
+
82
+ def _score_recency(file_path: str, root_dir: str, max_age_days: float = 30.0) -> float:
83
+ """Score based on how recently the file was modified (0-1, 1 = most recent)."""
84
+ try:
85
+ mtime = os.path.getmtime(os.path.join(root_dir, file_path))
86
+ import time
87
+ age_seconds = time.time() - mtime
88
+ age_days = age_seconds / 86400
89
+ if age_days <= 0:
90
+ return 1.0
91
+ if age_days >= max_age_days:
92
+ return 0.0
93
+ return 1.0 - (age_days / max_age_days)
94
+ except OSError:
95
+ return 0.0
96
+
97
+
98
+ def _get_structural_scores(top_files: list[str], root_dir: str) -> dict[str, float]:
99
+ """Boost files that are dependencies of already-relevant files."""
100
+ try:
101
+ from aru.tools.ast_tools import find_dependencies, _resolve_import_to_file, _find_project_root
102
+ except ImportError:
103
+ return {}
104
+
105
+ dep_counts: dict[str, int] = {}
106
+
107
+ for file_path in top_files[:5]: # Only trace top 5 to avoid slowness
108
+ full_path = os.path.join(root_dir, file_path)
109
+ if not os.path.isfile(full_path):
110
+ continue
111
+
112
+ try:
113
+ with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
114
+ content = f.read()
115
+ except OSError:
116
+ continue
117
+
118
+ # Extract imports and resolve to local files
119
+ for line in content.split("\n"):
120
+ stripped = line.strip()
121
+ if stripped.startswith("import ") or stripped.startswith("from "):
122
+ resolved = _resolve_import_to_file(stripped, root_dir)
123
+ if resolved:
124
+ normalized = resolved.replace("\\", "/")
125
+ dep_counts[normalized] = dep_counts.get(normalized, 0) + 1
126
+
127
+ if not dep_counts:
128
+ return {}
129
+
130
+ max_count = max(dep_counts.values())
131
+ return {k: v / max_count for k, v in dep_counts.items()}
132
+
133
+
134
+ def rank_files(task: str, top_k: int = 15) -> str:
135
+ """Rank project files by relevance to a given task description.
136
+
137
+ Uses multiple signals to determine which files are most relevant:
138
+ - Filename/path keyword matching
139
+ - Structural dependencies (files imported by relevant files)
140
+ - Modification recency
141
+
142
+ Use this as a first step when starting a new task to identify which files to read.
143
+
144
+ Args:
145
+ task: Natural language description of the task (e.g. "add authentication to the CLI").
146
+ top_k: Maximum number of files to return. Defaults to 15.
147
+ """
148
+ root_dir = os.getcwd()
149
+ all_files = _get_project_files(root_dir)
150
+
151
+ if not all_files:
152
+ return "No files found in the project."
153
+
154
+ keywords = _extract_keywords(task)
155
+
156
+ # Signal 1: Name match scores
157
+ name_scores = {f: _score_name_match(f, keywords) for f in all_files}
158
+
159
+ # Signal 2: Recency scores
160
+ recency_scores = {f: _score_recency(f, root_dir) for f in all_files}
161
+
162
+ # Preliminary ranking (without structural) to find top files for dependency tracing
163
+ preliminary_scores = {}
164
+ for f in all_files:
165
+ score = (
166
+ WEIGHT_NAME * name_scores.get(f, 0.0)
167
+ + WEIGHT_RECENCY * recency_scores.get(f, 0.0)
168
+ )
169
+ preliminary_scores[f] = score
170
+
171
+ # Signal 3: Structural scores (based on top preliminary results)
172
+ top_preliminary = sorted(preliminary_scores, key=preliminary_scores.get, reverse=True)[:10]
173
+ structural_scores = _get_structural_scores(top_preliminary, root_dir)
174
+
175
+ # Final combined scores
176
+ final_scores: dict[str, tuple[float, list[str]]] = {}
177
+ for f in all_files:
178
+ reasons = []
179
+ name = name_scores.get(f, 0.0)
180
+ structural = structural_scores.get(f, 0.0)
181
+ recency = recency_scores.get(f, 0.0)
182
+
183
+ score = (
184
+ WEIGHT_NAME * name
185
+ + WEIGHT_STRUCTURAL * structural
186
+ + WEIGHT_RECENCY * recency
187
+ )
188
+
189
+ # Build reason strings
190
+ if name > 0.3:
191
+ reasons.append("name match")
192
+ if structural > 0:
193
+ reasons.append("dependency of top files")
194
+ if recency > 0.7:
195
+ reasons.append("recently modified")
196
+
197
+ if score > 0:
198
+ final_scores[f] = (score, reasons)
199
+
200
+ # Sort and take top_k
201
+ ranked = sorted(final_scores.items(), key=lambda x: x[1][0], reverse=True)[:top_k]
202
+
203
+ if not ranked:
204
+ return f"No files found with relevance to: {task}"
205
+
206
+ # Normalize scores to 0-1 based on top score
207
+ max_score = ranked[0][1][0] if ranked else 1.0
208
+ if max_score == 0:
209
+ max_score = 1.0
210
+
211
+ # Format output
212
+ lines = [f"Files ranked by relevance to: \"{task}\"\n"]
213
+ lines.append("Ranking mode: name + structural + recency\n")
214
+
215
+ for i, (file_path, (score, reasons)) in enumerate(ranked, 1):
216
+ normalized_score = score / max_score
217
+ reason_str = " + ".join(reasons) if reasons else "low signal"
218
+ lines.append(f" {i:2d}. {file_path} ({normalized_score:.2f}) — {reason_str}")
219
+
220
+ return "\n".join(lines)
aru/tools/tasklist.py ADDED
@@ -0,0 +1,183 @@
1
+ """Task list tools for structured step execution.
2
+
3
+ Provides create_task_list and update_task tools that the executor must call
4
+ to plan and track subtasks within each plan step. Inspired by Claude Code
5
+ and Antigravity's task management approach.
6
+ """
7
+
8
+ import threading
9
+
10
+ from rich.console import Console, Group
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ _console = Console()
15
+ _live = None
16
+ _display = None
17
+
18
+ MAX_SUBTASKS = 10
19
+
20
+
21
+ def set_live(live):
22
+ global _live
23
+ _live = live
24
+
25
+
26
+ def set_display(display):
27
+ global _display
28
+ _display = display
29
+
30
+
31
+ class _TaskStore:
32
+ """Thread-safe store for the current step's subtask list."""
33
+
34
+ def __init__(self):
35
+ self._lock = threading.Lock()
36
+ self._tasks: list[dict] = [] # {"index": int, "description": str, "status": str}
37
+ self._created = False
38
+
39
+ def create(self, tasks: list[str]) -> list[dict]:
40
+ with self._lock:
41
+ self._tasks = [
42
+ {"index": i + 1, "description": desc, "status": "pending"}
43
+ for i, desc in enumerate(tasks)
44
+ ]
45
+ self._created = True
46
+ return list(self._tasks)
47
+
48
+ def update(self, index: int, status: str) -> dict | None:
49
+ with self._lock:
50
+ for task in self._tasks:
51
+ if task["index"] == index:
52
+ task["status"] = status
53
+ return dict(task)
54
+ return None
55
+
56
+ def get_all(self) -> list[dict]:
57
+ with self._lock:
58
+ return list(self._tasks)
59
+
60
+ @property
61
+ def is_created(self) -> bool:
62
+ with self._lock:
63
+ return self._created
64
+
65
+ def reset(self):
66
+ with self._lock:
67
+ self._tasks = []
68
+ self._created = False
69
+
70
+
71
+ # Global singleton per executor step (reset between steps)
72
+ _store = _TaskStore()
73
+
74
+
75
+ def reset_task_store():
76
+ """Reset the task store between executor steps."""
77
+ _store.reset()
78
+
79
+
80
+ def get_task_store() -> _TaskStore:
81
+ """Get the current task store for inspection."""
82
+ return _store
83
+
84
+
85
+ def _render_task_list(tasks: list[dict]) -> Panel:
86
+ """Render the task list as a Rich panel."""
87
+ lines = []
88
+ for t in tasks:
89
+ if t["status"] == "completed":
90
+ icon = "[bold green]✓[/bold green]"
91
+ style = "dim"
92
+ elif t["status"] == "in_progress":
93
+ icon = "[bold yellow]~[/bold yellow]"
94
+ style = "bold"
95
+ elif t["status"] == "failed":
96
+ icon = "[bold red]✗[/bold red]"
97
+ style = "red"
98
+ else:
99
+ icon = "[dim]○[/dim]"
100
+ style = "dim"
101
+ lines.append(Text.from_markup(f" {icon} {t['index']}. {t['description']}", style=style))
102
+
103
+ return Panel(
104
+ Group(*lines),
105
+ title="[bold cyan]Subtasks[/bold cyan]",
106
+ border_style="cyan",
107
+ expand=True,
108
+ )
109
+
110
+
111
+ def _show(panel: Panel):
112
+ """Display panel using the active display or console."""
113
+ if _display and hasattr(_display, "show_permission"):
114
+ _display.show_permission(panel)
115
+ elif _live:
116
+ _live.console.print(panel)
117
+ else:
118
+ _console.print(panel)
119
+
120
+
121
+ def create_task_list(tasks: list[str]) -> str:
122
+ """Create a subtask list for the current step. MUST be called before any other tool.
123
+
124
+ Define 1-10 concrete subtasks that you will execute in order.
125
+ Each subtask should be a single action (Read, Write, Edit, Run).
126
+
127
+ Args:
128
+ tasks: List of subtask descriptions. Min 1, max 10.
129
+ Example: ["Read backend/models.py", "Write backend/auth.py", "Edit backend/main.py — add import", "Run pytest"]
130
+ """
131
+ if _store.is_created:
132
+ return "Error: Task list already created for this step. Use update_task to update subtask status."
133
+
134
+ if len(tasks) < 1:
135
+ return "Error: Minimum 1 subtask required."
136
+
137
+ if len(tasks) > MAX_SUBTASKS:
138
+ return f"Error: Maximum {MAX_SUBTASKS} subtasks allowed. Got {len(tasks)}. Simplify your plan."
139
+
140
+ created = _store.create(tasks)
141
+ panel = _render_task_list(created)
142
+ _show(panel)
143
+
144
+ task_lines = "\n".join(f" {t['index']}. {t['description']}" for t in created)
145
+ return f"Task list created ({len(created)} subtasks):\n{task_lines}\n\nNow execute subtask 1."
146
+
147
+
148
+ def update_task(index: int, status: str) -> str:
149
+ """Update the status of a subtask. Call this as you complete each subtask.
150
+
151
+ Args:
152
+ index: Subtask number (1-based).
153
+ status: New status — one of: "in_progress", "completed", "failed".
154
+ """
155
+ if not _store.is_created:
156
+ return "Error: No task list exists. Call create_task_list first."
157
+
158
+ if status not in ("in_progress", "completed", "failed"):
159
+ return f"Error: Invalid status '{status}'. Use: in_progress, completed, failed."
160
+
161
+ updated = _store.update(index, status)
162
+ if not updated:
163
+ return f"Error: Subtask {index} not found."
164
+
165
+ # Show updated task list
166
+ all_tasks = _store.get_all()
167
+ panel = _render_task_list(all_tasks)
168
+ _show(panel)
169
+
170
+ # Check if all done
171
+ completed_count = sum(1 for t in all_tasks if t["status"] == "completed")
172
+ failed_count = sum(1 for t in all_tasks if t["status"] == "failed")
173
+ total = len(all_tasks)
174
+
175
+ if completed_count + failed_count == total:
176
+ return f"All subtasks finished ({completed_count} completed, {failed_count} failed). Step done. Output a brief summary of what was created/changed."
177
+
178
+ # Find next pending subtask
179
+ next_task = next((t for t in all_tasks if t["status"] == "pending"), None)
180
+ if next_task:
181
+ return f"Subtask {index} → {status}. Next: subtask {next_task['index']} — {next_task['description']}"
182
+
183
+ return f"Subtask {index} → {status}."