cowork-flow 0.0.46 → 0.0.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.46",
3
+ "version": "0.0.47",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 0.0.46
1
+ 0.0.47
@@ -5,7 +5,6 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import argparse
8
- import json
9
8
  import sys
10
9
  from datetime import datetime
11
10
  from pathlib import Path
@@ -48,6 +47,11 @@ from adapters.cli.task_context_commands import (
48
47
  from adapters.cli.task_create_command import cmd_create, ensure_tasks_dir
49
48
  from adapters.cli import task_navigation
50
49
  from adapters.cli.task_parser import build_parser, show_usage
50
+ from adapters.cli.task_next_runner import (
51
+ NextActionHandlers,
52
+ namespace_with as _args_with,
53
+ run_next_action,
54
+ )
51
55
  from adapters.cli.task_support import (
52
56
  Colors,
53
57
  colored,
@@ -57,6 +61,7 @@ from adapters.cli.task_support import (
57
61
  from adapters.cli.task_tree_commands import (
58
62
  cmd_add_subtask,
59
63
  cmd_list,
64
+ cmd_list_json,
60
65
  cmd_list_archive,
61
66
  cmd_remove_subtask,
62
67
  )
@@ -537,189 +542,6 @@ def cmd_current(args: argparse.Namespace) -> int:
537
542
  return 0
538
543
 
539
544
 
540
- def _next_target_for_run(args: argparse.Namespace, repo_root: Path):
541
- target_input = getattr(args, "dir", None)
542
- if target_input:
543
- task_dir = _resolve_task_dir(target_input, repo_root)
544
- return task_dir, _display_task_path(repo_root, task_dir), False
545
-
546
- active = get_active_task(repo_root)
547
- if active.task_path:
548
- task_dir = repo_root / active.task_path
549
- return task_dir, active.task_path, True
550
- return None, None, False
551
-
552
-
553
- def _next_payload_for_run(args: argparse.Namespace, repo_root: Path) -> dict[str, object]:
554
- task_dir, task_path, active_target = _next_target_for_run(args, repo_root)
555
- if task_dir is None or task_path is None:
556
- return task_navigation.build_navigation_payload(
557
- args=args,
558
- status="no_task",
559
- blockers=[],
560
- active_target=False,
561
- task_path=None,
562
- )
563
- if not task_dir.is_dir():
564
- return task_navigation.build_navigation_payload(
565
- args=args,
566
- status="stale",
567
- blockers=[f"task directory not found: {task_path}"],
568
- active_target=active_target,
569
- task_path=task_path,
570
- )
571
- status = task_navigation._status(repo_root, task_dir)
572
- blockers = task_navigation._blockers(repo_root, task_dir) if status == "planning" else []
573
- return task_navigation.build_navigation_payload(
574
- args=args,
575
- status=status,
576
- blockers=blockers,
577
- active_target=active_target,
578
- task_path=task_path,
579
- )
580
-
581
-
582
- def _args_with(args: argparse.Namespace, **overrides) -> argparse.Namespace:
583
- values = vars(args).copy()
584
- values.update(overrides)
585
- return argparse.Namespace(**values)
586
-
587
-
588
- def _run_create_action(args: argparse.Namespace, action: dict[str, object]) -> int:
589
- if not getattr(args, "title", None):
590
- print(
591
- colored("Error: create_task requires --title", Colors.RED),
592
- file=sys.stderr,
593
- )
594
- print(f"Command: {action.get('command')}", file=sys.stderr)
595
- return 1
596
- return cmd_create(
597
- _args_with(
598
- args,
599
- title=args.title,
600
- slug=getattr(args, "slug", None),
601
- assignee=getattr(args, "assignee", None),
602
- priority=getattr(args, "priority", "P2"),
603
- description=getattr(args, "description", None),
604
- parent=getattr(args, "parent", None),
605
- from_plan=getattr(args, "from_plan", None),
606
- )
607
- )
608
-
609
-
610
- def _create_input_names(args: argparse.Namespace) -> list[str]:
611
- names = (
612
- "title",
613
- "slug",
614
- "assignee",
615
- "description",
616
- "parent",
617
- "from_plan",
618
- )
619
- return [name for name in names if getattr(args, name, None)]
620
-
621
-
622
- def _validated_next_action(args: argparse.Namespace) -> tuple[dict[str, object] | None, int]:
623
- payload = _next_payload_for_run(args, get_repo_root())
624
- action = payload.get("action")
625
- if not isinstance(action, dict):
626
- print(
627
- colored("Error: task next did not return an action", Colors.RED),
628
- file=sys.stderr,
629
- )
630
- return None, 1
631
- create_inputs = _create_input_names(args)
632
- action_id = action.get("id")
633
- if create_inputs and action_id != "create_task":
634
- print(
635
- colored(
636
- f"Error: create_task inputs cannot run {action_id}",
637
- Colors.RED,
638
- ),
639
- file=sys.stderr,
640
- )
641
- print(
642
- "Hint: archive or finish the current task first, then rerun "
643
- "`task next --run --title ... --slug ... --assignee <name>`.",
644
- file=sys.stderr,
645
- )
646
- return None, 1
647
- if payload.get("blockers") or action.get("blockers"):
648
- print(colored("Error: next action is blocked", Colors.RED), file=sys.stderr)
649
- for blocker in payload.get("blockers", []) or action.get("blockers", []):
650
- print(f" - {blocker}", file=sys.stderr)
651
- return None, 1
652
- if not action.get("runnable"):
653
- action_id = action.get("id")
654
- print(
655
- colored(f"Error: next action is not executable: {action_id}", Colors.RED),
656
- file=sys.stderr,
657
- )
658
- if action.get("command"):
659
- print(f"Command: {action['command']}", file=sys.stderr)
660
- return None, 1
661
- return action, 0
662
-
663
-
664
- def _resolved_next_action_task(args: argparse.Namespace, action_id: object) -> str | None:
665
- if action_id == "create_task":
666
- return None
667
- _, task_path_value, _ = _next_target_for_run(args, get_repo_root())
668
- if task_path_value is None:
669
- print(colored("Error: no task target for action", Colors.RED), file=sys.stderr)
670
- return None
671
- return task_path_value
672
-
673
-
674
- def _dispatch_next_lifecycle_action(
675
- args: argparse.Namespace,
676
- action_id: object,
677
- task_path: str | None,
678
- action: dict[str, object],
679
- ) -> int:
680
- if action_id == "create_task":
681
- return _run_create_action(args, action)
682
- if task_path is None:
683
- return 1
684
- if action_id == "start_task":
685
- return cmd_start(_args_with(
686
- args,
687
- dir=task_path,
688
- auto=bool(getattr(args, "auto", False)),
689
- approved=bool(getattr(args, "approved", False)),
690
- ))
691
- if action_id == "request_review":
692
- return cmd_review(_args_with(args, dir=task_path))
693
- if action_id == "complete_task":
694
- return cmd_complete(_args_with(args, dir=task_path))
695
- if action_id == "archive_task":
696
- return cmd_archive(_args_with(
697
- args,
698
- name=Path(str(task_path)).name,
699
- commit=bool(getattr(args, "commit", False)),
700
- ))
701
- if action_id == "batch_execute":
702
- return cmd_start(_args_with(
703
- args,
704
- dir=task_path,
705
- auto=True,
706
- approved=bool(getattr(args, "approved", False)),
707
- ))
708
- print(
709
- colored(f"Error: unsupported next action: {action_id}", Colors.RED),
710
- file=sys.stderr,
711
- )
712
- return 1
713
-
714
-
715
- def _run_next_action(args: argparse.Namespace) -> int:
716
- action, error_code = _validated_next_action(args)
717
- if action is None:
718
- return error_code
719
- action_id = action.get("id")
720
- task_path = _resolved_next_action_task(args, action_id)
721
- return _dispatch_next_lifecycle_action(args, action_id, task_path, action)
722
-
723
545
  def cmd_next(args: argparse.Namespace) -> int:
724
546
  """Show or run the next workflow action."""
725
547
  if getattr(args, "list_tasks", False):
@@ -729,6 +551,14 @@ def cmd_next(args: argparse.Namespace) -> int:
729
551
  file=sys.stderr,
730
552
  )
731
553
  return 1
554
+ if getattr(args, "json", False):
555
+ return cmd_list_json(
556
+ _args_with(
557
+ args,
558
+ mine=bool(getattr(args, "mine", False)),
559
+ status=getattr(args, "status", None),
560
+ )
561
+ )
732
562
  return cmd_list(
733
563
  _args_with(
734
564
  args,
@@ -751,7 +581,16 @@ def cmd_next(args: argparse.Namespace) -> int:
751
581
  return 1
752
582
  return cmd_validate(_args_with(args, dir=args.dir))
753
583
  if getattr(args, "run", False):
754
- return _run_next_action(args)
584
+ return run_next_action(
585
+ args,
586
+ NextActionHandlers(
587
+ create=cmd_create,
588
+ start=cmd_start,
589
+ review=cmd_review,
590
+ complete=cmd_complete,
591
+ archive=cmd_archive,
592
+ ),
593
+ )
755
594
  return task_navigation.cmd_next(args)
756
595
 
757
596
 
@@ -18,6 +18,13 @@ from services.task_repository import TaskRepository, TaskRepositoryError
18
18
  from services.task_routing import _default_intent, route_request
19
19
 
20
20
 
21
+ RECOVERABLE_TASK_STATUSES = {
22
+ "in_progress": "active_unbound",
23
+ "review": "active_unbound",
24
+ "completed": "completed_unarchived",
25
+ }
26
+
27
+
21
28
 
22
29
  def _display(repo_root: Path, task_dir: Path) -> str:
23
30
  try:
@@ -41,6 +48,61 @@ def _blockers(repo_root: Path, task_dir: Path) -> list[str]:
41
48
  return blockers
42
49
 
43
50
 
51
+ def repository_recovery_signals(repo_root: Path) -> list[dict[str, str]]:
52
+ tasks_dir = repo_root / ".cowork-flow" / "tasks"
53
+ if not tasks_dir.is_dir():
54
+ return []
55
+ signals: list[dict[str, str]] = []
56
+ for task_dir in sorted(tasks_dir.iterdir()):
57
+ if not task_dir.is_dir() or task_dir.name == "archive":
58
+ continue
59
+ status = _status(repo_root, task_dir)
60
+ kind = RECOVERABLE_TASK_STATUSES.get(status)
61
+ if kind is None:
62
+ continue
63
+ task = _display(repo_root, task_dir)
64
+ hint = f"./.cowork-flow/run task next {task} --run"
65
+ if kind == "completed_unarchived":
66
+ hint = f"{hint} --intent archive"
67
+ signals.append(
68
+ {
69
+ "kind": kind,
70
+ "task": task,
71
+ "status": status,
72
+ "hint": hint,
73
+ }
74
+ )
75
+ return signals
76
+
77
+
78
+ def _attach_recovery(payload: dict[str, object], repo_root: Path) -> dict[str, object]:
79
+ signals = repository_recovery_signals(repo_root)
80
+ if signals:
81
+ payload["recovery"] = {
82
+ "signals": signals,
83
+ "listCommand": "./.cowork-flow/run task next --list --json",
84
+ }
85
+ return payload
86
+
87
+
88
+ def _print_recovery(payload: dict[str, object]) -> None:
89
+ recovery = payload.get("recovery")
90
+ if not isinstance(recovery, dict):
91
+ return
92
+ signals = recovery.get("signals")
93
+ if not isinstance(signals, list) or not signals:
94
+ return
95
+ print("Recovery:")
96
+ for signal in signals:
97
+ if isinstance(signal, dict):
98
+ print(
99
+ f" - {signal.get('kind')}: {signal.get('task')} "
100
+ f"({signal.get('status')})"
101
+ )
102
+ print(f" Hint: {signal.get('hint')}")
103
+ print(f"List: {recovery.get('listCommand')}")
104
+
105
+
44
106
  def _print_blockers(blockers: list[str]) -> None:
45
107
  if not blockers:
46
108
  print("Blockers: none")
@@ -105,6 +167,9 @@ def _print_text_payload(payload: dict[str, object]) -> None:
105
167
  print(f"Next action: {action['label']}")
106
168
  print(f"Skill: {action['activatedSkill'] or 'none'}")
107
169
  print(f"Command: {action['command'] or 'none'}")
170
+ diagnostics_command = action.get("diagnosticsCommand")
171
+ if diagnostics_command:
172
+ print(f"Diagnostics: {diagnostics_command}")
108
173
  blockers = payload["blockers"]
109
174
  if not isinstance(blockers, list):
110
175
  raise TypeError("navigation payload blockers must be a list")
@@ -123,13 +188,15 @@ def _navigation_target(args, repo_root: Path, structured: bool):
123
188
  if active.task_path:
124
189
  return repo_root / active.task_path, active.task_path, source, True
125
190
  if structured:
126
- _print_json_route(
191
+ payload = build_navigation_payload(
127
192
  args=args,
128
193
  status="no_task",
129
194
  blockers=[],
130
195
  active_target=False,
131
196
  task_path=None,
132
197
  )
198
+ _attach_recovery(payload, repo_root)
199
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=False))
133
200
  else:
134
201
  print("Status: no_task")
135
202
  print(f"Source: {source}")
@@ -140,7 +207,9 @@ def _navigation_target(args, repo_root: Path, structured: bool):
140
207
  blockers=[],
141
208
  active_target=False,
142
209
  )
210
+ _attach_recovery(payload, repo_root)
143
211
  _print_text_payload(payload)
212
+ _print_recovery(payload)
144
213
  return None
145
214
 
146
215
 
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """Delivery runner for deterministic `task next --run` actions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from adapters.cli import task_navigation
13
+ from adapters.cli.task_support import Colors, colored, resolve_task_dir
14
+ from infra.paths import get_repo_root
15
+ from runtime.session_state import get_active_task
16
+
17
+
18
+ LifecycleCommand = Callable[[argparse.Namespace], int]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class NextActionHandlers:
23
+ create: LifecycleCommand
24
+ start: LifecycleCommand
25
+ review: LifecycleCommand
26
+ complete: LifecycleCommand
27
+ archive: LifecycleCommand
28
+
29
+
30
+ def namespace_with(args: argparse.Namespace, **overrides) -> argparse.Namespace:
31
+ values = vars(args).copy()
32
+ values.update(overrides)
33
+ return argparse.Namespace(**values)
34
+
35
+
36
+ def _display_task_path(repo_root: Path, task_dir: Path) -> str:
37
+ try:
38
+ return task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
39
+ except ValueError:
40
+ return str(task_dir)
41
+
42
+
43
+ def _next_target_for_run(args: argparse.Namespace, repo_root: Path):
44
+ target_input = getattr(args, "dir", None)
45
+ if target_input:
46
+ task_dir = resolve_task_dir(target_input, repo_root)
47
+ return task_dir, _display_task_path(repo_root, task_dir), False
48
+
49
+ active = get_active_task(repo_root)
50
+ if active.task_path:
51
+ task_dir = repo_root / active.task_path
52
+ return task_dir, active.task_path, True
53
+ return None, None, False
54
+
55
+
56
+ def _next_payload_for_run(args: argparse.Namespace, repo_root: Path) -> dict[str, object]:
57
+ task_dir, task_path, active_target = _next_target_for_run(args, repo_root)
58
+ if task_dir is None or task_path is None:
59
+ return task_navigation.build_navigation_payload(
60
+ args=args,
61
+ status="no_task",
62
+ blockers=[],
63
+ active_target=False,
64
+ task_path=None,
65
+ )
66
+ if not task_dir.is_dir():
67
+ return task_navigation.build_navigation_payload(
68
+ args=args,
69
+ status="stale",
70
+ blockers=[f"task directory not found: {task_path}"],
71
+ active_target=active_target,
72
+ task_path=task_path,
73
+ )
74
+ status = task_navigation._status(repo_root, task_dir)
75
+ blockers = task_navigation._blockers(repo_root, task_dir) if status == "planning" else []
76
+ return task_navigation.build_navigation_payload(
77
+ args=args,
78
+ status=status,
79
+ blockers=blockers,
80
+ active_target=active_target,
81
+ task_path=task_path,
82
+ )
83
+
84
+
85
+ def _run_create_action(
86
+ args: argparse.Namespace,
87
+ action: dict[str, object],
88
+ handlers: NextActionHandlers,
89
+ ) -> int:
90
+ if not getattr(args, "title", None):
91
+ print(
92
+ colored("Error: create_task requires --title", Colors.RED),
93
+ file=sys.stderr,
94
+ )
95
+ print(f"Command: {action.get('command')}", file=sys.stderr)
96
+ return 1
97
+ return handlers.create(
98
+ namespace_with(
99
+ args,
100
+ title=args.title,
101
+ slug=getattr(args, "slug", None),
102
+ assignee=getattr(args, "assignee", None),
103
+ priority=getattr(args, "priority", "P2"),
104
+ description=getattr(args, "description", None),
105
+ parent=getattr(args, "parent", None),
106
+ from_plan=getattr(args, "from_plan", None),
107
+ )
108
+ )
109
+
110
+
111
+ def _create_input_names(args: argparse.Namespace) -> list[str]:
112
+ names = (
113
+ "title",
114
+ "slug",
115
+ "assignee",
116
+ "description",
117
+ "parent",
118
+ "from_plan",
119
+ )
120
+ return [name for name in names if getattr(args, name, None)]
121
+
122
+
123
+ def _validated_next_action(args: argparse.Namespace) -> tuple[dict[str, object] | None, int]:
124
+ payload = _next_payload_for_run(args, get_repo_root())
125
+ action = payload.get("action")
126
+ if not isinstance(action, dict):
127
+ print(
128
+ colored("Error: task next did not return an action", Colors.RED),
129
+ file=sys.stderr,
130
+ )
131
+ return None, 1
132
+ create_inputs = _create_input_names(args)
133
+ action_id = action.get("id")
134
+ if create_inputs and action_id != "create_task":
135
+ print(
136
+ colored(
137
+ f"Error: create_task inputs cannot run {action_id}",
138
+ Colors.RED,
139
+ ),
140
+ file=sys.stderr,
141
+ )
142
+ print(
143
+ "Hint: archive or finish the current task first, then rerun "
144
+ "`task next --run --title ... --slug ... --assignee <name>`.",
145
+ file=sys.stderr,
146
+ )
147
+ return None, 1
148
+ if payload.get("blockers") or action.get("blockers"):
149
+ print(colored("Error: next action is blocked", Colors.RED), file=sys.stderr)
150
+ for blocker in payload.get("blockers", []) or action.get("blockers", []):
151
+ print(f" - {blocker}", file=sys.stderr)
152
+ return None, 1
153
+ if not action.get("runnable"):
154
+ action_id = action.get("id")
155
+ print(
156
+ colored(f"Error: next action is not executable: {action_id}", Colors.RED),
157
+ file=sys.stderr,
158
+ )
159
+ if action.get("command"):
160
+ print(f"Command: {action['command']}", file=sys.stderr)
161
+ return None, 1
162
+ return action, 0
163
+
164
+
165
+ def _resolved_next_action_task(args: argparse.Namespace, action_id: object) -> str | None:
166
+ if action_id == "create_task":
167
+ return None
168
+ _, task_path_value, _ = _next_target_for_run(args, get_repo_root())
169
+ if task_path_value is None:
170
+ print(colored("Error: no task target for action", Colors.RED), file=sys.stderr)
171
+ return None
172
+ return task_path_value
173
+
174
+
175
+ def _dispatch_next_lifecycle_action(
176
+ args: argparse.Namespace,
177
+ action_id: object,
178
+ task_path: str | None,
179
+ action: dict[str, object],
180
+ handlers: NextActionHandlers,
181
+ ) -> int:
182
+ if action_id == "create_task":
183
+ return _run_create_action(args, action, handlers)
184
+ if task_path is None:
185
+ return 1
186
+ if action_id == "start_task":
187
+ return handlers.start(
188
+ namespace_with(
189
+ args,
190
+ dir=task_path,
191
+ auto=bool(getattr(args, "auto", False)),
192
+ approved=bool(getattr(args, "approved", False)),
193
+ )
194
+ )
195
+ if action_id == "request_review":
196
+ return handlers.review(namespace_with(args, dir=task_path))
197
+ if action_id == "complete_task":
198
+ return handlers.complete(namespace_with(args, dir=task_path))
199
+ if action_id == "archive_task":
200
+ return handlers.archive(
201
+ namespace_with(
202
+ args,
203
+ name=Path(str(task_path)).name,
204
+ commit=bool(getattr(args, "commit", False)),
205
+ )
206
+ )
207
+ if action_id == "batch_execute":
208
+ return handlers.start(
209
+ namespace_with(
210
+ args,
211
+ dir=task_path,
212
+ auto=True,
213
+ approved=bool(getattr(args, "approved", False)),
214
+ )
215
+ )
216
+ print(
217
+ colored(f"Error: unsupported next action: {action_id}", Colors.RED),
218
+ file=sys.stderr,
219
+ )
220
+ return 1
221
+
222
+
223
+ def run_next_action(args: argparse.Namespace, handlers: NextActionHandlers) -> int:
224
+ action, error_code = _validated_next_action(args)
225
+ if action is None:
226
+ return error_code
227
+ action_id = action.get("id")
228
+ task_path = _resolved_next_action_task(args, action_id)
229
+ return _dispatch_next_lifecycle_action(args, action_id, task_path, action, handlers)
@@ -14,7 +14,7 @@ USAGE_TEXT = """Task workflow router for cowork-flow
14
14
  Usage:
15
15
  ./.cowork-flow/run task next [dir] [--json] [--intent I]
16
16
  ./.cowork-flow/run task next [dir] --validate
17
- ./.cowork-flow/run task next --list [--mine] [--status S]
17
+ ./.cowork-flow/run task next --list [--json] [--mine] [--status S]
18
18
  ./.cowork-flow/run task next [dir] --run [--intent I]
19
19
 
20
20
  Public contract:
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import json
6
7
  import sys
7
8
 
8
9
  from services.task_tree import TaskTreeError, TaskTreeService
@@ -140,6 +141,73 @@ def cmd_list(args) -> int:
140
141
  return 0
141
142
 
142
143
 
144
+ def _list_task_records(repo_root, *, mine: bool, status: str | None):
145
+ active_task = get_active_task(repo_root).task_path
146
+ developer = get_developer(repo_root)
147
+ if mine and not developer:
148
+ return None, "No developer set. Run init_developer.py first"
149
+
150
+ service = TaskTreeService(repo_root)
151
+ all_tasks = service.active_nodes()
152
+ records: list[dict[str, object]] = []
153
+
154
+ def append_task(dir_name: str, depth: int = 0) -> None:
155
+ info = all_tasks[dir_name]
156
+ if mine and info.assignee != developer:
157
+ return
158
+ if status and info.status != status:
159
+ return
160
+ relative_path = f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}"
161
+ done, total = service.children_progress(info.children, all_tasks)
162
+ records.append(
163
+ {
164
+ "name": dir_name,
165
+ "path": relative_path,
166
+ "status": info.status,
167
+ "assignee": info.assignee,
168
+ "parent": info.parent,
169
+ "children": list(info.children),
170
+ "childrenDone": done,
171
+ "childrenTotal": total,
172
+ "depth": depth,
173
+ "active": relative_path == active_task,
174
+ }
175
+ )
176
+ for child_name in info.children:
177
+ if child_name in all_tasks:
178
+ append_task(child_name, depth + 1)
179
+
180
+ for dir_name in service.root_names(all_tasks):
181
+ append_task(dir_name)
182
+ return records, None
183
+
184
+
185
+ def cmd_list_json(args) -> int:
186
+ repo_root = get_repo_root()
187
+ records, error = _list_task_records(
188
+ repo_root,
189
+ mine=bool(getattr(args, "mine", False)),
190
+ status=getattr(args, "status", None),
191
+ )
192
+ if error:
193
+ print(colored(f"Error: {error}", Colors.RED), file=sys.stderr)
194
+ return 1
195
+ print(
196
+ json.dumps(
197
+ {
198
+ "tasks": records or [],
199
+ "count": len(records or []),
200
+ "filters": {
201
+ "mine": bool(getattr(args, "mine", False)),
202
+ "status": getattr(args, "status", None),
203
+ },
204
+ },
205
+ ensure_ascii=False,
206
+ )
207
+ )
208
+ return 0
209
+
210
+
143
211
  def cmd_list_archive(args) -> int:
144
212
  repo_root = get_repo_root()
145
213
  month = args.month
@@ -27,6 +27,7 @@ class SkillAction:
27
27
  lifecycle_check: str | None
28
28
  mutates_state: bool
29
29
  command: str | None
30
+ diagnostics_command: str | None
30
31
 
31
32
 
32
33
  @dataclass(frozen=True)
@@ -142,7 +143,14 @@ def _parse_manifest(path: Path, raw: dict[str, Any]) -> SkillManifest:
142
143
  _reject_unknown_fields(
143
144
  item,
144
145
  allowed=frozenset(
145
- {"id", "label", "lifecycleCheck", "mutatesState", "command"}
146
+ {
147
+ "id",
148
+ "label",
149
+ "lifecycleCheck",
150
+ "mutatesState",
151
+ "command",
152
+ "diagnosticsCommand",
153
+ }
146
154
  ),
147
155
  scope="manifest action",
148
156
  path=path,
@@ -162,6 +170,9 @@ def _parse_manifest(path: Path, raw: dict[str, Any]) -> SkillManifest:
162
170
  command = item.get("command")
163
171
  if command is not None and not isinstance(command, str):
164
172
  raise SkillManifestError(f"manifest command is invalid: {path}")
173
+ diagnostics_command = item.get("diagnosticsCommand")
174
+ if diagnostics_command is not None and not isinstance(diagnostics_command, str):
175
+ raise SkillManifestError(f"manifest diagnosticsCommand is invalid: {path}")
165
176
  actions.append(
166
177
  SkillAction(
167
178
  skill=skill,
@@ -170,6 +181,11 @@ def _parse_manifest(path: Path, raw: dict[str, Any]) -> SkillManifest:
170
181
  lifecycle_check=lifecycle_check.strip() if isinstance(lifecycle_check, str) else None,
171
182
  mutates_state=mutates_state,
172
183
  command=command.strip() if isinstance(command, str) else None,
184
+ diagnostics_command=(
185
+ diagnostics_command.strip()
186
+ if isinstance(diagnostics_command, str)
187
+ else None
188
+ ),
173
189
  )
174
190
  )
175
191
 
@@ -333,7 +349,14 @@ def load_skill_manifests(repo_root: Path) -> tuple[SkillManifest, ...]:
333
349
  def _manifest_signature(manifest: SkillManifest) -> tuple[object, ...]:
334
350
  return (
335
351
  tuple(
336
- (item.action_id, item.label, item.lifecycle_check, item.mutates_state, item.command)
352
+ (
353
+ item.action_id,
354
+ item.label,
355
+ item.lifecycle_check,
356
+ item.mutates_state,
357
+ item.command,
358
+ item.diagnostics_command,
359
+ )
337
360
  for item in manifest.actions
338
361
  ),
339
362
  tuple(
@@ -47,6 +47,12 @@ def _action_command(action_id: str, task_path: str | None, template: str | None)
47
47
  return None
48
48
 
49
49
 
50
+ def _diagnostics_command(task_path: str | None, template: str | None) -> str | None:
51
+ if template:
52
+ return template.replace("<task-dir>", task_path or "<task-dir>")
53
+ return None
54
+
55
+
50
56
  def _action_contract(
51
57
  *,
52
58
  status: str,
@@ -90,6 +96,10 @@ def _action_contract(
90
96
  "label": owner.label if owner is not None else action_id,
91
97
  "activatedSkill": owner.skill if owner is not None else None,
92
98
  "command": _action_command(action_id, task_path, owner.command if owner else None),
99
+ "diagnosticsCommand": _diagnostics_command(
100
+ task_path,
101
+ owner.diagnostics_command if owner else None,
102
+ ),
93
103
  "mutatesState": action["mutatesState"],
94
104
  "lifecycleCheck": lifecycle_check,
95
105
  "runtimeGate": lifecycle_check,
@@ -141,6 +151,7 @@ def route_request(
141
151
  "nextAction": action["id"],
142
152
  "activatedSkill": action["activatedSkill"],
143
153
  "actionCommand": action["command"],
154
+ "diagnosticsCommand": action["diagnosticsCommand"],
144
155
  "mutatesState": action["mutatesState"],
145
156
  "lifecycleCheck": action["lifecycleCheck"],
146
157
  "runtimeGate": action["runtimeGate"],
@@ -7,7 +7,7 @@ not contain Skill ids, prompt wording, CLI commands, or script paths.
7
7
  Each Skill may declare its own `manifest.json` with:
8
8
 
9
9
  - `actions`: action id, display label, lifecycle check, mutation fact, and an
10
- optional command description;
10
+ optional command description plus optional `diagnosticsCommand`;
11
11
  - `context`: implement/check/debug activation rules by dev type or path;
12
12
  - `commands`: Skill-owned runtime entrypoints.
13
13
 
@@ -28,7 +28,9 @@ resume and result recording are also Skill commands rather than task CLI
28
28
  handlers.
29
29
 
30
30
  Review-oriented Skill commands may provide advisory facts, such as changed-file
31
- coverage, applicable spec sources, and test-intent signals. They must remain
31
+ coverage, applicable spec sources, and test-intent signals. An action-level
32
+ `diagnosticsCommand` may expose these helpers in text and JSON navigation, but
33
+ it does not make the helper runnable as a lifecycle action. They must remain
32
34
  read-only helpers: no task-local review evidence file, lifecycle state mutation,
33
35
  natural-language spec hard gate, or pass/fail completion verdict.
34
36
 
@@ -41,6 +43,13 @@ Skill-id priority tables.
41
43
 
42
44
  Runtime Health has two scopes. An installed project validates its installed
43
45
  runtime, detected host platforms, and installed Skill manifests without
44
- requiring a `template/` directory. A cowork-flow source checkout additionally
45
- validates complete template host assets and template-to-live runtime and Skill
46
- replica parity, including every detected Skill target.
46
+ requiring a `template/` directory. A cowork-flow source checkout distinguishes
47
+ tracked source checkout bootstrap files, ignored local live runtime, and the
48
+ template distribution source. Source checkout health must not force-track the whole `.cowork-flow/` tree: tracked bootstrap files must match the template,
49
+ ignored local live runtime is compared only when present, and Skill replica
50
+ parity is checked for every detected installed Skill target.
51
+
52
+ Runtime Health also reports stale task hygiene read-only. It may warn about
53
+ completed-unarchived tasks, active-unbound tasks, and missing task context, and
54
+ it may include command hints. These warnings must not mutate task lifecycle
55
+ state, write evidence files, or become completion gates.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.46",
3
+ "version": "0.0.47",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -9,6 +9,9 @@ Use this Skill to stress-test a decision while course correction is still cheap.
9
9
  It is advisory and independent from lifecycle start, completion, and archive
10
10
  gates.
11
11
 
12
+ Prefer precise context over broad narrative: pass only the smallest artifact,
13
+ the contract it must satisfy, and the decision claim under review.
14
+
12
15
  ## Apply When
13
16
 
14
17
  - The user explicitly asks for doubt review or an adversarial pass.
@@ -30,6 +33,20 @@ gates.
30
33
  4. **RECONCILE**: fix actionable findings, accept explicit trade-offs, and discard noise.
31
34
  5. **STOP**: stop after trivial findings, three cycles, or user direction.
32
35
 
36
+ ## Severity
37
+
38
+ Use severity to keep doubt actionable instead of theatrical.
39
+
40
+ - `critical`: invalidates correctness, safety, lifecycle boundaries, data integrity, or completion readiness.
41
+ - `important`: likely bug, missing meaningful verification, spec drift, or maintainability risk that should be fixed before completion.
42
+ - `minor`: clarity, naming, or polish that should be noted but does not block progress.
43
+
44
+ ## Verification Discipline
45
+
46
+ - Use verification-before-completion: do not accept behavioral correctness until current source or command output supports it.
47
+ - Use anti-rationalization: challenge convenient explanations for missing edge cases, tests, or scope checks.
48
+ - State what evidence would change the conclusion; do not turn advisory doubt into a hidden gate.
49
+
33
50
  Pass only ARTIFACT + CONTRACT to the reviewer so the claim does not bias the
34
51
  review. Keep the result in the task discussion or review output. No task-local
35
52
  evidence file or self-declared acceptance record is required for readiness.
@@ -16,12 +16,17 @@ Run diagnostics through the common runner:
16
16
  .cowork-flow/run doctor --all
17
17
  .cowork-flow/run doctor --host-adapters
18
18
  .cowork-flow/run doctor --subagent-safety
19
+ .cowork-flow/run doctor --task-hygiene
20
+ .cowork-flow/run doctor --task-hygiene --json
19
21
  ```
20
22
 
21
23
  ## Boundaries
22
24
 
23
25
  - Diagnostics may inspect runtime contracts, host assets, hooks, and template files.
24
26
  - Installed projects validate only detected host platforms and do not require a source `template/` tree.
25
- - Source checkouts also validate template-to-live runtime and Skill replica parity.
27
+ - Source checkouts distinguish tracked source checkout bootstrap files, ignored local live runtime, and the template distribution source.
28
+ - Source checkout health must not force-track the whole `.cowork-flow/` tree; ignored local live runtime files are checked only when present.
29
+ - Source checkouts also validate Skill replica parity for detected installed host targets.
30
+ - stale task hygiene reports completed-unarchived tasks, active-unbound tasks, and missing task context with command hints only.
26
31
  - Diagnostics must not mutate task lifecycle state.
27
32
  - Do not add lifecycle routing rules here; `task next` remains the workflow router.
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import json
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
@@ -30,15 +31,33 @@ from infra.paths import get_repo_root
30
31
  from infra.skill_manifest import SkillManifestError, action_owners, load_skill_manifests
31
32
 
32
33
 
33
- def _same_file(left: Path, right: Path, errors: list[str]) -> None:
34
+ SOURCE_CHECKOUT_BOOTSTRAP_FILES = (
35
+ Path(".cowork-flow/scripts/adapters/cli/task_navigation.py"),
36
+ Path(".cowork-flow/scripts/adapters/cli/task_parser.py"),
37
+ )
38
+
39
+
40
+ def _compare_file(
41
+ left: Path,
42
+ right: Path,
43
+ errors: list[str],
44
+ *,
45
+ missing_left: str = "missing template asset",
46
+ missing_right: str = "missing installed asset",
47
+ drift: str = "distribution drift",
48
+ ) -> None:
34
49
  if not left.is_file():
35
- errors.append(f"missing template asset: {left}")
50
+ errors.append(f"{missing_left}: {left}")
36
51
  return
37
52
  if not right.is_file():
38
- errors.append(f"missing installed asset: {right}")
53
+ errors.append(f"{missing_right}: {right}")
39
54
  return
40
55
  if left.read_bytes() != right.read_bytes():
41
- errors.append(f"distribution drift: {left} != {right}")
56
+ errors.append(f"{drift}: {left} != {right}")
57
+
58
+
59
+ def _same_file(left: Path, right: Path, errors: list[str]) -> None:
60
+ _compare_file(left, right, errors)
42
61
 
43
62
 
44
63
  def _distribution_root(repo_root: Path) -> Path:
@@ -82,19 +101,42 @@ def check_distribution(repo_root: Path) -> list[str]:
82
101
  if _distribution_root(repo_root) == repo_root:
83
102
  return errors
84
103
  runtime_root = template / ".cowork-flow" / "scripts"
104
+ bootstrap = set(SOURCE_CHECKOUT_BOOTSTRAP_FILES)
105
+ for relative in SOURCE_CHECKOUT_BOOTSTRAP_FILES:
106
+ _compare_file(
107
+ template / relative,
108
+ repo_root / relative,
109
+ errors,
110
+ missing_left="missing template bootstrap asset",
111
+ missing_right="missing source bootstrap asset",
112
+ drift="source bootstrap drift",
113
+ )
85
114
  for source in _distribution_files(runtime_root):
86
115
  relative = source.relative_to(template)
87
- _same_file(source, repo_root / relative, errors)
116
+ if relative in bootstrap:
117
+ continue
118
+ target = repo_root / relative
119
+ if target.is_file():
120
+ _compare_file(
121
+ source,
122
+ target,
123
+ errors,
124
+ drift="local live runtime drift",
125
+ )
88
126
  for relative in (Path(".cowork-flow/run"), Path(".cowork-flow/run.cmd")):
89
127
  source = template / relative
90
- if source.is_file():
91
- _same_file(source, repo_root / relative, errors)
128
+ target = repo_root / relative
129
+ if source.is_file() and target.is_file():
130
+ _compare_file(source, target, errors, drift="local live runtime drift")
92
131
  try:
93
132
  host_manifest = load_host_manifest(template)
94
- installed_platforms = detect_installed_platforms(repo_root)
95
133
  except HostManifestError as error:
96
134
  errors.append(str(error))
97
135
  return errors
136
+ try:
137
+ installed_platforms = detect_installed_platforms(repo_root)
138
+ except HostManifestError:
139
+ installed_platforms = ()
98
140
  skill_targets = {
99
141
  host_manifest.platform(platform_id).skill_target
100
142
  for platform_id in installed_platforms
@@ -108,6 +150,113 @@ def check_distribution(repo_root: Path) -> list[str]:
108
150
  return errors
109
151
 
110
152
 
153
+ def _task_path(repo_root: Path, task_dir: Path) -> str:
154
+ try:
155
+ return task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
156
+ except ValueError:
157
+ return str(task_dir)
158
+
159
+
160
+ def _task_metadata(path: Path) -> dict[str, object]:
161
+ try:
162
+ data = json.loads(path.read_text(encoding="utf-8"))
163
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
164
+ return {}
165
+ return data if isinstance(data, dict) else {}
166
+
167
+
168
+ def _session_task_paths(repo_root: Path) -> set[str]:
169
+ sessions = repo_root / ".cowork-flow" / ".runtime" / "sessions"
170
+ active: set[str] = set()
171
+ if not sessions.is_dir():
172
+ return active
173
+ for path in sorted(sessions.glob("*.json")):
174
+ data = _task_metadata(path)
175
+ task_path = data.get("active_task_path")
176
+ if isinstance(task_path, str) and task_path.strip():
177
+ active.add(task_path.replace("\\", "/"))
178
+ return active
179
+
180
+
181
+ def _task_hygiene_issue(
182
+ *,
183
+ kind: str,
184
+ task: str,
185
+ status: str,
186
+ message: str,
187
+ hint: str,
188
+ ) -> dict[str, str]:
189
+ return {
190
+ "kind": kind,
191
+ "task": task,
192
+ "status": status,
193
+ "message": message,
194
+ "hint": hint,
195
+ }
196
+
197
+
198
+ def _missing_context_files(task_dir: Path) -> tuple[str, ...]:
199
+ required = ("decision-anchor.md", "implement.jsonl", "check.jsonl", "debug.jsonl")
200
+ return tuple(name for name in required if not (task_dir / name).is_file())
201
+
202
+
203
+ def check_task_hygiene(repo_root: Path) -> list[dict[str, str]]:
204
+ tasks_dir = repo_root / ".cowork-flow" / "tasks"
205
+ if not tasks_dir.is_dir():
206
+ return []
207
+ bound_tasks = _session_task_paths(repo_root)
208
+ issues: list[dict[str, str]] = []
209
+ for task_dir in sorted(tasks_dir.iterdir()):
210
+ if not task_dir.is_dir() or task_dir.name == "archive":
211
+ continue
212
+ task = _task_path(repo_root, task_dir)
213
+ data = _task_metadata(task_dir / "task.json")
214
+ status = str(data.get("status") or "unknown")
215
+ if status == "completed":
216
+ issues.append(
217
+ _task_hygiene_issue(
218
+ kind="completed_unarchived",
219
+ task=task,
220
+ status=status,
221
+ message="completed task remains in the active task tree",
222
+ hint=f"./.cowork-flow/run task next {task} --run --intent archive",
223
+ )
224
+ )
225
+ if status in {"in_progress", "review"} and task not in bound_tasks:
226
+ issues.append(
227
+ _task_hygiene_issue(
228
+ kind="in_progress_unbound",
229
+ task=task,
230
+ status=status,
231
+ message="active task state is not bound to any runtime session",
232
+ hint=f"./.cowork-flow/run task next {task} --run",
233
+ )
234
+ )
235
+ missing = _missing_context_files(task_dir)
236
+ if missing:
237
+ issues.append(
238
+ _task_hygiene_issue(
239
+ kind="missing_task_context",
240
+ task=task,
241
+ status=status,
242
+ message=f"missing task context file(s): {', '.join(missing)}",
243
+ hint=f"./.cowork-flow/run task next {task} --validate",
244
+ )
245
+ )
246
+ return issues
247
+
248
+
249
+ def _print_task_hygiene_issues(issues: list[dict[str, str]]) -> None:
250
+ for issue in issues:
251
+ print(
252
+ "WARNING: "
253
+ f"{issue['kind']}: {issue['task']} ({issue['status']}): "
254
+ f"{issue['message']}",
255
+ file=sys.stderr,
256
+ )
257
+ print(f"Hint: {issue['hint']}", file=sys.stderr)
258
+
259
+
111
260
  def check_runtime(repo_root: Path) -> list[str]:
112
261
  errors: list[str] = []
113
262
  try:
@@ -142,6 +291,7 @@ def _run_checks(repo_root: Path) -> int:
142
291
  errors.extend(_host_errors(repo_root))
143
292
  errors.extend(check_runtime(repo_root))
144
293
  errors.extend(check_distribution(repo_root))
294
+ _print_task_hygiene_issues(check_task_hygiene(repo_root))
145
295
  if errors:
146
296
  for error in errors:
147
297
  print(f"ERROR: {error}", file=sys.stderr)
@@ -170,11 +320,25 @@ def _run_runtime_checks(repo_root: Path) -> int:
170
320
  return 0
171
321
 
172
322
 
323
+ def _run_task_hygiene_checks(repo_root: Path, *, structured: bool = False) -> int:
324
+ issues = check_task_hygiene(repo_root)
325
+ if structured:
326
+ print(json.dumps({"issues": issues}, ensure_ascii=False))
327
+ return 0
328
+ if issues:
329
+ _print_task_hygiene_issues(issues)
330
+ else:
331
+ print("task hygiene checks passed")
332
+ return 0
333
+
334
+
173
335
  def build_parser() -> argparse.ArgumentParser:
174
336
  parser = argparse.ArgumentParser(description="cowork-flow diagnostics")
175
337
  parser.add_argument("--all", action="store_true", help="Run all structured health checks")
176
338
  parser.add_argument("--subagent-safety", action="store_true", help="Run runtime safety checks")
177
339
  parser.add_argument("--host-adapters", action="store_true", help="Run host asset checks")
340
+ parser.add_argument("--task-hygiene", action="store_true", help="Report stale task hygiene issues")
341
+ parser.add_argument("--json", action="store_true", help="Render machine-readable diagnostics where supported")
178
342
  return parser
179
343
 
180
344
 
@@ -187,6 +351,8 @@ def main() -> int:
187
351
  return _run_host_checks(repo_root)
188
352
  if args.subagent_safety:
189
353
  return _run_runtime_checks(repo_root)
354
+ if args.task_hygiene:
355
+ return _run_task_hygiene_checks(repo_root, structured=bool(args.json))
190
356
  build_parser().print_help()
191
357
  return 0
192
358
 
@@ -16,6 +16,9 @@ Use this Skill during the check phase, before the `task next --run` completion a
16
16
  - Prefer fresh verification from the current checkout over stale prior output.
17
17
  - Do not create task-local review artifact files. The review result and command output are the evidence.
18
18
  - Use advisory helper output only as review input; it must not become a second lifecycle gate.
19
+ - Use anti-self-proof discipline: do not accept the agent's own summary, checklist, or intended test coverage as proof without exact diff, command output, or source evidence.
20
+ - Use anti-rationalization discipline: treat convenient explanations for missing tests, missing specs, or unchecked lifecycle blockers as findings until verified.
21
+ - Keep verification-before-completion explicit: completion requires current commands from this checkout, not stale logs or advisory helper confidence.
19
22
 
20
23
  ## Inputs
21
24
 
@@ -39,6 +42,8 @@ Read only what is needed for the current task:
39
42
  - **Code quality**: naming, layering, error handling, state boundaries, security-sensitive paths, and complexity are reviewed against applicable user specs.
40
43
  - **Lifecycle blockers**: state/scope blockers are fixed before acceptance; review does not invent hard blockers for natural-language specs.
41
44
  - **Advisory facts**: helper output is used to focus review, not to declare pass/fail or block completion.
45
+ - **Anti-self-proof**: every claimed pass maps to source, diff, test output, or lifecycle output.
46
+ - **Anti-rationalization**: every accepted gap has an explicit user decision or narrow technical reason.
42
47
 
43
48
  ## Severity
44
49
 
@@ -7,14 +7,16 @@
7
7
  "label": "mark task ready for review",
8
8
  "lifecycleCheck": "task_review",
9
9
  "mutatesState": true,
10
- "command": "./.cowork-flow/run task next <task-dir> --run --intent review"
10
+ "command": "./.cowork-flow/run task next <task-dir> --run --intent review",
11
+ "diagnosticsCommand": "./.cowork-flow/run review-check <task-dir> --json"
11
12
  },
12
13
  {
13
14
  "id": "complete_task",
14
15
  "label": "complete reviewed task",
15
16
  "lifecycleCheck": "task_complete",
16
17
  "mutatesState": true,
17
- "command": "./.cowork-flow/run task next <task-dir> --run --intent review"
18
+ "command": "./.cowork-flow/run task next <task-dir> --run --intent review",
19
+ "diagnosticsCommand": "./.cowork-flow/run review-check <task-dir> --json"
18
20
  }
19
21
  ],
20
22
  "context": [