cowork-flow 0.0.4 → 0.0.7
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.
- package/README.md +44 -1
- package/package.json +8 -5
- package/src/lib/copy-template.js +9 -9
- package/src/lib/package-info.js +8 -2
- package/template/.agent/skills/agent-team-execution/SKILL.md +26 -0
- package/template/{.superpowers → .agent/skills}/brainstorming/scripts/start-server.sh +0 -0
- package/template/{.superpowers → .agent/skills}/brainstorming/scripts/stop-server.sh +0 -0
- package/template/.agent/skills/start/SKILL.md +1 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/agent-team/adapters.yaml +15 -0
- package/template/.cowork-flow/agent-team/agents.yaml +142 -0
- package/template/.cowork-flow/agent-team/policy.yaml +23 -0
- package/template/.cowork-flow/config.yaml +9 -0
- package/template/.cowork-flow/run +10 -93
- package/template/.cowork-flow/run.cmd +12 -115
- package/template/.cowork-flow/scripts/agent_team.py +584 -0
- package/template/.cowork-flow/scripts/change.py +68 -15
- package/template/.cowork-flow/scripts/common/agent_team.py +467 -0
- package/template/.cowork-flow/scripts/common/archive_utils.py +72 -0
- package/template/.cowork-flow/scripts/common/config.py +18 -0
- package/template/.cowork-flow/scripts/common/git_context.py +17 -0
- package/template/.cowork-flow/scripts/common/task_utils.py +5 -18
- package/template/.cowork-flow/scripts/run.py +92 -0
- package/template/.cowork-flow/scripts/task.py +1 -1
- package/template/.cowork-flow/workflow.md +65 -158
- package/template/.superpowers/executing-plans/SKILL.md +2 -0
- package/template/AGENTS.md +3 -2
- /package/template/{.superpowers → .agent/skills}/brainstorming/SKILL.md +0 -0
- /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/frame-template.html +0 -0
- /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/helper.js +0 -0
- /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/server.cjs +0 -0
- /package/template/{.superpowers → .agent/skills}/brainstorming/spec-document-reviewer-prompt.md +0 -0
- /package/template/{.superpowers → .agent/skills}/brainstorming/visual-companion.md +0 -0
- /package/template/{.superpowers → .agent/skills}/requesting-code-review/SKILL.md +0 -0
- /package/template/{.superpowers → .agent/skills}/requesting-code-review/code-reviewer.md +0 -0
- /package/template/{.superpowers → .agent/skills}/subagent-driven-development/SKILL.md +0 -0
- /package/template/{.superpowers → .agent/skills}/subagent-driven-development/code-quality-reviewer-prompt.md +0 -0
- /package/template/{.superpowers → .agent/skills}/subagent-driven-development/implementer-prompt.md +0 -0
- /package/template/{.superpowers → .agent/skills}/subagent-driven-development/spec-reviewer-prompt.md +0 -0
- /package/template/{.superpowers → .agent/skills}/writing-plans/SKILL.md +0 -0
- /package/template/{.superpowers → .agent/skills}/writing-plans/plan-document-reviewer-prompt.md +0 -0
|
@@ -6,12 +6,18 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
8
|
import re
|
|
9
|
-
import shutil
|
|
10
9
|
import sys
|
|
11
10
|
from datetime import datetime
|
|
12
11
|
from pathlib import Path
|
|
13
12
|
|
|
14
|
-
from common.
|
|
13
|
+
from common.archive_utils import archive_directory_resumable
|
|
14
|
+
from common.paths import (
|
|
15
|
+
DIR_ARCHIVE,
|
|
16
|
+
DIR_CHANGES,
|
|
17
|
+
DIR_WORKFLOW,
|
|
18
|
+
generate_task_date_prefix,
|
|
19
|
+
get_repo_root,
|
|
20
|
+
)
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -107,9 +113,52 @@ def _resolve_link(repo_root: Path, base_dir: str, value: object) -> Path | None:
|
|
|
107
113
|
return raw_path
|
|
108
114
|
|
|
109
115
|
direct = repo_root / raw_path
|
|
110
|
-
|
|
116
|
+
workflow_base = Path(DIR_WORKFLOW) / base_dir
|
|
117
|
+
if direct.exists() or raw_path.parts[:2] == workflow_base.parts:
|
|
111
118
|
return direct
|
|
112
|
-
return repo_root /
|
|
119
|
+
return repo_root / workflow_base / raw_path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _archived_task_link(repo_root: Path, task_name: str) -> str | None:
|
|
123
|
+
archive_root = repo_root / DIR_WORKFLOW / "tasks" / DIR_ARCHIVE
|
|
124
|
+
if not archive_root.is_dir():
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
matches = [path for path in archive_root.glob(f"*/{task_name}") if path.is_dir()]
|
|
128
|
+
if len(matches) != 1:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
relative = matches[0].relative_to(archive_root)
|
|
132
|
+
return str(Path(DIR_ARCHIVE) / relative).replace("\\", "/")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize_task_link(repo_root: Path, value: object) -> object:
|
|
136
|
+
if not isinstance(value, str) or not value.strip():
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
target = _resolve_link(repo_root, "tasks", value)
|
|
140
|
+
tasks_root = repo_root / DIR_WORKFLOW / "tasks"
|
|
141
|
+
archive_root = tasks_root / DIR_ARCHIVE
|
|
142
|
+
if target is None:
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
if not target.exists():
|
|
146
|
+
return _archived_task_link(repo_root, target.name) or value
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
relative = target.resolve().relative_to(archive_root.resolve())
|
|
150
|
+
except ValueError:
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
return str(Path(DIR_ARCHIVE) / relative).replace("\\", "/")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_archive_metadata(repo_root: Path, change_dir: Path) -> None:
|
|
157
|
+
metadata = _read_metadata(change_dir)
|
|
158
|
+
normalized_task = _normalize_task_link(repo_root, metadata.get("task"))
|
|
159
|
+
if normalized_task != metadata.get("task"):
|
|
160
|
+
metadata["task"] = normalized_task
|
|
161
|
+
_write_metadata(change_dir, metadata)
|
|
113
162
|
|
|
114
163
|
|
|
115
164
|
def _validate_link(repo_root: Path, metadata: dict[str, object], field: str, base_dir: str) -> str | None:
|
|
@@ -178,14 +227,15 @@ def create_change(args: argparse.Namespace) -> int:
|
|
|
178
227
|
if not _validate_slug(slug):
|
|
179
228
|
return 2
|
|
180
229
|
|
|
181
|
-
|
|
230
|
+
dir_name = f"{generate_task_date_prefix()}-{slug}"
|
|
231
|
+
change_dir = _change_dir(repo_root, dir_name)
|
|
182
232
|
if change_dir.exists():
|
|
183
|
-
print(f"Error: change already exists: {
|
|
233
|
+
print(f"Error: change already exists: {dir_name}", file=sys.stderr)
|
|
184
234
|
return 1
|
|
185
235
|
|
|
186
236
|
change_dir.mkdir(parents=True)
|
|
187
237
|
(change_dir / "proposal.md").write_text(
|
|
188
|
-
f"# {
|
|
238
|
+
f"# {dir_name}\n\nDescribe the proposed behavior change.\n",
|
|
189
239
|
encoding="utf-8",
|
|
190
240
|
)
|
|
191
241
|
(change_dir / "design.md").write_text("", encoding="utf-8")
|
|
@@ -193,7 +243,7 @@ def create_change(args: argparse.Namespace) -> int:
|
|
|
193
243
|
_write_metadata(
|
|
194
244
|
change_dir,
|
|
195
245
|
{
|
|
196
|
-
"slug":
|
|
246
|
+
"slug": dir_name,
|
|
197
247
|
"status": "draft",
|
|
198
248
|
"level": args.level,
|
|
199
249
|
"created_at": _now_iso(),
|
|
@@ -203,7 +253,7 @@ def create_change(args: argparse.Namespace) -> int:
|
|
|
203
253
|
},
|
|
204
254
|
)
|
|
205
255
|
|
|
206
|
-
print(f"created {
|
|
256
|
+
print(f"created {dir_name}")
|
|
207
257
|
return 0
|
|
208
258
|
|
|
209
259
|
|
|
@@ -215,22 +265,25 @@ def validate_command(args: argparse.Namespace) -> int:
|
|
|
215
265
|
def archive_change(args: argparse.Namespace) -> int:
|
|
216
266
|
repo_root = get_repo_root()
|
|
217
267
|
slug = args.slug
|
|
268
|
+
source = _change_dir(repo_root, slug)
|
|
269
|
+
if source.is_dir():
|
|
270
|
+
_normalize_archive_metadata(repo_root, source)
|
|
271
|
+
|
|
218
272
|
if not validate_change(repo_root, slug, quiet=True):
|
|
219
273
|
return 1
|
|
220
274
|
|
|
221
|
-
source = _change_dir(repo_root, slug)
|
|
222
275
|
month = datetime.now().astimezone().strftime("%Y-%m")
|
|
223
276
|
destination = _archive_dir(repo_root) / month / slug
|
|
224
|
-
|
|
225
|
-
|
|
277
|
+
result = archive_directory_resumable(source, destination)
|
|
278
|
+
if not result.ok:
|
|
279
|
+
level = "Warning" if result.partial else "Error"
|
|
280
|
+
print(f"{level}: {result.message}", file=sys.stderr)
|
|
226
281
|
return 1
|
|
227
282
|
|
|
228
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
-
shutil.move(str(source), str(destination))
|
|
230
|
-
|
|
231
283
|
metadata = _read_metadata(destination)
|
|
232
284
|
metadata["status"] = "archived"
|
|
233
285
|
metadata["archived_at"] = _now_iso()
|
|
286
|
+
metadata["task"] = _normalize_task_link(repo_root, metadata.get("task"))
|
|
234
287
|
_write_metadata(destination, metadata)
|
|
235
288
|
|
|
236
289
|
print(f"archived {slug}")
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Helpers for the cowork-flow agent team runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TASK_RE = re.compile(r"^### Task\s+(\d+):\s*(.+?)\s*$")
|
|
11
|
+
FILE_RE = re.compile(r"^-\s+(Create|Modify|Test):\s+`([^`]+)`")
|
|
12
|
+
DEP_RE = re.compile(r"depends on Task\s+(\d+)", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_plan(text: str) -> list[dict[str, object]]:
|
|
16
|
+
tasks: list[dict[str, object]] = []
|
|
17
|
+
current: dict[str, object] | None = None
|
|
18
|
+
|
|
19
|
+
for raw_line in text.splitlines():
|
|
20
|
+
line = raw_line.rstrip()
|
|
21
|
+
task_match = TASK_RE.match(line)
|
|
22
|
+
if task_match:
|
|
23
|
+
current = {
|
|
24
|
+
"number": int(task_match.group(1)),
|
|
25
|
+
"id": f"T{int(task_match.group(1)):03d}",
|
|
26
|
+
"title": task_match.group(2),
|
|
27
|
+
"files": [],
|
|
28
|
+
"steps": [],
|
|
29
|
+
"commands": [],
|
|
30
|
+
"explicit_dependencies": [],
|
|
31
|
+
}
|
|
32
|
+
tasks.append(current)
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if current is None:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
file_match = FILE_RE.match(line.strip())
|
|
39
|
+
if file_match:
|
|
40
|
+
current["files"].append(
|
|
41
|
+
{
|
|
42
|
+
"kind": file_match.group(1).lower(),
|
|
43
|
+
"path": file_match.group(2),
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
stripped = line.strip()
|
|
49
|
+
if stripped.startswith("- [ ]"):
|
|
50
|
+
current["steps"].append(stripped)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if stripped.startswith("Run:"):
|
|
54
|
+
current["commands"].append(stripped.removeprefix("Run:").strip())
|
|
55
|
+
|
|
56
|
+
dep_match = DEP_RE.search(stripped)
|
|
57
|
+
if dep_match:
|
|
58
|
+
current["explicit_dependencies"].append(f"T{int(dep_match.group(1)):03d}")
|
|
59
|
+
|
|
60
|
+
return tasks
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _task_files(task: dict[str, object]) -> set[str]:
|
|
64
|
+
files = task.get("files", [])
|
|
65
|
+
if not isinstance(files, list):
|
|
66
|
+
return set()
|
|
67
|
+
paths = set()
|
|
68
|
+
for item in files:
|
|
69
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str):
|
|
70
|
+
paths.add(item["path"])
|
|
71
|
+
return paths
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _task_dependencies(tasks: list[dict[str, object]]) -> list[dict[str, str]]:
|
|
75
|
+
dependencies: list[dict[str, str]] = []
|
|
76
|
+
for index, task in enumerate(tasks):
|
|
77
|
+
task_id = str(task["id"])
|
|
78
|
+
task_files = _task_files(task)
|
|
79
|
+
|
|
80
|
+
explicit = task.get("explicit_dependencies", [])
|
|
81
|
+
if isinstance(explicit, list):
|
|
82
|
+
for dependency in explicit:
|
|
83
|
+
dependencies.append(
|
|
84
|
+
{
|
|
85
|
+
"task": task_id,
|
|
86
|
+
"depends_on_task": str(dependency),
|
|
87
|
+
"reason": "explicit",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for previous in tasks[:index]:
|
|
92
|
+
previous_files = _task_files(previous)
|
|
93
|
+
if task_files.intersection(previous_files):
|
|
94
|
+
dependencies.append(
|
|
95
|
+
{
|
|
96
|
+
"task": task_id,
|
|
97
|
+
"depends_on_task": str(previous["id"]),
|
|
98
|
+
"reason": "file-overlap",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return dependencies
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
DEFAULT_ROLE_TYPES = {
|
|
106
|
+
"implementer": "worker",
|
|
107
|
+
"spec-reviewer": "default",
|
|
108
|
+
"quality-reviewer": "default",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ROLE_CAPABILITIES = {
|
|
112
|
+
"implementer": {"implementation", "test-writing"},
|
|
113
|
+
"spec-reviewer": {"spec-review", "acceptance-check"},
|
|
114
|
+
"quality-reviewer": {"code-quality-review", "test-review", "verification"},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ROLE_TASK_TYPES = {
|
|
118
|
+
"implementer": {"code", "test"},
|
|
119
|
+
"spec-reviewer": {"review", "documentation"},
|
|
120
|
+
"quality-reviewer": {"review", "verification"},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _role_config(registry: dict[str, object] | None, role: str) -> dict[str, object]:
|
|
125
|
+
agents = registry.get("agents", {}) if registry else {}
|
|
126
|
+
if not isinstance(agents, dict):
|
|
127
|
+
return {}
|
|
128
|
+
config = agents.get(role, {})
|
|
129
|
+
return config if isinstance(config, dict) else {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ensure_agent_config(registry: dict[str, object], role: str) -> dict[str, object]:
|
|
133
|
+
agents = registry.setdefault("agents", {})
|
|
134
|
+
if not isinstance(agents, dict):
|
|
135
|
+
registry["agents"] = {}
|
|
136
|
+
agents = registry["agents"]
|
|
137
|
+
config = agents.setdefault(role, {})
|
|
138
|
+
if not isinstance(config, dict):
|
|
139
|
+
agents[role] = {}
|
|
140
|
+
config = agents[role]
|
|
141
|
+
return config
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_list_item(line: str) -> str | None:
|
|
145
|
+
stripped = line.strip()
|
|
146
|
+
if not stripped.startswith("-"):
|
|
147
|
+
return None
|
|
148
|
+
value = stripped[1:].strip()
|
|
149
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
150
|
+
value = value[1:-1]
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_agent_registry(path: Path) -> dict[str, object]:
|
|
155
|
+
registry: dict[str, object] = {"default_adapter": "codex", "agents": {}}
|
|
156
|
+
try:
|
|
157
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
158
|
+
except OSError:
|
|
159
|
+
return registry
|
|
160
|
+
|
|
161
|
+
current_role: str | None = None
|
|
162
|
+
current_list: str | None = None
|
|
163
|
+
current_nested: str | None = None
|
|
164
|
+
prompt_role: str | None = None
|
|
165
|
+
prompt_lines: list[str] = []
|
|
166
|
+
in_agents = False
|
|
167
|
+
|
|
168
|
+
def flush_prompt() -> None:
|
|
169
|
+
nonlocal prompt_role, prompt_lines
|
|
170
|
+
if prompt_role is None:
|
|
171
|
+
return
|
|
172
|
+
config = _ensure_agent_config(registry, prompt_role)
|
|
173
|
+
config["prompt"] = "\n".join(prompt_lines).rstrip()
|
|
174
|
+
prompt_role = None
|
|
175
|
+
prompt_lines = []
|
|
176
|
+
|
|
177
|
+
for raw_line in lines:
|
|
178
|
+
indent = len(raw_line) - len(raw_line.lstrip())
|
|
179
|
+
stripped = raw_line.strip()
|
|
180
|
+
|
|
181
|
+
if prompt_role is not None:
|
|
182
|
+
if raw_line.startswith(" ") or not stripped:
|
|
183
|
+
prompt_lines.append(raw_line[6:] if raw_line.startswith(" ") else "")
|
|
184
|
+
continue
|
|
185
|
+
flush_prompt()
|
|
186
|
+
|
|
187
|
+
if not stripped or stripped.startswith("#"):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if indent == 0:
|
|
191
|
+
current_role = None
|
|
192
|
+
current_list = None
|
|
193
|
+
current_nested = None
|
|
194
|
+
in_agents = stripped == "agents:"
|
|
195
|
+
if stripped.startswith("default_adapter:"):
|
|
196
|
+
registry["default_adapter"] = stripped.partition(":")[2].strip() or "codex"
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if in_agents and indent == 2 and stripped.endswith(":"):
|
|
200
|
+
current_role = stripped[:-1].strip()
|
|
201
|
+
current_list = None
|
|
202
|
+
current_nested = None
|
|
203
|
+
_ensure_agent_config(registry, current_role)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if not in_agents or current_role is None:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
config = _ensure_agent_config(registry, current_role)
|
|
210
|
+
|
|
211
|
+
if indent == 4 and stripped == "prompt: |":
|
|
212
|
+
current_list = None
|
|
213
|
+
current_nested = None
|
|
214
|
+
prompt_role = current_role
|
|
215
|
+
prompt_lines = []
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if indent == 4 and stripped.endswith(":"):
|
|
219
|
+
key = stripped[:-1].strip()
|
|
220
|
+
current_list = key if key in {"capabilities", "preferred_task_types", "file_patterns"} else None
|
|
221
|
+
current_nested = key if key == "risk_limits" else None
|
|
222
|
+
if current_list:
|
|
223
|
+
config[current_list] = []
|
|
224
|
+
elif current_nested:
|
|
225
|
+
config[current_nested] = {}
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if indent == 4 and ":" in stripped:
|
|
229
|
+
key, _, value = stripped.partition(":")
|
|
230
|
+
key = key.strip()
|
|
231
|
+
value = value.strip()
|
|
232
|
+
current_list = None
|
|
233
|
+
current_nested = None
|
|
234
|
+
if key == "agent_type":
|
|
235
|
+
config[key] = value
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if indent == 6 and current_list:
|
|
239
|
+
value = _parse_list_item(stripped)
|
|
240
|
+
if value is not None:
|
|
241
|
+
items = config.setdefault(current_list, [])
|
|
242
|
+
if isinstance(items, list):
|
|
243
|
+
items.append(value)
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
if indent == 6 and current_nested == "risk_limits" and ":" in stripped:
|
|
247
|
+
key, _, value = stripped.partition(":")
|
|
248
|
+
limits = config.setdefault("risk_limits", {})
|
|
249
|
+
if isinstance(limits, dict):
|
|
250
|
+
try:
|
|
251
|
+
limits[key.strip()] = int(value.strip())
|
|
252
|
+
except ValueError:
|
|
253
|
+
limits[key.strip()] = value.strip()
|
|
254
|
+
|
|
255
|
+
flush_prompt()
|
|
256
|
+
return registry
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _as_string_set(value: object) -> set[str]:
|
|
260
|
+
if not isinstance(value, list):
|
|
261
|
+
return set()
|
|
262
|
+
return {item for item in value if isinstance(item, str)}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _matches_pattern(path: str, pattern: str) -> bool:
|
|
266
|
+
if pattern.endswith("/**"):
|
|
267
|
+
return path.startswith(pattern[:-3])
|
|
268
|
+
return Path(path).match(pattern)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _file_match_count(config: dict[str, object], task: dict[str, object]) -> int:
|
|
272
|
+
patterns = _as_string_set(config.get("file_patterns"))
|
|
273
|
+
if not patterns:
|
|
274
|
+
return 0
|
|
275
|
+
count = 0
|
|
276
|
+
for file_path in _task_files(task):
|
|
277
|
+
if any(_matches_pattern(file_path, pattern) for pattern in patterns):
|
|
278
|
+
count += 1
|
|
279
|
+
return count
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _agent_score(role: str, name: str, config: dict[str, object], task: dict[str, object]) -> int:
|
|
283
|
+
score = 0
|
|
284
|
+
if name == role:
|
|
285
|
+
score += 10
|
|
286
|
+
score += 5 * len(_as_string_set(config.get("capabilities")).intersection(ROLE_CAPABILITIES.get(role, set())))
|
|
287
|
+
score += 3 * len(_as_string_set(config.get("preferred_task_types")).intersection(ROLE_TASK_TYPES.get(role, set())))
|
|
288
|
+
score += 2 * _file_match_count(config, task)
|
|
289
|
+
return score
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _select_agent(role: str, task: dict[str, object], registry: dict[str, object] | None = None) -> tuple[str, str, str]:
|
|
293
|
+
agents = registry.get("agents", {}) if registry else {}
|
|
294
|
+
if not isinstance(agents, dict):
|
|
295
|
+
agents = {}
|
|
296
|
+
|
|
297
|
+
best_name = role
|
|
298
|
+
best_config = _role_config(registry, role)
|
|
299
|
+
best_score = _agent_score(role, role, best_config, task) if best_config else -1
|
|
300
|
+
|
|
301
|
+
for name, config in agents.items():
|
|
302
|
+
if not isinstance(name, str) or not isinstance(config, dict):
|
|
303
|
+
continue
|
|
304
|
+
score = _agent_score(role, name, config, task)
|
|
305
|
+
if score > best_score or (score == best_score and name == role):
|
|
306
|
+
best_name = name
|
|
307
|
+
best_config = config
|
|
308
|
+
best_score = score
|
|
309
|
+
|
|
310
|
+
agent_type = DEFAULT_ROLE_TYPES.get(role, "default")
|
|
311
|
+
configured_type = best_config.get("agent_type") if isinstance(best_config, dict) else None
|
|
312
|
+
if isinstance(configured_type, str) and configured_type:
|
|
313
|
+
agent_type = configured_type
|
|
314
|
+
prompt = best_config.get("prompt") if isinstance(best_config, dict) else ""
|
|
315
|
+
return best_name, agent_type, prompt if isinstance(prompt, str) else ""
|
|
316
|
+
|
|
317
|
+
def build_dispatch_plan(
|
|
318
|
+
tasks: list[dict[str, object]],
|
|
319
|
+
registry: dict[str, object] | None = None,
|
|
320
|
+
) -> dict[str, object]:
|
|
321
|
+
assignments: list[dict[str, object]] = []
|
|
322
|
+
task_dependencies = _task_dependencies(tasks)
|
|
323
|
+
dependency_lookup: dict[str, list[str]] = {}
|
|
324
|
+
for dependency in task_dependencies:
|
|
325
|
+
dependency_lookup.setdefault(dependency["task"], []).append(dependency["depends_on_task"])
|
|
326
|
+
|
|
327
|
+
for task in tasks:
|
|
328
|
+
task_id = str(task["id"])
|
|
329
|
+
task_dependency_assignments = [
|
|
330
|
+
f"{dependency}-quality-reviewer"
|
|
331
|
+
for dependency in dependency_lookup.get(task_id, [])
|
|
332
|
+
]
|
|
333
|
+
chain = [
|
|
334
|
+
("implementer", task_dependency_assignments),
|
|
335
|
+
("spec-reviewer", [f"{task_id}-implementer"]),
|
|
336
|
+
("quality-reviewer", [f"{task_id}-spec-reviewer"]),
|
|
337
|
+
]
|
|
338
|
+
for role, depends_on in chain:
|
|
339
|
+
agent, agent_type, agent_prompt = _select_agent(role, task, registry)
|
|
340
|
+
assignments.append(
|
|
341
|
+
{
|
|
342
|
+
"id": f"{task_id}-{role}",
|
|
343
|
+
"task": task_id,
|
|
344
|
+
"title": task["title"],
|
|
345
|
+
"role": role,
|
|
346
|
+
"recommended_agent": agent,
|
|
347
|
+
"agent_type": agent_type,
|
|
348
|
+
"agent_prompt": agent_prompt,
|
|
349
|
+
"depends_on": depends_on,
|
|
350
|
+
"files": task.get("files", []),
|
|
351
|
+
"steps": task.get("steps", []),
|
|
352
|
+
"commands": task.get("commands", []),
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
"version": 1,
|
|
358
|
+
"adapter": str(registry.get("default_adapter", "codex")) if registry else "codex",
|
|
359
|
+
"tasks": tasks,
|
|
360
|
+
"task_dependencies": task_dependencies,
|
|
361
|
+
"assignments": assignments,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def render_dispatch_plan(dispatch_plan: dict[str, object]) -> str:
|
|
366
|
+
lines = [
|
|
367
|
+
"version: 1",
|
|
368
|
+
f"adapter: {dispatch_plan['adapter']}",
|
|
369
|
+
"task_dependencies:",
|
|
370
|
+
]
|
|
371
|
+
for dependency in dispatch_plan["task_dependencies"]:
|
|
372
|
+
lines.extend(
|
|
373
|
+
[
|
|
374
|
+
f" - task: {dependency['task']}",
|
|
375
|
+
f" depends_on_task: {dependency['depends_on_task']}",
|
|
376
|
+
f" reason: {dependency['reason']}",
|
|
377
|
+
]
|
|
378
|
+
)
|
|
379
|
+
lines.append("assignments:")
|
|
380
|
+
for assignment in dispatch_plan["assignments"]:
|
|
381
|
+
lines.extend(
|
|
382
|
+
[
|
|
383
|
+
f" - id: {assignment['id']}",
|
|
384
|
+
f" task: {assignment['task']}",
|
|
385
|
+
f" role: {assignment['role']}",
|
|
386
|
+
f" recommended_agent: {assignment['recommended_agent']}",
|
|
387
|
+
f" agent_type: {assignment['agent_type']}",
|
|
388
|
+
" depends_on:",
|
|
389
|
+
]
|
|
390
|
+
)
|
|
391
|
+
depends_on = assignment.get("depends_on", [])
|
|
392
|
+
if depends_on:
|
|
393
|
+
for dependency in depends_on:
|
|
394
|
+
lines.append(f" - {dependency}")
|
|
395
|
+
else:
|
|
396
|
+
lines.append(" - none")
|
|
397
|
+
lines.append("")
|
|
398
|
+
return "\n".join(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def write_json(path: Path, data: dict[str, object]) -> None:
|
|
402
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def build_initial_status(dispatch_plan: dict[str, object]) -> dict[str, object]:
|
|
406
|
+
assignments: dict[str, dict[str, object]] = {}
|
|
407
|
+
for assignment in dispatch_plan["assignments"]:
|
|
408
|
+
depends_on = [
|
|
409
|
+
dependency
|
|
410
|
+
for dependency in assignment.get("depends_on", [])
|
|
411
|
+
if dependency != "none"
|
|
412
|
+
]
|
|
413
|
+
assignments[assignment["id"]] = {
|
|
414
|
+
"status": "ready" if not depends_on else "pending",
|
|
415
|
+
"attempts": 0,
|
|
416
|
+
"depends_on": depends_on,
|
|
417
|
+
"role": assignment["role"],
|
|
418
|
+
"task": assignment["task"],
|
|
419
|
+
"recommended_agent": assignment["recommended_agent"],
|
|
420
|
+
"agent_type": assignment["agent_type"],
|
|
421
|
+
"agent_prompt": assignment.get("agent_prompt", ""),
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return {"version": 1, "current_batch": 1, "assignments": assignments}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def build_initial_metrics(dispatch_plan: dict[str, object]) -> dict[str, object]:
|
|
428
|
+
return {
|
|
429
|
+
"assignments": len(dispatch_plan["assignments"]),
|
|
430
|
+
"attempts": 0,
|
|
431
|
+
"successfulAssignments": 0,
|
|
432
|
+
"failedAssignments": 0,
|
|
433
|
+
"reviewReworks": 0,
|
|
434
|
+
"agents": {},
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _render_named_items(title: str, items: object) -> list[str]:
|
|
439
|
+
lines = [f"## {title}", ""]
|
|
440
|
+
if isinstance(items, list) and items:
|
|
441
|
+
for item in items:
|
|
442
|
+
lines.append(f"- {item}")
|
|
443
|
+
else:
|
|
444
|
+
lines.append("- none")
|
|
445
|
+
lines.append("")
|
|
446
|
+
return lines
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def render_assignment_prompt(assignment: dict[str, object]) -> str:
|
|
450
|
+
lines = [
|
|
451
|
+
f"# {assignment['id']}",
|
|
452
|
+
"",
|
|
453
|
+
f"Role: {assignment['role']}",
|
|
454
|
+
f"Recommended agent: {assignment['recommended_agent']}",
|
|
455
|
+
f"Agent type: {assignment['agent_type']}",
|
|
456
|
+
f"Task: {assignment.get('title', '')}",
|
|
457
|
+
"",
|
|
458
|
+
]
|
|
459
|
+
agent_prompt = assignment.get("agent_prompt")
|
|
460
|
+
if isinstance(agent_prompt, str) and agent_prompt.strip():
|
|
461
|
+
lines.extend(["## Agent prompt", "", agent_prompt.strip(), ""])
|
|
462
|
+
lines.append("You are not alone in this codebase. Respect the write boundary, do not revert other agents' edits, and report changed files.")
|
|
463
|
+
lines.append("")
|
|
464
|
+
lines.extend(_render_named_items("Files", assignment.get("files")))
|
|
465
|
+
lines.extend(_render_named_items("Steps", assignment.get("steps")))
|
|
466
|
+
lines.extend(_render_named_items("Commands", assignment.get("commands")))
|
|
467
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small resumable directory archive helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import filecmp
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ArchiveResult:
|
|
14
|
+
status: str
|
|
15
|
+
destination: Path
|
|
16
|
+
message: str = ""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def ok(self) -> bool:
|
|
20
|
+
return self.status == "archived"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def partial(self) -> bool:
|
|
24
|
+
return self.status == "partial"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def directories_match(left: Path, right: Path) -> bool:
|
|
28
|
+
"""Return True when two directory trees contain the same files and bytes."""
|
|
29
|
+
if not left.is_dir() or not right.is_dir():
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
left_files = sorted(path.relative_to(left) for path in left.rglob("*") if path.is_file())
|
|
33
|
+
right_files = sorted(path.relative_to(right) for path in right.rglob("*") if path.is_file())
|
|
34
|
+
if left_files != right_files:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
return all(filecmp.cmp(left / rel, right / rel, shallow=False) for rel in left_files)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def archive_directory_resumable(source: Path, destination: Path) -> ArchiveResult:
|
|
41
|
+
"""Copy, verify, then delete a directory, resuming matching duplicate state."""
|
|
42
|
+
if not source.is_dir():
|
|
43
|
+
return ArchiveResult("failed", destination, f"source directory not found: {source}")
|
|
44
|
+
|
|
45
|
+
if destination.exists():
|
|
46
|
+
if not destination.is_dir():
|
|
47
|
+
return ArchiveResult("failed", destination, f"archive destination is not a directory: {destination}")
|
|
48
|
+
if not directories_match(source, destination):
|
|
49
|
+
return ArchiveResult(
|
|
50
|
+
"failed",
|
|
51
|
+
destination,
|
|
52
|
+
f"archive source and destination differ: {source} | {destination}",
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
try:
|
|
57
|
+
shutil.copytree(source, destination)
|
|
58
|
+
except (OSError, IOError, shutil.Error) as exc:
|
|
59
|
+
return ArchiveResult("failed", destination, f"failed to copy directory to archive: {exc}")
|
|
60
|
+
if not directories_match(source, destination):
|
|
61
|
+
return ArchiveResult("failed", destination, f"archive verification failed: {source} | {destination}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
shutil.rmtree(source)
|
|
65
|
+
except (OSError, IOError) as exc:
|
|
66
|
+
return ArchiveResult(
|
|
67
|
+
"partial",
|
|
68
|
+
destination,
|
|
69
|
+
f"archive verified but source remains: {source}; delete failed: {exc}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return ArchiveResult("archived", destination)
|