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 +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +24 -185
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +70 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +229 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_tree_commands.py +68 -0
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +25 -2
- package/template/.cowork-flow/scripts/services/task_routing.py +11 -0
- package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +14 -5
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/adversarial-review/SKILL.md +17 -0
- package/template/skills/runtime-health/SKILL.md +6 -1
- package/template/skills/runtime-health/scripts/doctor.py +174 -8
- package/template/skills/task-review/SKILL.md +5 -0
- package/template/skills/task-review/manifest.json +4 -2
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
(
|
|
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.
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
-
|
|
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"
|
|
50
|
+
errors.append(f"{missing_left}: {left}")
|
|
36
51
|
return
|
|
37
52
|
if not right.is_file():
|
|
38
|
-
errors.append(f"
|
|
53
|
+
errors.append(f"{missing_right}: {right}")
|
|
39
54
|
return
|
|
40
55
|
if left.read_bytes() != right.read_bytes():
|
|
41
|
-
errors.append(f"
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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": [
|