cowork-flow 0.0.1

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 (101) hide show
  1. package/README.md +193 -0
  2. package/bin/cowork-flow.js +9 -0
  3. package/package.json +28 -0
  4. package/src/cli.js +61 -0
  5. package/src/commands/init.js +88 -0
  6. package/src/commands/sync.js +33 -0
  7. package/src/commands/update.js +60 -0
  8. package/src/lib/copy-template.js +189 -0
  9. package/src/lib/package-info.js +49 -0
  10. package/src/lib/paths.js +6 -0
  11. package/template/.agent/skills/break-loop/SKILL.md +131 -0
  12. package/template/.agent/skills/check-cross-layer/SKILL.md +158 -0
  13. package/template/.agent/skills/finish-work/SKILL.md +163 -0
  14. package/template/.agent/skills/record-session/SKILL.md +78 -0
  15. package/template/.agent/skills/start/SKILL.md +390 -0
  16. package/template/.agent/skills/update-spec/SKILL.md +335 -0
  17. package/template/.cowork-flow/.version +1 -0
  18. package/template/.cowork-flow/changes/archive/.gitkeep +1 -0
  19. package/template/.cowork-flow/config.yaml +49 -0
  20. package/template/.cowork-flow/plans/.gitkeep +1 -0
  21. package/template/.cowork-flow/scripts/add_session.py +441 -0
  22. package/template/.cowork-flow/scripts/change.py +314 -0
  23. package/template/.cowork-flow/scripts/common/__init__.py +84 -0
  24. package/template/.cowork-flow/scripts/common/config.py +128 -0
  25. package/template/.cowork-flow/scripts/common/developer.py +205 -0
  26. package/template/.cowork-flow/scripts/common/files.py +24 -0
  27. package/template/.cowork-flow/scripts/common/git_context.py +628 -0
  28. package/template/.cowork-flow/scripts/common/paths.py +350 -0
  29. package/template/.cowork-flow/scripts/common/task_utils.py +200 -0
  30. package/template/.cowork-flow/scripts/get_context.py +16 -0
  31. package/template/.cowork-flow/scripts/get_developer.py +26 -0
  32. package/template/.cowork-flow/scripts/init_developer.py +51 -0
  33. package/template/.cowork-flow/scripts/task.py +1378 -0
  34. package/template/.cowork-flow/spec/backend/database-guidelines.md +26 -0
  35. package/template/.cowork-flow/spec/backend/directory-structure.md +27 -0
  36. package/template/.cowork-flow/spec/backend/error-handling.md +20 -0
  37. package/template/.cowork-flow/spec/backend/index.md +36 -0
  38. package/template/.cowork-flow/spec/backend/logging-guidelines.md +20 -0
  39. package/template/.cowork-flow/spec/backend/quality-guidelines.md +24 -0
  40. package/template/.cowork-flow/spec/frontend/component-guidelines.md +19 -0
  41. package/template/.cowork-flow/spec/frontend/directory-structure.md +18 -0
  42. package/template/.cowork-flow/spec/frontend/hook-guidelines.md +17 -0
  43. package/template/.cowork-flow/spec/frontend/index.md +28 -0
  44. package/template/.cowork-flow/spec/frontend/quality-guidelines.md +23 -0
  45. package/template/.cowork-flow/spec/frontend/state-management.md +18 -0
  46. package/template/.cowork-flow/spec/frontend/type-safety.md +18 -0
  47. package/template/.cowork-flow/spec/guides/code-reuse-thinking-guide.md +26 -0
  48. package/template/.cowork-flow/spec/guides/cross-layer-thinking-guide.md +21 -0
  49. package/template/.cowork-flow/spec/guides/index.md +31 -0
  50. package/template/.cowork-flow/spec/guides/pre-implementation-checklist.md +15 -0
  51. package/template/.cowork-flow/tasks/.gitkeep +1 -0
  52. package/template/.cowork-flow/tasks/archive/.gitkeep +1 -0
  53. package/template/.cowork-flow/workflow.md +411 -0
  54. package/template/.cowork-flow/workspace/index.md +95 -0
  55. package/template/.superpowers/brainstorming/SKILL.md +164 -0
  56. package/template/.superpowers/brainstorming/scripts/frame-template.html +214 -0
  57. package/template/.superpowers/brainstorming/scripts/helper.js +88 -0
  58. package/template/.superpowers/brainstorming/scripts/server.cjs +354 -0
  59. package/template/.superpowers/brainstorming/scripts/start-server.sh +148 -0
  60. package/template/.superpowers/brainstorming/scripts/stop-server.sh +56 -0
  61. package/template/.superpowers/brainstorming/spec-document-reviewer-prompt.md +49 -0
  62. package/template/.superpowers/brainstorming/visual-companion.md +287 -0
  63. package/template/.superpowers/dispatching-parallel-agents/SKILL.md +182 -0
  64. package/template/.superpowers/executing-plans/SKILL.md +70 -0
  65. package/template/.superpowers/finishing-a-development-branch/SKILL.md +251 -0
  66. package/template/.superpowers/receiving-code-review/SKILL.md +213 -0
  67. package/template/.superpowers/requesting-code-review/SKILL.md +103 -0
  68. package/template/.superpowers/requesting-code-review/code-reviewer.md +168 -0
  69. package/template/.superpowers/subagent-driven-development/SKILL.md +279 -0
  70. package/template/.superpowers/subagent-driven-development/code-quality-reviewer-prompt.md +25 -0
  71. package/template/.superpowers/subagent-driven-development/implementer-prompt.md +113 -0
  72. package/template/.superpowers/subagent-driven-development/spec-reviewer-prompt.md +61 -0
  73. package/template/.superpowers/systematic-debugging/CREATION-LOG.md +119 -0
  74. package/template/.superpowers/systematic-debugging/SKILL.md +296 -0
  75. package/template/.superpowers/systematic-debugging/condition-based-waiting-example.ts +158 -0
  76. package/template/.superpowers/systematic-debugging/condition-based-waiting.md +115 -0
  77. package/template/.superpowers/systematic-debugging/defense-in-depth.md +122 -0
  78. package/template/.superpowers/systematic-debugging/find-polluter.sh +63 -0
  79. package/template/.superpowers/systematic-debugging/root-cause-tracing.md +169 -0
  80. package/template/.superpowers/systematic-debugging/test-academic.md +14 -0
  81. package/template/.superpowers/systematic-debugging/test-pressure-1.md +58 -0
  82. package/template/.superpowers/systematic-debugging/test-pressure-2.md +68 -0
  83. package/template/.superpowers/systematic-debugging/test-pressure-3.md +69 -0
  84. package/template/.superpowers/test-driven-development/SKILL.md +371 -0
  85. package/template/.superpowers/test-driven-development/testing-anti-patterns.md +299 -0
  86. package/template/.superpowers/using-git-worktrees/SKILL.md +215 -0
  87. package/template/.superpowers/using-superpowers/SKILL.md +117 -0
  88. package/template/.superpowers/using-superpowers/references/codex-tools.md +59 -0
  89. package/template/.superpowers/using-superpowers/references/copilot-tools.md +42 -0
  90. package/template/.superpowers/using-superpowers/references/gemini-tools.md +51 -0
  91. package/template/.superpowers/verification-before-completion/SKILL.md +139 -0
  92. package/template/.superpowers/writing-plans/SKILL.md +152 -0
  93. package/template/.superpowers/writing-plans/plan-document-reviewer-prompt.md +49 -0
  94. package/template/.superpowers/writing-skills/SKILL.md +655 -0
  95. package/template/.superpowers/writing-skills/anthropic-best-practices.md +1150 -0
  96. package/template/.superpowers/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  97. package/template/.superpowers/writing-skills/graphviz-conventions.dot +172 -0
  98. package/template/.superpowers/writing-skills/persuasion-principles.md +187 -0
  99. package/template/.superpowers/writing-skills/render-graphs.js +168 -0
  100. package/template/.superpowers/writing-skills/testing-skills-with-subagents.md +384 -0
  101. package/template/AGENTS.md +116 -0
@@ -0,0 +1,1378 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Task Management Script for cowork-flow workflow.
5
+
6
+ Usage:
7
+ python3 task.py create "<title>" [--slug <name>] [--assignee <dev>] [--priority P0|P1|P2|P3] [--parent <dir>]
8
+ python3 task.py init-context <dir> <type> # Initialize jsonl files
9
+ python3 task.py add-context <dir> <file> <path> [reason] # Add jsonl entry
10
+ python3 task.py validate <dir> # Validate jsonl files
11
+ python3 task.py list-context <dir> # List jsonl entries
12
+ python3 task.py start <dir> # Set as current task
13
+ python3 task.py finish # Clear current task
14
+ python3 task.py archive <task-name> # Archive completed task
15
+ python3 task.py list # List active tasks
16
+ python3 task.py list-archive [month] # List archived tasks
17
+ python3 task.py add-subtask <parent-dir> <child-dir> # Link child to parent
18
+ python3 task.py remove-subtask <parent-dir> <child-dir> # Unlink child from parent
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+
25
+ # IMPORTANT: Force stdout to use UTF-8 on Windows
26
+ # This fixes UnicodeEncodeError when outputting non-ASCII characters
27
+ if sys.platform == "win32":
28
+ import io as _io
29
+ if hasattr(sys.stdout, "reconfigure"):
30
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
31
+ elif hasattr(sys.stdout, "detach"):
32
+ sys.stdout = _io.TextIOWrapper(sys.stdout.detach(), encoding="utf-8", errors="replace") # type: ignore[union-attr]
33
+
34
+ import argparse
35
+ import json
36
+ import re
37
+ import shutil
38
+ from copy import deepcopy
39
+ from datetime import datetime
40
+ from pathlib import Path
41
+
42
+ from common.files import (
43
+ read_json_file as _read_json_file,
44
+ write_json_file as _write_json_file,
45
+ )
46
+ from common.git_context import _run_git_command
47
+ from common.paths import (
48
+ DIR_WORKFLOW,
49
+ DIR_AGENT,
50
+ DIR_TASKS,
51
+ DIR_SPEC,
52
+ DIR_ARCHIVE,
53
+ FILE_CURRENT_TASK,
54
+ FILE_TASK_JSON,
55
+ get_repo_root,
56
+ get_developer,
57
+ get_tasks_dir,
58
+ get_current_task,
59
+ set_current_task,
60
+ clear_current_task,
61
+ generate_task_date_prefix,
62
+ )
63
+ from common.task_utils import (
64
+ find_task_by_name,
65
+ archive_task_complete,
66
+ )
67
+ from common.config import get_hooks
68
+
69
+ CONTEXT_JSONL_FILES = ["implement.jsonl", "check.jsonl", "debug.jsonl"]
70
+ DONE_STATUSES = ("completed", "done")
71
+
72
+
73
+ # =============================================================================
74
+ # Colors
75
+ # =============================================================================
76
+
77
+ class Colors:
78
+ RED = "\033[0;31m"
79
+ GREEN = "\033[0;32m"
80
+ YELLOW = "\033[1;33m"
81
+ BLUE = "\033[0;34m"
82
+ CYAN = "\033[0;36m"
83
+ NC = "\033[0m"
84
+
85
+
86
+ def colored(text: str, color: str) -> str:
87
+ """Apply color to text."""
88
+ return f"{color}{text}{Colors.NC}"
89
+
90
+
91
+ def _get_current_task_file_path(repo_root: Path) -> Path:
92
+ """Build the absolute path to .current-task."""
93
+ return repo_root / DIR_WORKFLOW / FILE_CURRENT_TASK
94
+
95
+
96
+ def _is_current_task_dir(current_task: str, dir_name: str) -> bool:
97
+ """Check whether the current-task pointer refers to this task directory."""
98
+ return Path(current_task).name == dir_name
99
+
100
+
101
+ def _clear_current_task_or_report(repo_root: Path, current_task: str) -> bool:
102
+ """Clear .current-task and print a concrete error on failure."""
103
+ if clear_current_task(repo_root):
104
+ return True
105
+
106
+ current_file = _get_current_task_file_path(repo_root)
107
+ print(
108
+ colored(f"Error: Failed to clear current task file: {current_file}", Colors.RED),
109
+ file=sys.stderr,
110
+ )
111
+ print(f"Current task still points to: {current_task}", file=sys.stderr)
112
+ return False
113
+
114
+
115
+ def _write_json_or_report(path: Path, data: dict, label: str) -> bool:
116
+ """Write JSON data and print a concrete error on failure."""
117
+ if _write_json_file(path, data):
118
+ return True
119
+
120
+ print(
121
+ colored(f"Error: Failed to write {label}: {path}", Colors.RED),
122
+ file=sys.stderr,
123
+ )
124
+ return False
125
+
126
+
127
+ def _restore_current_task_or_report(repo_root: Path, current_task: str) -> bool:
128
+ """Restore .current-task after a failed archive attempt."""
129
+ current_file = _get_current_task_file_path(repo_root)
130
+
131
+ try:
132
+ current_file.parent.mkdir(parents=True, exist_ok=True)
133
+ current_file.write_text(current_task, encoding="utf-8")
134
+ return True
135
+ except OSError:
136
+ print(
137
+ colored(f"Error: Failed to restore current task file: {current_file}", Colors.RED),
138
+ file=sys.stderr,
139
+ )
140
+ print(f"Original current task was: {current_task}", file=sys.stderr)
141
+ return False
142
+
143
+
144
+ def _build_archived_task_relationship_updates(
145
+ dir_name: str,
146
+ task_data: dict,
147
+ tasks_dir: Path,
148
+ ) -> list[tuple[Path, dict, dict, str]]:
149
+ """Prepare task relationship updates for an archived task."""
150
+ updates: list[tuple[Path, dict, dict, str]] = []
151
+ task_parent = task_data.get("parent")
152
+ task_children = task_data.get("children", [])
153
+
154
+ if task_parent:
155
+ parent_dir = find_task_by_name(task_parent, tasks_dir)
156
+ if parent_dir:
157
+ parent_json = parent_dir / FILE_TASK_JSON
158
+ if parent_json.is_file():
159
+ parent_data = _read_json_file(parent_json)
160
+ if parent_data:
161
+ parent_children = parent_data.get("children", [])
162
+ if dir_name in parent_children:
163
+ updated_parent_data = deepcopy(parent_data)
164
+ updated_children = list(parent_children)
165
+ updated_children.remove(dir_name)
166
+ updated_parent_data["children"] = updated_children
167
+ updates.append(
168
+ (
169
+ parent_json,
170
+ parent_data,
171
+ updated_parent_data,
172
+ f"parent task metadata for {parent_dir.name}",
173
+ )
174
+ )
175
+
176
+ for child_name in task_children:
177
+ child_dir_path = find_task_by_name(child_name, tasks_dir)
178
+ if child_dir_path:
179
+ child_json = child_dir_path / FILE_TASK_JSON
180
+ if child_json.is_file():
181
+ child_data = _read_json_file(child_json)
182
+ if child_data:
183
+ updated_child_data = deepcopy(child_data)
184
+ updated_child_data["parent"] = None
185
+ updates.append(
186
+ (
187
+ child_json,
188
+ child_data,
189
+ updated_child_data,
190
+ f"child task metadata for {child_dir_path.name}",
191
+ )
192
+ )
193
+
194
+ return updates
195
+
196
+
197
+ def _apply_json_updates_with_rollback(
198
+ updates: list[tuple[Path, dict, dict, str]]
199
+ ) -> bool:
200
+ """Apply a batch of JSON updates and roll back any partial writes on failure."""
201
+ applied: list[tuple[Path, dict, str]] = []
202
+
203
+ for path, original_data, updated_data, label in updates:
204
+ if not _write_json_or_report(path, updated_data, label):
205
+ for rollback_path, rollback_data, rollback_label in reversed(applied):
206
+ if not _write_json_file(rollback_path, rollback_data):
207
+ print(
208
+ colored(
209
+ f"Warning: Failed to roll back {rollback_label}: {rollback_path}",
210
+ Colors.YELLOW,
211
+ ),
212
+ file=sys.stderr,
213
+ )
214
+ return False
215
+
216
+ applied.append((path, original_data, label))
217
+
218
+ return True
219
+
220
+
221
+ def _update_archived_task_relationships(
222
+ dir_name: str,
223
+ task_data: dict,
224
+ tasks_dir: Path,
225
+ ) -> bool:
226
+ """Remove archived task references from active task relationships."""
227
+ updates = _build_archived_task_relationship_updates(dir_name, task_data, tasks_dir)
228
+ return _apply_json_updates_with_rollback(updates)
229
+
230
+
231
+ def _rollback_archived_task_or_report(
232
+ task_dir: Path,
233
+ archive_dest: Path,
234
+ task_data: dict | None,
235
+ repo_root: Path,
236
+ current_task: str | None = None,
237
+ ) -> None:
238
+ """Try to restore the original task location and metadata after archive failure."""
239
+ print(
240
+ colored("Warning: Archive failed after partial progress; attempting rollback.", Colors.YELLOW),
241
+ file=sys.stderr,
242
+ )
243
+
244
+ if archive_dest.exists() and not task_dir.exists():
245
+ try:
246
+ shutil.move(str(archive_dest), str(task_dir))
247
+ except (OSError, IOError, shutil.Error) as e:
248
+ print(
249
+ colored(f"Error: Failed to move archived task back to source: {e}", Colors.RED),
250
+ file=sys.stderr,
251
+ )
252
+ elif archive_dest.exists() and task_dir.exists():
253
+ print(
254
+ colored(
255
+ f"Error: Both source and archive paths exist during rollback: {task_dir} | {archive_dest}",
256
+ Colors.RED,
257
+ ),
258
+ file=sys.stderr,
259
+ )
260
+
261
+ if task_data and task_dir.is_dir():
262
+ _write_json_or_report(task_dir / FILE_TASK_JSON, task_data, "restored task metadata")
263
+
264
+ if current_task:
265
+ _restore_current_task_or_report(repo_root, current_task)
266
+
267
+
268
+ def _finalize_archived_task_metadata(
269
+ archive_dest: Path,
270
+ task_data: dict | None,
271
+ tasks_dir: Path,
272
+ dir_name: str,
273
+ today: str,
274
+ ) -> bool:
275
+ """Write archived task metadata after the directory move succeeds."""
276
+ if not task_data:
277
+ return True
278
+
279
+ archived_json = archive_dest / FILE_TASK_JSON
280
+ archived_task_data = deepcopy(task_data)
281
+ archived_task_data["status"] = "completed"
282
+ archived_task_data["completedAt"] = today
283
+ if not _write_json_or_report(
284
+ archived_json,
285
+ archived_task_data,
286
+ "archived task metadata",
287
+ ):
288
+ return False
289
+
290
+ return _update_archived_task_relationships(dir_name, task_data, tasks_dir)
291
+
292
+
293
+ # =============================================================================
294
+ # Lifecycle Hooks
295
+ # =============================================================================
296
+
297
+ def _run_hooks(event: str, task_json_path: Path, repo_root: Path) -> None:
298
+ """Run lifecycle hooks for an event.
299
+
300
+ Args:
301
+ event: Event name (e.g. "after_create").
302
+ task_json_path: Absolute path to the task's task.json.
303
+ repo_root: Repository root for cwd and config lookup.
304
+ """
305
+ import os
306
+ import subprocess
307
+
308
+ commands = get_hooks(event, repo_root)
309
+ if not commands:
310
+ return
311
+
312
+ env = {**os.environ, "TASK_JSON_PATH": str(task_json_path)}
313
+
314
+ for cmd in commands:
315
+ try:
316
+ result = subprocess.run(
317
+ cmd,
318
+ shell=True,
319
+ cwd=repo_root,
320
+ env=env,
321
+ capture_output=True,
322
+ text=True,
323
+ encoding="utf-8",
324
+ errors="replace",
325
+ )
326
+ if result.returncode != 0:
327
+ print(
328
+ colored(f"[WARN] Hook failed ({event}): {cmd}", Colors.YELLOW),
329
+ file=sys.stderr,
330
+ )
331
+ if result.stderr.strip():
332
+ print(f" {result.stderr.strip()}", file=sys.stderr)
333
+ except Exception as e:
334
+ print(
335
+ colored(f"[WARN] Hook error ({event}): {cmd} - {e}", Colors.YELLOW),
336
+ file=sys.stderr,
337
+ )
338
+
339
+
340
+ def _slugify(title: str) -> str:
341
+ """Convert title to slug (only works with ASCII)."""
342
+ result = title.lower()
343
+ result = re.sub(r"[^a-z0-9]", "-", result)
344
+ result = re.sub(r"-+", "-", result)
345
+ result = result.strip("-")
346
+ return result
347
+
348
+
349
+ def _resolve_task_dir(target_dir: str, repo_root: Path) -> Path:
350
+ """Resolve task directory to absolute path.
351
+
352
+ Supports:
353
+ - Absolute path: /path/to/task
354
+ - Relative path: .cowork-flow/tasks/01-31-my-task
355
+ - Task name: my-task (uses find_task_by_name for lookup)
356
+ """
357
+ if not target_dir:
358
+ return Path()
359
+
360
+ # Absolute path
361
+ if target_dir.startswith("/"):
362
+ return Path(target_dir)
363
+
364
+ # Relative path (contains path separator or starts with workflow directory)
365
+ if "/" in target_dir or target_dir.startswith(DIR_WORKFLOW):
366
+ return repo_root / target_dir
367
+
368
+ # Task name - try to find in tasks directory
369
+ tasks_dir = get_tasks_dir(repo_root)
370
+ found = find_task_by_name(target_dir, tasks_dir)
371
+ if found:
372
+ return found
373
+
374
+ # Fallback to treating as relative path
375
+ return repo_root / target_dir
376
+
377
+
378
+ # =============================================================================
379
+ # JSONL Default Content Generators
380
+ # =============================================================================
381
+
382
+ def get_implement_base() -> list[dict]:
383
+ """Get base implement context entries."""
384
+ return [
385
+ {"file": f"{DIR_WORKFLOW}/workflow.md", "reason": "Project workflow and conventions"},
386
+ ]
387
+
388
+
389
+ def get_implement_backend() -> list[dict]:
390
+ """Get backend implement context entries."""
391
+ return [
392
+ {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md", "reason": "Backend development guide"},
393
+ ]
394
+
395
+
396
+ def get_implement_frontend() -> list[dict]:
397
+ """Get frontend implement context entries."""
398
+ return [
399
+ {"file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md", "reason": "Frontend development guide"},
400
+ ]
401
+
402
+
403
+ def _skill_path(name: str) -> str:
404
+ return f"{DIR_AGENT}/skills/{name}/SKILL.md"
405
+
406
+
407
+ def get_check_context(dev_type: str, repo_root: Path) -> list[dict]:
408
+ """Get check context entries."""
409
+ entries = [
410
+ {"file": _skill_path("finish-work"), "reason": "Finish work checklist"},
411
+ ]
412
+
413
+ if dev_type in ("backend", "frontend", "fullstack"):
414
+ entries.append({"file": _skill_path("check-cross-layer"), "reason": "Cross-layer consistency check"})
415
+
416
+ return entries
417
+
418
+
419
+ def get_debug_context(dev_type: str, repo_root: Path) -> list[dict]:
420
+ """Get debug context entries."""
421
+ entries: list[dict] = [
422
+ {"file": _skill_path("break-loop"), "reason": "Deep bug analysis workflow"},
423
+ ]
424
+
425
+ if dev_type in ("backend", "frontend", "fullstack"):
426
+ entries.append({"file": _skill_path("check-cross-layer"), "reason": "Cross-layer consistency check"})
427
+
428
+ return entries
429
+
430
+
431
+ def _write_jsonl(path: Path, entries: list[dict]) -> None:
432
+ """Write entries to JSONL file."""
433
+ lines = [json.dumps(entry, ensure_ascii=False) for entry in entries]
434
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
435
+
436
+
437
+ def _iter_jsonl_lines(path: Path):
438
+ """Yield line number and content from a JSONL file."""
439
+ with path.open("r", encoding="utf-8") as f:
440
+ for line_num, line in enumerate(f, start=1):
441
+ yield line_num, line.rstrip("\n")
442
+
443
+
444
+ def _load_task_summary(task_dir: Path) -> dict:
445
+ """Load task fields needed for tree/list output."""
446
+ data = _read_json_file(task_dir / FILE_TASK_JSON) or {}
447
+ return {
448
+ "status": data.get("status", "unknown"),
449
+ "assignee": data.get("assignee", "-"),
450
+ "children": data.get("children", []),
451
+ "parent": data.get("parent"),
452
+ }
453
+
454
+
455
+ def _load_task_summaries(tasks_dir: Path) -> dict[str, dict]:
456
+ """Load active task summaries keyed by directory name."""
457
+ all_tasks: dict[str, dict] = {}
458
+ if not tasks_dir.is_dir():
459
+ return all_tasks
460
+
461
+ for d in sorted(tasks_dir.iterdir()):
462
+ if d.is_dir() and d.name != DIR_ARCHIVE:
463
+ all_tasks[d.name] = _load_task_summary(d)
464
+ return all_tasks
465
+
466
+
467
+ # =============================================================================
468
+ # Task Operations
469
+ # =============================================================================
470
+
471
+ def ensure_tasks_dir(repo_root: Path) -> Path:
472
+ """Ensure tasks directory exists."""
473
+ tasks_dir = get_tasks_dir(repo_root)
474
+ archive_dir = tasks_dir / "archive"
475
+
476
+ if not tasks_dir.exists():
477
+ tasks_dir.mkdir(parents=True)
478
+ print(colored(f"Created tasks directory: {tasks_dir}", Colors.GREEN), file=sys.stderr)
479
+
480
+ if not archive_dir.exists():
481
+ archive_dir.mkdir(parents=True)
482
+
483
+ return tasks_dir
484
+
485
+
486
+ # =============================================================================
487
+ # Command: create
488
+ # =============================================================================
489
+
490
+ def cmd_create(args: argparse.Namespace) -> int:
491
+ """Create a new task."""
492
+ repo_root = get_repo_root()
493
+
494
+ if not args.title:
495
+ print(colored("Error: title is required", Colors.RED), file=sys.stderr)
496
+ return 1
497
+
498
+ # Default assignee to current developer
499
+ assignee = args.assignee
500
+ if not assignee:
501
+ assignee = get_developer(repo_root)
502
+ if not assignee:
503
+ print(colored("Error: No developer set. Run init_developer.py first or use --assignee", Colors.RED), file=sys.stderr)
504
+ return 1
505
+
506
+ ensure_tasks_dir(repo_root)
507
+
508
+ # Get current developer as creator
509
+ creator = get_developer(repo_root) or assignee
510
+
511
+ # Generate slug if not provided
512
+ slug = args.slug or _slugify(args.title)
513
+ if not slug:
514
+ print(colored("Error: could not generate slug from title", Colors.RED), file=sys.stderr)
515
+ return 1
516
+
517
+ # Create task directory with MM-DD-slug format
518
+ tasks_dir = get_tasks_dir(repo_root)
519
+ date_prefix = generate_task_date_prefix()
520
+ dir_name = f"{date_prefix}-{slug}"
521
+ task_dir = tasks_dir / dir_name
522
+ task_json_path = task_dir / FILE_TASK_JSON
523
+
524
+ if task_dir.exists():
525
+ print(colored(f"Warning: Task directory already exists: {dir_name}", Colors.YELLOW), file=sys.stderr)
526
+ else:
527
+ task_dir.mkdir(parents=True)
528
+
529
+ today = datetime.now().strftime("%Y-%m-%d")
530
+
531
+ task_data = {
532
+ "id": slug,
533
+ "name": slug,
534
+ "title": args.title,
535
+ "description": args.description or "",
536
+ "status": "planning",
537
+ "dev_type": None,
538
+ "scope": None,
539
+ "priority": args.priority,
540
+ "creator": creator,
541
+ "assignee": assignee,
542
+ "createdAt": today,
543
+ "completedAt": None,
544
+ "commit": None,
545
+ "subtasks": [],
546
+ "children": [],
547
+ "parent": None,
548
+ "relatedFiles": [],
549
+ "notes": "",
550
+ "meta": {},
551
+ }
552
+
553
+ _write_json_file(task_json_path, task_data)
554
+
555
+ # Handle --parent: establish bidirectional link
556
+ if args.parent:
557
+ parent_dir = _resolve_task_dir(args.parent, repo_root)
558
+ parent_json_path = parent_dir / FILE_TASK_JSON
559
+ if not parent_json_path.is_file():
560
+ print(colored(f"Warning: Parent task.json not found: {args.parent}", Colors.YELLOW), file=sys.stderr)
561
+ else:
562
+ parent_data = _read_json_file(parent_json_path)
563
+ if parent_data:
564
+ # Add child to parent's children list
565
+ parent_children = parent_data.get("children", [])
566
+ if dir_name not in parent_children:
567
+ parent_children.append(dir_name)
568
+ parent_data["children"] = parent_children
569
+ _write_json_file(parent_json_path, parent_data)
570
+
571
+ # Set parent in child's task.json
572
+ task_data["parent"] = parent_dir.name
573
+ _write_json_file(task_json_path, task_data)
574
+
575
+ print(colored(f"Linked as child of: {parent_dir.name}", Colors.GREEN), file=sys.stderr)
576
+
577
+ print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
578
+ print("", file=sys.stderr)
579
+ print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
580
+ print(" 1. Create prd.md with requirements", file=sys.stderr)
581
+ print(" 2. Run: python3 task.py init-context <dir> <dev_type>", file=sys.stderr)
582
+ print(" 3. Run: python3 task.py start <dir>", file=sys.stderr)
583
+ print("", file=sys.stderr)
584
+
585
+ # Output relative path for script chaining
586
+ print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
587
+
588
+ _run_hooks("after_create", task_json_path, repo_root)
589
+ return 0
590
+
591
+
592
+ # =============================================================================
593
+ # Command: init-context
594
+ # =============================================================================
595
+
596
+ def cmd_init_context(args: argparse.Namespace) -> int:
597
+ """Initialize JSONL context files for a task."""
598
+ repo_root = get_repo_root()
599
+ target_dir = _resolve_task_dir(args.dir, repo_root)
600
+ dev_type = args.type
601
+
602
+ if not dev_type:
603
+ print(colored("Error: Missing arguments", Colors.RED))
604
+ print("Usage: python3 task.py init-context <task-dir> <dev_type>")
605
+ print(" dev_type: backend | frontend | fullstack | test | docs")
606
+ return 1
607
+
608
+ if not target_dir.is_dir():
609
+ print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
610
+ return 1
611
+
612
+ print(colored("=== Initializing Agent Context Files ===", Colors.BLUE))
613
+ print(f"Target dir: {target_dir}")
614
+ print(f"Dev type: {dev_type}")
615
+ print()
616
+
617
+ # implement.jsonl
618
+ print(colored("Creating implement.jsonl...", Colors.CYAN))
619
+ implement_entries = get_implement_base()
620
+ if dev_type in ("backend", "test"):
621
+ implement_entries.extend(get_implement_backend())
622
+ elif dev_type == "frontend":
623
+ implement_entries.extend(get_implement_frontend())
624
+ elif dev_type == "fullstack":
625
+ implement_entries.extend(get_implement_backend())
626
+ implement_entries.extend(get_implement_frontend())
627
+
628
+ implement_file = target_dir / "implement.jsonl"
629
+ _write_jsonl(implement_file, implement_entries)
630
+ print(f" {colored('[OK]', Colors.GREEN)} {len(implement_entries)} entries")
631
+
632
+ # check.jsonl
633
+ print(colored("Creating check.jsonl...", Colors.CYAN))
634
+ check_entries = get_check_context(dev_type, repo_root)
635
+ check_file = target_dir / "check.jsonl"
636
+ _write_jsonl(check_file, check_entries)
637
+ print(f" {colored('[OK]', Colors.GREEN)} {len(check_entries)} entries")
638
+
639
+ # debug.jsonl
640
+ print(colored("Creating debug.jsonl...", Colors.CYAN))
641
+ debug_entries = get_debug_context(dev_type, repo_root)
642
+ debug_file = target_dir / "debug.jsonl"
643
+ _write_jsonl(debug_file, debug_entries)
644
+ print(f" {colored('[OK]', Colors.GREEN)} {len(debug_entries)} entries")
645
+
646
+ print()
647
+ print(colored("[OK] All context files created", Colors.GREEN))
648
+ print()
649
+ print(colored("Next steps:", Colors.BLUE))
650
+ print(" 1. Add task-specific specs: python3 task.py add-context <dir> <jsonl> <path>")
651
+ print(" 2. Set as current: python3 task.py start <dir>")
652
+
653
+ return 0
654
+
655
+
656
+ # =============================================================================
657
+ # Command: add-context
658
+ # =============================================================================
659
+
660
+ def cmd_add_context(args: argparse.Namespace) -> int:
661
+ """Add entry to JSONL context file."""
662
+ repo_root = get_repo_root()
663
+ target_dir = _resolve_task_dir(args.dir, repo_root)
664
+
665
+ jsonl_name = args.file
666
+ path = args.path
667
+ reason = args.reason or "Added manually"
668
+
669
+ if not target_dir.is_dir():
670
+ print(colored(f"Error: Directory not found: {target_dir}", Colors.RED))
671
+ return 1
672
+
673
+ # Support shorthand
674
+ if not jsonl_name.endswith(".jsonl"):
675
+ jsonl_name = f"{jsonl_name}.jsonl"
676
+
677
+ jsonl_file = target_dir / jsonl_name
678
+ full_path = repo_root / path
679
+
680
+ entry_type = "file"
681
+ if full_path.is_dir():
682
+ entry_type = "directory"
683
+ if not path.endswith("/"):
684
+ path = f"{path}/"
685
+ elif not full_path.is_file():
686
+ print(colored(f"Error: Path not found: {path}", Colors.RED))
687
+ return 1
688
+
689
+ # Check if already exists
690
+ if jsonl_file.is_file():
691
+ for _, line in _iter_jsonl_lines(jsonl_file):
692
+ if f'"{path}"' in line:
693
+ print(colored(f"Warning: Entry already exists for {path}", Colors.YELLOW))
694
+ return 0
695
+
696
+ # Add entry
697
+ entry: dict
698
+ if entry_type == "directory":
699
+ entry = {"file": path, "type": "directory", "reason": reason}
700
+ else:
701
+ entry = {"file": path, "reason": reason}
702
+
703
+ with jsonl_file.open("a", encoding="utf-8") as f:
704
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
705
+
706
+ print(colored(f"Added {entry_type}: {path}", Colors.GREEN))
707
+ return 0
708
+
709
+
710
+ # =============================================================================
711
+ # Command: validate
712
+ # =============================================================================
713
+
714
+ def cmd_validate(args: argparse.Namespace) -> int:
715
+ """Validate JSONL context files."""
716
+ repo_root = get_repo_root()
717
+ target_dir = _resolve_task_dir(args.dir, repo_root)
718
+
719
+ if not target_dir.is_dir():
720
+ print(colored("Error: task directory required", Colors.RED))
721
+ return 1
722
+
723
+ print(colored("=== Validating Context Files ===", Colors.BLUE))
724
+ print(f"Target dir: {target_dir}")
725
+ print()
726
+
727
+ total_errors = 0
728
+ for jsonl_name in CONTEXT_JSONL_FILES:
729
+ jsonl_file = target_dir / jsonl_name
730
+ errors = _validate_jsonl(jsonl_file, repo_root)
731
+ total_errors += errors
732
+
733
+ print()
734
+ if total_errors == 0:
735
+ print(colored("[OK] All validations passed", Colors.GREEN))
736
+ return 0
737
+ else:
738
+ print(colored(f"[FAIL] Validation failed ({total_errors} errors)", Colors.RED))
739
+ return 1
740
+
741
+
742
+ def _validate_jsonl(jsonl_file: Path, repo_root: Path) -> int:
743
+ """Validate a single JSONL file."""
744
+ file_name = jsonl_file.name
745
+ errors = 0
746
+
747
+ if not jsonl_file.is_file():
748
+ print(f" {colored(f'{file_name}: not found (skipped)', Colors.YELLOW)}")
749
+ return 0
750
+
751
+ line_num = 0
752
+ for line_num, line in _iter_jsonl_lines(jsonl_file):
753
+ if not line.strip():
754
+ continue
755
+
756
+ try:
757
+ data = json.loads(line)
758
+ except json.JSONDecodeError:
759
+ print(f" {colored(f'{file_name}:{line_num}: Invalid JSON', Colors.RED)}")
760
+ errors += 1
761
+ continue
762
+
763
+ file_path = data.get("file")
764
+ entry_type = data.get("type", "file")
765
+
766
+ if not file_path:
767
+ print(f" {colored(f'{file_name}:{line_num}: Missing file field', Colors.RED)}")
768
+ errors += 1
769
+ continue
770
+
771
+ full_path = repo_root / file_path
772
+ if entry_type == "directory":
773
+ if not full_path.is_dir():
774
+ print(f" {colored(f'{file_name}:{line_num}: Directory not found: {file_path}', Colors.RED)}")
775
+ errors += 1
776
+ else:
777
+ if not full_path.is_file():
778
+ print(f" {colored(f'{file_name}:{line_num}: File not found: {file_path}', Colors.RED)}")
779
+ errors += 1
780
+
781
+ if errors == 0:
782
+ print(f" {colored(f'{file_name}: [OK] ({line_num} entries)', Colors.GREEN)}")
783
+ else:
784
+ print(f" {colored(f'{file_name}: [FAIL] ({errors} errors)', Colors.RED)}")
785
+
786
+ return errors
787
+
788
+
789
+ # =============================================================================
790
+ # Command: list-context
791
+ # =============================================================================
792
+
793
+ def cmd_list_context(args: argparse.Namespace) -> int:
794
+ """List JSONL context entries."""
795
+ repo_root = get_repo_root()
796
+ target_dir = _resolve_task_dir(args.dir, repo_root)
797
+
798
+ if not target_dir.is_dir():
799
+ print(colored("Error: task directory required", Colors.RED))
800
+ return 1
801
+
802
+ print(colored("=== Context Files ===", Colors.BLUE))
803
+ print()
804
+
805
+ for jsonl_name in CONTEXT_JSONL_FILES:
806
+ jsonl_file = target_dir / jsonl_name
807
+ if not jsonl_file.is_file():
808
+ continue
809
+
810
+ print(colored(f"[{jsonl_name}]", Colors.CYAN))
811
+
812
+ count = 0
813
+ for _, line in _iter_jsonl_lines(jsonl_file):
814
+ if not line.strip():
815
+ continue
816
+
817
+ try:
818
+ data = json.loads(line)
819
+ except json.JSONDecodeError:
820
+ continue
821
+
822
+ count += 1
823
+ file_path = data.get("file", "?")
824
+ entry_type = data.get("type", "file")
825
+ reason = data.get("reason", "-")
826
+
827
+ if entry_type == "directory":
828
+ print(f" {colored(f'{count}.', Colors.GREEN)} [DIR] {file_path}")
829
+ else:
830
+ print(f" {colored(f'{count}.', Colors.GREEN)} {file_path}")
831
+ print(f" {colored('->', Colors.YELLOW)} {reason}")
832
+
833
+ print()
834
+
835
+ return 0
836
+
837
+
838
+ # =============================================================================
839
+ # Command: start / finish
840
+ # =============================================================================
841
+
842
+ def cmd_start(args: argparse.Namespace) -> int:
843
+ """Set current task."""
844
+ repo_root = get_repo_root()
845
+ task_input = args.dir
846
+
847
+ if not task_input:
848
+ print(colored("Error: task directory or name required", Colors.RED))
849
+ return 1
850
+
851
+ # Resolve task directory (supports task name, relative path, or absolute path)
852
+ full_path = _resolve_task_dir(task_input, repo_root)
853
+
854
+ if not full_path.is_dir():
855
+ print(colored(f"Error: Task not found: {task_input}", Colors.RED))
856
+ print(f"Hint: Use task name (e.g., 'my-task') or full path (e.g., '{DIR_WORKFLOW}/tasks/01-31-my-task')")
857
+ return 1
858
+
859
+ # Convert to relative path for storage
860
+ try:
861
+ task_dir = str(full_path.relative_to(repo_root))
862
+ except ValueError:
863
+ task_dir = str(full_path)
864
+
865
+ if set_current_task(task_dir, repo_root):
866
+ print(colored(f"[OK] Current task set to: {task_dir}", Colors.GREEN))
867
+ print()
868
+ print(colored("The hook will now inject context from this task's jsonl files.", Colors.BLUE))
869
+
870
+ task_json_path = full_path / FILE_TASK_JSON
871
+ _run_hooks("after_start", task_json_path, repo_root)
872
+ return 0
873
+ else:
874
+ print(colored("Error: Failed to set current task", Colors.RED))
875
+ return 1
876
+
877
+
878
+ def cmd_finish(args: argparse.Namespace) -> int:
879
+ """Clear current task."""
880
+ repo_root = get_repo_root()
881
+ current = get_current_task(repo_root)
882
+
883
+ if not current:
884
+ print(colored("No current task set", Colors.YELLOW))
885
+ return 0
886
+
887
+ # Resolve task.json path before clearing
888
+ task_json_path = repo_root / current / FILE_TASK_JSON
889
+
890
+ if not _clear_current_task_or_report(repo_root, current):
891
+ return 1
892
+
893
+ print(colored(f"[OK] Cleared current task (was: {current})", Colors.GREEN))
894
+
895
+ if task_json_path.is_file():
896
+ _run_hooks("after_finish", task_json_path, repo_root)
897
+ return 0
898
+
899
+
900
+ # =============================================================================
901
+ # Command: archive
902
+ # =============================================================================
903
+
904
+ def cmd_archive(args: argparse.Namespace) -> int:
905
+ """Archive completed task."""
906
+ repo_root = get_repo_root()
907
+ task_name = args.name
908
+
909
+ if not task_name:
910
+ print(colored("Error: Task name is required", Colors.RED), file=sys.stderr)
911
+ return 1
912
+
913
+ tasks_dir = get_tasks_dir(repo_root)
914
+
915
+ # Find task directory
916
+ task_dir = find_task_by_name(task_name, tasks_dir)
917
+
918
+ if not task_dir or not task_dir.is_dir():
919
+ print(colored(f"Error: Task not found: {task_name}", Colors.RED), file=sys.stderr)
920
+ print("Active tasks:", file=sys.stderr)
921
+ cmd_list(argparse.Namespace(mine=False, status=None))
922
+ return 1
923
+
924
+ dir_name = task_dir.name
925
+ task_json_path = task_dir / FILE_TASK_JSON
926
+
927
+ today = datetime.now().strftime("%Y-%m-%d")
928
+ task_data = None
929
+ if task_json_path.is_file():
930
+ task_data = _read_json_file(task_json_path)
931
+
932
+ # Clear if current task
933
+ cleared_current_task: str | None = None
934
+ current = get_current_task(repo_root)
935
+ if current and _is_current_task_dir(current, dir_name):
936
+ if not _clear_current_task_or_report(repo_root, current):
937
+ return 1
938
+ cleared_current_task = current
939
+
940
+ # Archive
941
+ result = archive_task_complete(task_dir, repo_root)
942
+ if "archived_to" not in result:
943
+ if cleared_current_task:
944
+ _restore_current_task_or_report(repo_root, cleared_current_task)
945
+ return 1
946
+
947
+ archive_dest = Path(result["archived_to"])
948
+ if not _finalize_archived_task_metadata(
949
+ archive_dest,
950
+ task_data,
951
+ tasks_dir,
952
+ dir_name,
953
+ today,
954
+ ):
955
+ _rollback_archived_task_or_report(
956
+ task_dir,
957
+ archive_dest,
958
+ task_data,
959
+ repo_root,
960
+ current_task=cleared_current_task,
961
+ )
962
+ return 1
963
+
964
+ if "archived_to" in result:
965
+ archived_json = archive_dest / FILE_TASK_JSON
966
+ year_month = archive_dest.parent.name
967
+ print(colored(f"Archived: {dir_name} -> archive/{year_month}/", Colors.GREEN), file=sys.stderr)
968
+
969
+ # Auto-commit unless --no-commit
970
+ if not getattr(args, "no_commit", False):
971
+ _auto_commit_archive(dir_name, repo_root)
972
+
973
+ # Return the archive path
974
+ print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/{year_month}/{dir_name}")
975
+
976
+ # Run hooks with the archived path
977
+ _run_hooks("after_archive", archived_json, repo_root)
978
+ return 0
979
+
980
+ return 1
981
+
982
+
983
+ def _auto_commit_archive(task_name: str, repo_root: Path) -> None:
984
+ """Stage .cowork-flow/tasks/ changes and commit after archive."""
985
+ tasks_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}"
986
+ _run_git_command(["add", "-A", tasks_rel], cwd=repo_root)
987
+
988
+ # Check if there are staged changes
989
+ rc, _, _ = _run_git_command(
990
+ ["diff", "--cached", "--quiet", "--", tasks_rel], cwd=repo_root
991
+ )
992
+ if rc == 0:
993
+ print("[OK] No task changes to commit.", file=sys.stderr)
994
+ return
995
+
996
+ commit_msg = f"chore(task): archive {task_name}"
997
+ rc, _, err = _run_git_command(["commit", "-m", commit_msg], cwd=repo_root)
998
+ if rc == 0:
999
+ print(f"[OK] Auto-committed: {commit_msg}", file=sys.stderr)
1000
+ else:
1001
+ print(f"[WARN] Auto-commit failed: {err.strip()}", file=sys.stderr)
1002
+
1003
+
1004
+ # =============================================================================
1005
+ # Command: add-subtask
1006
+ # =============================================================================
1007
+
1008
+ def cmd_add_subtask(args: argparse.Namespace) -> int:
1009
+ """Link a child task to a parent task."""
1010
+ repo_root = get_repo_root()
1011
+
1012
+ parent_dir = _resolve_task_dir(args.parent_dir, repo_root)
1013
+ child_dir = _resolve_task_dir(args.child_dir, repo_root)
1014
+
1015
+ parent_json_path = parent_dir / FILE_TASK_JSON
1016
+ child_json_path = child_dir / FILE_TASK_JSON
1017
+
1018
+ if not parent_json_path.is_file():
1019
+ print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
1020
+ return 1
1021
+
1022
+ if not child_json_path.is_file():
1023
+ print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
1024
+ return 1
1025
+
1026
+ parent_data = _read_json_file(parent_json_path)
1027
+ child_data = _read_json_file(child_json_path)
1028
+
1029
+ if not parent_data or not child_data:
1030
+ print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
1031
+ return 1
1032
+
1033
+ # Check if child already has a parent
1034
+ existing_parent = child_data.get("parent")
1035
+ if existing_parent:
1036
+ print(colored(f"Error: Child task already has a parent: {existing_parent}", Colors.RED), file=sys.stderr)
1037
+ return 1
1038
+
1039
+ # Add child to parent's children list
1040
+ parent_children = parent_data.get("children", [])
1041
+ child_dir_name = child_dir.name
1042
+ if child_dir_name not in parent_children:
1043
+ parent_children.append(child_dir_name)
1044
+ parent_data["children"] = parent_children
1045
+
1046
+ # Set parent in child's task.json
1047
+ child_data["parent"] = parent_dir.name
1048
+
1049
+ # Write both
1050
+ _write_json_file(parent_json_path, parent_data)
1051
+ _write_json_file(child_json_path, child_data)
1052
+
1053
+ print(colored(f"Linked: {child_dir.name} -> {parent_dir.name}", Colors.GREEN), file=sys.stderr)
1054
+ return 0
1055
+
1056
+
1057
+ # =============================================================================
1058
+ # Command: remove-subtask
1059
+ # =============================================================================
1060
+
1061
+ def cmd_remove_subtask(args: argparse.Namespace) -> int:
1062
+ """Unlink a child task from a parent task."""
1063
+ repo_root = get_repo_root()
1064
+
1065
+ parent_dir = _resolve_task_dir(args.parent_dir, repo_root)
1066
+ child_dir = _resolve_task_dir(args.child_dir, repo_root)
1067
+
1068
+ parent_json_path = parent_dir / FILE_TASK_JSON
1069
+ child_json_path = child_dir / FILE_TASK_JSON
1070
+
1071
+ if not parent_json_path.is_file():
1072
+ print(colored(f"Error: Parent task.json not found: {args.parent_dir}", Colors.RED), file=sys.stderr)
1073
+ return 1
1074
+
1075
+ if not child_json_path.is_file():
1076
+ print(colored(f"Error: Child task.json not found: {args.child_dir}", Colors.RED), file=sys.stderr)
1077
+ return 1
1078
+
1079
+ parent_data = _read_json_file(parent_json_path)
1080
+ child_data = _read_json_file(child_json_path)
1081
+
1082
+ if not parent_data or not child_data:
1083
+ print(colored("Error: Failed to read task.json", Colors.RED), file=sys.stderr)
1084
+ return 1
1085
+
1086
+ # Remove child from parent's children list
1087
+ parent_children = parent_data.get("children", [])
1088
+ child_dir_name = child_dir.name
1089
+ if child_dir_name in parent_children:
1090
+ parent_children.remove(child_dir_name)
1091
+ parent_data["children"] = parent_children
1092
+
1093
+ # Clear parent in child's task.json
1094
+ child_data["parent"] = None
1095
+
1096
+ # Write both
1097
+ _write_json_file(parent_json_path, parent_data)
1098
+ _write_json_file(child_json_path, child_data)
1099
+
1100
+ print(colored(f"Unlinked: {child_dir.name} from {parent_dir.name}", Colors.GREEN), file=sys.stderr)
1101
+ return 0
1102
+
1103
+
1104
+ # =============================================================================
1105
+ # Command: list
1106
+ # =============================================================================
1107
+
1108
+ def _get_children_progress(children: list[str], all_tasks: dict[str, dict]) -> str:
1109
+ """Get children progress summary like '[2/3 done]'."""
1110
+ if not children:
1111
+ return ""
1112
+ done_count = 0
1113
+ total = len(children)
1114
+ for child_name in children:
1115
+ status = all_tasks.get(child_name, {}).get("status", "")
1116
+ if status in DONE_STATUSES:
1117
+ done_count += 1
1118
+ return f" [{done_count}/{total} done]"
1119
+
1120
+
1121
+ def cmd_list(args: argparse.Namespace) -> int:
1122
+ """List active tasks."""
1123
+ repo_root = get_repo_root()
1124
+ tasks_dir = get_tasks_dir(repo_root)
1125
+ current_task = get_current_task(repo_root)
1126
+ developer = get_developer(repo_root)
1127
+ filter_mine = args.mine
1128
+ filter_status = args.status
1129
+
1130
+ if filter_mine:
1131
+ if not developer:
1132
+ print(colored("Error: No developer set. Run init_developer.py first", Colors.RED), file=sys.stderr)
1133
+ return 1
1134
+ print(colored(f"My tasks (assignee: {developer}):", Colors.BLUE))
1135
+ else:
1136
+ print(colored("All active tasks:", Colors.BLUE))
1137
+ print()
1138
+
1139
+ all_tasks = _load_task_summaries(tasks_dir)
1140
+
1141
+ # Second pass: display tasks hierarchically
1142
+ count = 0
1143
+
1144
+ def _print_task(dir_name: str, indent: int = 0) -> None:
1145
+ nonlocal count
1146
+ info = all_tasks[dir_name]
1147
+ status = info["status"]
1148
+ assignee = info["assignee"]
1149
+ children = info["children"]
1150
+
1151
+ # Apply --mine filter
1152
+ if filter_mine and assignee != developer:
1153
+ return
1154
+
1155
+ # Apply --status filter
1156
+ if filter_status and status != filter_status:
1157
+ return
1158
+
1159
+ relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
1160
+ marker = ""
1161
+ if relative_path == current_task:
1162
+ marker = f" {colored('<- current', Colors.GREEN)}"
1163
+
1164
+ # Children progress
1165
+ progress = _get_children_progress(children, all_tasks) if children else ""
1166
+
1167
+ prefix = " " * indent + " - "
1168
+
1169
+ if filter_mine:
1170
+ print(f"{prefix}{dir_name}/ ({status}){progress}{marker}")
1171
+ else:
1172
+ print(f"{prefix}{dir_name}/ ({status}){progress} [{colored(assignee, Colors.CYAN)}]{marker}")
1173
+ count += 1
1174
+
1175
+ # Print children indented
1176
+ for child_name in children:
1177
+ if child_name in all_tasks:
1178
+ _print_task(child_name, indent + 1)
1179
+
1180
+ # Display only top-level tasks (those without a parent)
1181
+ for dir_name in sorted(all_tasks.keys()):
1182
+ info = all_tasks[dir_name]
1183
+ if not info["parent"]:
1184
+ _print_task(dir_name)
1185
+
1186
+ if count == 0:
1187
+ if filter_mine:
1188
+ print(" (no tasks assigned to you)")
1189
+ else:
1190
+ print(" (no active tasks)")
1191
+
1192
+ print()
1193
+ print(f"Total: {count} task(s)")
1194
+ return 0
1195
+
1196
+
1197
+ # =============================================================================
1198
+ # Command: list-archive
1199
+ # =============================================================================
1200
+
1201
+ def cmd_list_archive(args: argparse.Namespace) -> int:
1202
+ """List archived tasks."""
1203
+ repo_root = get_repo_root()
1204
+ tasks_dir = get_tasks_dir(repo_root)
1205
+ archive_dir = tasks_dir / "archive"
1206
+ month = args.month
1207
+
1208
+ print(colored("Archived tasks:", Colors.BLUE))
1209
+ print()
1210
+
1211
+ if month:
1212
+ month_dir = archive_dir / month
1213
+ if month_dir.is_dir():
1214
+ print(f"[{month}]")
1215
+ for d in sorted(month_dir.iterdir()):
1216
+ if d.is_dir():
1217
+ print(f" - {d.name}/")
1218
+ else:
1219
+ print(f" No archives for {month}")
1220
+ else:
1221
+ if archive_dir.is_dir():
1222
+ for month_dir in sorted(archive_dir.iterdir()):
1223
+ if month_dir.is_dir():
1224
+ month_name = month_dir.name
1225
+ count = sum(1 for d in month_dir.iterdir() if d.is_dir())
1226
+ print(f"[{month_name}] - {count} task(s)")
1227
+
1228
+ return 0
1229
+
1230
+
1231
+ # =============================================================================
1232
+ # Help
1233
+ # =============================================================================
1234
+
1235
+ def show_usage() -> None:
1236
+ """Show usage help."""
1237
+ print("""Task Management Script for cowork-flow workflow
1238
+
1239
+ Usage:
1240
+ python3 task.py create <title> Create new task directory
1241
+ python3 task.py create <title> --parent <dir> Create task as child of parent
1242
+ python3 task.py init-context <dir> <dev_type> Initialize jsonl files
1243
+ python3 task.py add-context <dir> <jsonl> <path> [reason] Add entry to jsonl
1244
+ python3 task.py validate <dir> Validate jsonl files
1245
+ python3 task.py list-context <dir> List jsonl entries
1246
+ python3 task.py start <dir> Set as current task
1247
+ python3 task.py finish Clear current task
1248
+ python3 task.py archive <task-name> Archive completed task
1249
+ python3 task.py add-subtask <parent> <child> Link child task to parent
1250
+ python3 task.py remove-subtask <parent> <child> Unlink child from parent
1251
+ python3 task.py list [--mine] [--status <status>] List tasks
1252
+ python3 task.py list-archive [YYYY-MM] List archived tasks
1253
+
1254
+ Arguments:
1255
+ dev_type: backend | frontend | fullstack | test | docs
1256
+
1257
+ List options:
1258
+ --mine, -m Show only tasks assigned to current developer
1259
+ --status, -s <s> Filter by status (planning, in_progress, review, completed)
1260
+
1261
+ Examples:
1262
+ python3 task.py create "Add login feature" --slug add-login
1263
+ python3 task.py create "Child task" --slug child --parent .cowork-flow/tasks/01-21-parent
1264
+ python3 task.py init-context .cowork-flow/tasks/01-21-add-login backend
1265
+ python3 task.py add-context <dir> implement .cowork-flow/spec/backend/auth.md "Auth guidelines"
1266
+ python3 task.py start .cowork-flow/tasks/01-21-add-login
1267
+ python3 task.py finish
1268
+ python3 task.py archive add-login
1269
+ python3 task.py add-subtask parent-task child-task # Link existing tasks
1270
+ python3 task.py remove-subtask parent-task child-task
1271
+ python3 task.py list # List all active tasks
1272
+ python3 task.py list --mine # List my tasks only
1273
+ python3 task.py list --mine --status in_progress # List my in-progress tasks
1274
+ """)
1275
+
1276
+
1277
+ # =============================================================================
1278
+ # Main Entry
1279
+ # =============================================================================
1280
+
1281
+ def main() -> int:
1282
+ """CLI entry point."""
1283
+ parser = argparse.ArgumentParser(
1284
+ description="Task Management Script for cowork-flow workflow",
1285
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1286
+ )
1287
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
1288
+
1289
+ # create
1290
+ p_create = subparsers.add_parser("create", help="Create new task")
1291
+ p_create.add_argument("title", help="Task title")
1292
+ p_create.add_argument("--slug", "-s", help="Task slug")
1293
+ p_create.add_argument("--assignee", "-a", help="Assignee developer")
1294
+ p_create.add_argument("--priority", "-p", default="P2", help="Priority (P0-P3)")
1295
+ p_create.add_argument("--description", "-d", help="Task description")
1296
+ p_create.add_argument("--parent", help="Parent task directory (establishes subtask link)")
1297
+
1298
+ # init-context
1299
+ p_init = subparsers.add_parser("init-context", help="Initialize context files")
1300
+ p_init.add_argument("dir", help="Task directory")
1301
+ p_init.add_argument("type", help="Dev type: backend|frontend|fullstack|test|docs")
1302
+
1303
+ # add-context
1304
+ p_add = subparsers.add_parser("add-context", help="Add context entry")
1305
+ p_add.add_argument("dir", help="Task directory")
1306
+ p_add.add_argument("file", help="JSONL file (implement|check|debug)")
1307
+ p_add.add_argument("path", help="File path to add")
1308
+ p_add.add_argument("reason", nargs="?", help="Reason for adding")
1309
+
1310
+ # validate
1311
+ p_validate = subparsers.add_parser("validate", help="Validate context files")
1312
+ p_validate.add_argument("dir", help="Task directory")
1313
+
1314
+ # list-context
1315
+ p_listctx = subparsers.add_parser("list-context", help="List context entries")
1316
+ p_listctx.add_argument("dir", help="Task directory")
1317
+
1318
+ # start
1319
+ p_start = subparsers.add_parser("start", help="Set current task")
1320
+ p_start.add_argument("dir", help="Task directory")
1321
+
1322
+ # finish
1323
+ subparsers.add_parser("finish", help="Clear current task")
1324
+
1325
+ # archive
1326
+ p_archive = subparsers.add_parser("archive", help="Archive task")
1327
+ p_archive.add_argument("name", help="Task name")
1328
+ p_archive.add_argument("--no-commit", action="store_true", help="Skip auto git commit after archive")
1329
+
1330
+ # list
1331
+ p_list = subparsers.add_parser("list", help="List tasks")
1332
+ p_list.add_argument("--mine", "-m", action="store_true", help="My tasks only")
1333
+ p_list.add_argument("--status", "-s", help="Filter by status")
1334
+
1335
+ # add-subtask
1336
+ p_addsub = subparsers.add_parser("add-subtask", help="Link child task to parent")
1337
+ p_addsub.add_argument("parent_dir", help="Parent task directory")
1338
+ p_addsub.add_argument("child_dir", help="Child task directory")
1339
+
1340
+ # remove-subtask
1341
+ p_rmsub = subparsers.add_parser("remove-subtask", help="Unlink child task from parent")
1342
+ p_rmsub.add_argument("parent_dir", help="Parent task directory")
1343
+ p_rmsub.add_argument("child_dir", help="Child task directory")
1344
+
1345
+ # list-archive
1346
+ p_listarch = subparsers.add_parser("list-archive", help="List archived tasks")
1347
+ p_listarch.add_argument("month", nargs="?", help="Month (YYYY-MM)")
1348
+
1349
+ args = parser.parse_args()
1350
+
1351
+ if not args.command:
1352
+ show_usage()
1353
+ return 1
1354
+
1355
+ commands = {
1356
+ "create": cmd_create,
1357
+ "init-context": cmd_init_context,
1358
+ "add-context": cmd_add_context,
1359
+ "validate": cmd_validate,
1360
+ "list-context": cmd_list_context,
1361
+ "start": cmd_start,
1362
+ "finish": cmd_finish,
1363
+ "archive": cmd_archive,
1364
+ "add-subtask": cmd_add_subtask,
1365
+ "remove-subtask": cmd_remove_subtask,
1366
+ "list": cmd_list,
1367
+ "list-archive": cmd_list_archive,
1368
+ }
1369
+
1370
+ if args.command in commands:
1371
+ return commands[args.command](args)
1372
+ else:
1373
+ show_usage()
1374
+ return 1
1375
+
1376
+
1377
+ if __name__ == "__main__":
1378
+ sys.exit(main())