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.
Files changed (41) hide show
  1. package/README.md +44 -1
  2. package/package.json +8 -5
  3. package/src/lib/copy-template.js +9 -9
  4. package/src/lib/package-info.js +8 -2
  5. package/template/.agent/skills/agent-team-execution/SKILL.md +26 -0
  6. package/template/{.superpowers → .agent/skills}/brainstorming/scripts/start-server.sh +0 -0
  7. package/template/{.superpowers → .agent/skills}/brainstorming/scripts/stop-server.sh +0 -0
  8. package/template/.agent/skills/start/SKILL.md +1 -0
  9. package/template/.cowork-flow/.version +1 -1
  10. package/template/.cowork-flow/agent-team/adapters.yaml +15 -0
  11. package/template/.cowork-flow/agent-team/agents.yaml +142 -0
  12. package/template/.cowork-flow/agent-team/policy.yaml +23 -0
  13. package/template/.cowork-flow/config.yaml +9 -0
  14. package/template/.cowork-flow/run +10 -93
  15. package/template/.cowork-flow/run.cmd +12 -115
  16. package/template/.cowork-flow/scripts/agent_team.py +584 -0
  17. package/template/.cowork-flow/scripts/change.py +68 -15
  18. package/template/.cowork-flow/scripts/common/agent_team.py +467 -0
  19. package/template/.cowork-flow/scripts/common/archive_utils.py +72 -0
  20. package/template/.cowork-flow/scripts/common/config.py +18 -0
  21. package/template/.cowork-flow/scripts/common/git_context.py +17 -0
  22. package/template/.cowork-flow/scripts/common/task_utils.py +5 -18
  23. package/template/.cowork-flow/scripts/run.py +92 -0
  24. package/template/.cowork-flow/scripts/task.py +1 -1
  25. package/template/.cowork-flow/workflow.md +65 -158
  26. package/template/.superpowers/executing-plans/SKILL.md +2 -0
  27. package/template/AGENTS.md +3 -2
  28. /package/template/{.superpowers → .agent/skills}/brainstorming/SKILL.md +0 -0
  29. /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/frame-template.html +0 -0
  30. /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/helper.js +0 -0
  31. /package/template/{.superpowers → .agent/skills}/brainstorming/scripts/server.cjs +0 -0
  32. /package/template/{.superpowers → .agent/skills}/brainstorming/spec-document-reviewer-prompt.md +0 -0
  33. /package/template/{.superpowers → .agent/skills}/brainstorming/visual-companion.md +0 -0
  34. /package/template/{.superpowers → .agent/skills}/requesting-code-review/SKILL.md +0 -0
  35. /package/template/{.superpowers → .agent/skills}/requesting-code-review/code-reviewer.md +0 -0
  36. /package/template/{.superpowers → .agent/skills}/subagent-driven-development/SKILL.md +0 -0
  37. /package/template/{.superpowers → .agent/skills}/subagent-driven-development/code-quality-reviewer-prompt.md +0 -0
  38. /package/template/{.superpowers → .agent/skills}/subagent-driven-development/implementer-prompt.md +0 -0
  39. /package/template/{.superpowers → .agent/skills}/subagent-driven-development/spec-reviewer-prompt.md +0 -0
  40. /package/template/{.superpowers → .agent/skills}/writing-plans/SKILL.md +0 -0
  41. /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.paths import DIR_ARCHIVE, DIR_CHANGES, DIR_WORKFLOW, get_repo_root
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
- if direct.exists():
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 / DIR_WORKFLOW / base_dir / raw_path
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
- change_dir = _change_dir(repo_root, slug)
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: {slug}", file=sys.stderr)
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"# {slug}\n\nDescribe the proposed behavior change.\n",
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": 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 {slug}")
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
- if destination.exists():
225
- print(f"Error: archive destination already exists: {destination}", file=sys.stderr)
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)