cowork-flow 0.0.47 → 0.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +102 -23
  3. package/package.json +8 -3
  4. package/src/cli.js +6 -0
  5. package/src/commands/init.js +0 -83
  6. package/src/commands/source-refresh.js +40 -0
  7. package/src/commands/sync.js +6 -0
  8. package/src/commands/update.js +59 -6
  9. package/src/lib/copy-template.js +200 -50
  10. package/src/lib/host-assets.js +367 -64
  11. package/src/lib/plan-applier.js +47 -0
  12. package/template/.claude/hooks/inject-workflow-state.py +7 -0
  13. package/template/.codex/hooks/inject-workflow-state.py +7 -0
  14. package/template/.codex/hooks.json +1 -1
  15. package/template/.cowork-flow/.version +1 -1
  16. package/template/.cowork-flow/config.yaml +0 -11
  17. package/template/.cowork-flow/scripts/adapters/cli/init_developer.py +0 -1
  18. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +7 -4
  19. package/template/.cowork-flow/scripts/adapters/cli/task.py +8 -484
  20. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +15 -134
  21. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +7 -1
  22. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +388 -0
  23. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +163 -2
  24. package/template/.cowork-flow/scripts/adapters/git/git_context.py +16 -193
  25. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +170 -84
  26. package/template/.cowork-flow/scripts/infra/config.py +0 -17
  27. package/template/.cowork-flow/scripts/infra/developer_profile.py +0 -95
  28. package/template/.cowork-flow/scripts/infra/paths.py +0 -79
  29. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  30. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  31. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  32. package/template/.cowork-flow/scripts/kernel/workflow_route.py +15 -15
  33. package/template/.cowork-flow/scripts/run.py +0 -5
  34. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  35. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  36. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  37. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +186 -69
  38. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  39. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  40. package/template/.cowork-flow/scripts/services/readiness.py +3 -170
  41. package/template/.cowork-flow/scripts/services/task_archive.py +115 -24
  42. package/template/.cowork-flow/scripts/services/task_context.py +103 -365
  43. package/template/.cowork-flow/scripts/services/task_creation.py +40 -5
  44. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  45. package/template/.cowork-flow/scripts/services/task_routing.py +80 -67
  46. package/template/.cowork-flow/scripts/services/task_utils.py +0 -146
  47. package/template/.cowork-flow/scripts/services/workflow_runtime.py +1 -1
  48. package/template/.cowork-flow/spec/contracts/capabilities.md +23 -0
  49. package/template/.cowork-flow/spec/contracts/decision-anchor.md +2 -2
  50. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  51. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  52. package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +20 -0
  53. package/template/.cowork-flow/spec/contracts/plan-binding.md +50 -0
  54. package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +31 -7
  55. package/template/.cowork-flow/spec/guides/index.md +3 -1
  56. package/template/.cowork-flow/spec/index.md +5 -4
  57. package/template/.cowork-flow/spec/references/definition-of-done.md +2 -3
  58. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  59. package/template/.cowork-flow/spec/runtime/contract-registry.json +14 -0
  60. package/template/.cowork-flow/spec/runtime/host-assets.json +73 -3
  61. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  62. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +150 -8
  63. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  64. package/template/skills/batch-execution/SKILL.md +37 -3
  65. package/template/skills/batch-execution/scripts/batch_action.py +4 -0
  66. package/template/skills/batch-execution/scripts/batch_execution.py +120 -499
  67. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  68. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  69. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  70. package/template/skills/brainstorming/SKILL.md +8 -7
  71. package/template/skills/cowork-flow/SKILL.md +3 -0
  72. package/template/skills/party-mode/scripts/party_action_contract.py +113 -0
  73. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  74. package/template/skills/party-mode/scripts/party_board_store.py +148 -0
  75. package/template/skills/party-mode/scripts/party_mode_v2.py +345 -317
  76. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  77. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  78. package/template/skills/runtime-health/SKILL.md +14 -2
  79. package/template/skills/runtime-health/scripts/doctor.py +278 -41
  80. package/template/skills/task-planning/SKILL.md +105 -61
  81. package/template/skills/task-review/SKILL.md +4 -4
  82. package/template/skills/task-review/scripts/review_check.py +277 -20
  83. package/template/skills/test-first/SKILL.md +18 -3
  84. package/template/skills/test-first/manifest.json +8 -4
  85. package/template/.cowork-flow/changes/archive/.gitkeep +0 -1
  86. package/template/.cowork-flow/scripts/adapters/cli/add_session.py +0 -465
  87. package/template/.cowork-flow/scripts/adapters/cli/change.py +0 -412
  88. package/template/.cowork-flow/workspace/index.md +0 -102
  89. package/template/.zcode/scaffold/AGENTS.md +0 -145
  90. package/template/.zcode/scaffold/CLAUDE.md +0 -5
@@ -17,14 +17,12 @@ from adapters.cli.task_support import (
17
17
  from adapters.cli.task_tree_commands import cmd_list
18
18
  from infra.paths import (
19
19
  DIR_ARCHIVE,
20
- DIR_CHANGES,
21
20
  DIR_TASKS,
22
21
  DIR_WORKFLOW,
23
22
  FILE_TASK_JSON,
24
23
  get_repo_root,
25
24
  )
26
25
  from adapters.git.git_context import _run_git_command
27
- from infra.archive_utils import archive_directory_resumable
28
26
 
29
27
 
30
28
  from kernel.task_state import DONE_STATUSES # noqa: F401
@@ -38,127 +36,10 @@ def is_git_dirty(repo_root) -> bool:
38
36
  return rc != 0 or bool(stdout.strip())
39
37
 
40
38
 
41
- def linked_active_changes_for_task(repo_root, task_dir) -> list[str]:
42
- from adapters.cli.change import linked_active_changes_for_task as find_changes
43
-
44
- return find_changes(repo_root, (task_dir,))
45
-
46
-
47
- def linked_changes_ready_for_archive(repo_root, slugs: list[str]) -> bool:
48
- from adapters.cli.change import validate_change
49
-
50
- ready = True
51
- for slug in slugs:
52
- if not validate_change(repo_root, slug, quiet=True):
53
- print(
54
- colored(
55
- f"Error: Linked change is not ready to archive: {slug}",
56
- Colors.RED,
57
- ),
58
- file=sys.stderr,
59
- )
60
- ready = False
61
- return ready
62
-
63
-
64
- def _linked_change_paths(repo_root, slug: str):
65
- source = repo_root / DIR_WORKFLOW / DIR_CHANGES / slug
66
- month = datetime.now().astimezone().strftime("%Y-%m")
67
- destination = (
68
- repo_root
69
- / DIR_WORKFLOW
70
- / DIR_CHANGES
71
- / DIR_ARCHIVE
72
- / month
73
- / slug
74
- )
75
- return source, destination
76
-
77
-
78
- def _restore_linked_changes(
79
- repo_root,
80
- slugs: list[str],
81
- metadata: dict[str, bytes],
82
- ) -> bool:
83
- restored = True
84
- for slug in reversed(slugs):
85
- source, destination = _linked_change_paths(repo_root, slug)
86
- if destination.is_dir():
87
- result = archive_directory_resumable(destination, source)
88
- if not result.ok:
89
- print(
90
- colored(
91
- f"Error: Failed to restore linked change: {slug}: "
92
- f"{result.message}",
93
- Colors.RED,
94
- ),
95
- file=sys.stderr,
96
- )
97
- restored = False
98
- continue
99
- if source.is_dir():
100
- try:
101
- (source / "change.yaml").write_bytes(metadata[slug])
102
- except OSError as error:
103
- print(
104
- colored(
105
- f"Error: Failed to restore linked change metadata: "
106
- f"{slug}: {error}",
107
- Colors.RED,
108
- ),
109
- file=sys.stderr,
110
- )
111
- restored = False
112
- return restored
113
-
114
-
115
- def archive_linked_changes(repo_root, slugs: list[str]) -> bool:
116
- from adapters.cli.change import archive_change_by_slug
117
-
118
- metadata: dict[str, bytes] = {}
119
- try:
120
- for slug in slugs:
121
- source, _ = _linked_change_paths(repo_root, slug)
122
- metadata[slug] = (source / "change.yaml").read_bytes()
123
- except OSError as error:
124
- print(
125
- colored(
126
- f"Error: Failed to snapshot linked change metadata: {error}",
127
- Colors.RED,
128
- ),
129
- file=sys.stderr,
130
- )
131
- return False
132
-
133
- archived: list[str] = []
134
- for slug in slugs:
135
- if archive_change_by_slug(repo_root, slug) is None:
136
- print(
137
- colored(
138
- f"Error: Failed to archive linked change: {slug}",
139
- Colors.RED,
140
- ),
141
- file=sys.stderr,
142
- )
143
- _restore_linked_changes(repo_root, slugs, metadata)
144
- return False
145
- archived.append(slug)
146
-
147
- for slug in archived:
148
- print(
149
- colored(f"Archived linked change: {slug}", Colors.GREEN),
150
- file=sys.stderr,
151
- )
152
- return True
153
-
154
-
155
39
  def auto_commit_archive(task_name: str, repo_root) -> None:
156
40
  archive_rels = [
157
41
  relative
158
- for relative in (
159
- f"{DIR_WORKFLOW}/{DIR_TASKS}",
160
- f"{DIR_WORKFLOW}/{DIR_CHANGES}",
161
- )
42
+ for relative in (f"{DIR_WORKFLOW}/{DIR_TASKS}",)
162
43
  if (repo_root / relative).exists()
163
44
  ]
164
45
  _run_git_command(["add", "-A", *archive_rels], cwd=repo_root)
@@ -213,20 +94,27 @@ def _archive_error_message(task_name: str, error: TaskArchiveError) -> str:
213
94
  return f"Failed to archive task: {error.detail}"
214
95
 
215
96
 
216
- def _archive_task(repo_root, task_name: str, task_dir, linked_changes: list[str]):
97
+ def _print_rollback_issues(error: TaskArchiveError) -> None:
98
+ if not error.rollback_issues:
99
+ return
100
+ print("Rollback issues:", file=sys.stderr)
101
+ for issue in error.rollback_issues:
102
+ print(
103
+ f"- {issue.stage}: {issue.path} ? {issue.detail}",
104
+ file=sys.stderr,
105
+ )
106
+
107
+
108
+ def _archive_task(repo_root, task_name: str, task_dir):
217
109
  try:
218
110
  return TaskArchiveService(repo_root).archive(
219
111
  task_dir,
220
112
  archived_at=datetime.now().strftime("%Y-%m-%d"),
221
- finalize=(
222
- lambda: archive_linked_changes(repo_root, linked_changes)
223
- )
224
- if linked_changes
225
- else None,
226
113
  )
227
114
  except TaskArchiveError as error:
228
115
  message = _archive_error_message(task_name, error)
229
116
  print(colored(f"Error: {message}", Colors.RED), file=sys.stderr)
117
+ _print_rollback_issues(error)
230
118
  return None
231
119
 
232
120
 
@@ -254,13 +142,6 @@ def cmd_archive(args) -> int:
254
142
  if task_dir is None:
255
143
  return 1
256
144
 
257
- linked_changes = linked_active_changes_for_task(repo_root, task_dir)
258
- if linked_changes and not linked_changes_ready_for_archive(
259
- repo_root,
260
- linked_changes,
261
- ):
262
- return 1
263
-
264
145
  if is_git_dirty(repo_root):
265
146
  print(
266
147
  colored(
@@ -271,7 +152,7 @@ def cmd_archive(args) -> int:
271
152
  file=sys.stderr,
272
153
  )
273
154
 
274
- result = _archive_task(repo_root, task_name, task_dir, linked_changes)
155
+ result = _archive_task(repo_root, task_name, task_dir)
275
156
  if result is None:
276
157
  return 1
277
158
 
@@ -8,7 +8,6 @@ from pathlib import Path
8
8
 
9
9
  from services.task_context import (
10
10
  CONTEXT_JSONL_FILES,
11
- PLANNED_FILE_HINT,
12
11
  TaskContextError,
13
12
  TaskContextService,
14
13
  )
@@ -16,6 +15,13 @@ from adapters.cli.task_support import Colors, colored, resolve_task_dir
16
15
  from infra.paths import get_repo_root
17
16
 
18
17
 
18
+ PLANNED_FILE_HINT = (
19
+ "If this is a planned new file, add "
20
+ '"type": "planned-file"; if this is an already-deleted file in scope, add '
21
+ '"type": "deleted-file" before running `task next --run`.'
22
+ )
23
+
24
+
19
25
  def _report_jsonl_skip(path: Path, reason: str) -> None:
20
26
  print(f" {colored('[SKIP]', Colors.YELLOW)} {path.name}: {reason}")
21
27
 
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Lifecycle task CLI command handlers."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ if __package__:
12
+ from . import _bootstrap as _bootstrap # noqa: F401
13
+ else:
14
+ import _bootstrap # noqa: F401
15
+
16
+ from services.task_lifecycle import LifecycleResult, TaskLifecycleService
17
+ from adapters.cli.task_support import (
18
+ Colors,
19
+ colored,
20
+ resolve_task_dir as _resolve_task_dir,
21
+ run_hooks as _run_hooks,
22
+ )
23
+ from adapters.cli.execution_context_args import execution_context_from_namespace
24
+ from infra.paths import (
25
+ DIR_WORKFLOW,
26
+ FILE_TASK_JSON,
27
+ get_repo_root,
28
+ )
29
+ from runtime.session_state import clear_active_task, get_active_task
30
+
31
+
32
+ def _report_check_block(title: str, blockers: tuple[str, ...]) -> int:
33
+ print(colored(f"Error: {title}", Colors.RED), file=sys.stderr)
34
+ for blocker in blockers:
35
+ print(f" - {blocker}", file=sys.stderr)
36
+ return 1
37
+
38
+
39
+ def _print_transition_blockers(blockers: list[str]) -> None:
40
+ print(
41
+ colored("Error: Task state transition blocked", Colors.RED),
42
+ file=sys.stderr,
43
+ )
44
+ for blocker in blockers:
45
+ print(f" - {blocker}", file=sys.stderr)
46
+
47
+
48
+ def _display_task_path(repo_root: Path, task_dir: Path) -> str:
49
+ try:
50
+ return task_dir.resolve().relative_to(repo_root.resolve()).as_posix()
51
+ except ValueError:
52
+ return str(task_dir)
53
+
54
+
55
+ def _resolve_status_task_dir(
56
+ args: argparse.Namespace,
57
+ repo_root: Path,
58
+ ) -> Path | None:
59
+ target_input = getattr(args, "dir", None)
60
+ if target_input:
61
+ task_dir = _resolve_task_dir(target_input, repo_root)
62
+ else:
63
+ active = get_active_task(repo_root)
64
+ if not active.context_key:
65
+ print(
66
+ colored(
67
+ "Error: Missing session context. Set "
68
+ "COWORK_FLOW_CONTEXT_ID or pass a task dir.",
69
+ Colors.RED,
70
+ ),
71
+ file=sys.stderr,
72
+ )
73
+ return None
74
+ if not active.task_path:
75
+ print(
76
+ colored(
77
+ "Error: No active task set for this session",
78
+ Colors.RED,
79
+ ),
80
+ file=sys.stderr,
81
+ )
82
+ return None
83
+ task_dir = repo_root / active.task_path
84
+
85
+ if not task_dir.is_dir():
86
+ print(
87
+ colored(
88
+ f"Error: Task not found: {target_input or task_dir}",
89
+ Colors.RED,
90
+ ),
91
+ file=sys.stderr,
92
+ )
93
+ return None
94
+ if not (task_dir / FILE_TASK_JSON).is_file():
95
+ print(
96
+ colored(
97
+ f"Error: task.json not found: {task_dir}",
98
+ Colors.RED,
99
+ ),
100
+ file=sys.stderr,
101
+ )
102
+ return None
103
+ return task_dir
104
+
105
+
106
+ def _report_lifecycle_preflight(
107
+ result: LifecycleResult,
108
+ ) -> int:
109
+ print(colored(f"Error: {result.title}", Colors.RED), file=sys.stderr)
110
+ for blocker in result.blockers:
111
+ print(f" - {blocker}", file=sys.stderr)
112
+ if result.hint:
113
+ print(f"Hint: {result.hint}", file=sys.stderr)
114
+ return 1
115
+
116
+
117
+ def _report_lifecycle_repository_error(
118
+ result: LifecycleResult,
119
+ ) -> int:
120
+ error = result.repository_error
121
+ action = (
122
+ "read"
123
+ if error is not None and error.code.startswith("TASK-LOAD-")
124
+ else "write"
125
+ )
126
+ task_json = result.task_dir / FILE_TASK_JSON
127
+ print(
128
+ colored(
129
+ f"Error: Failed to {action} task metadata: {task_json}",
130
+ Colors.RED,
131
+ ),
132
+ file=sys.stderr,
133
+ )
134
+ return 1
135
+
136
+
137
+ def _resolve_start_task(
138
+ task_input: str,
139
+ repo_root: Path,
140
+ ) -> Path | None:
141
+ full_path = _resolve_task_dir(task_input, repo_root)
142
+ if full_path.is_dir():
143
+ return full_path
144
+ print(
145
+ colored(
146
+ f"Error: Task not found: {task_input}",
147
+ Colors.RED,
148
+ )
149
+ )
150
+ print(
151
+ "Hint: Use task name (e.g., 'my-task') or full path "
152
+ f"(e.g., '{DIR_WORKFLOW}/tasks/01-31-my-task')"
153
+ )
154
+ return None
155
+
156
+
157
+ def _run_auto_start(
158
+ args: argparse.Namespace,
159
+ full_path: Path,
160
+ service: TaskLifecycleService,
161
+ ) -> int:
162
+ readiness = service.start_readiness(full_path)
163
+ if not readiness.ok:
164
+ return _report_lifecycle_preflight(readiness)
165
+ from adapters.cli.batch_mode import run_batch_entry
166
+
167
+ return run_batch_entry(get_repo_root(), full_path, args)
168
+
169
+
170
+ def _report_start_failure(
171
+ result: LifecycleResult,
172
+ ) -> int:
173
+ if result.title:
174
+ return _report_lifecycle_preflight(result)
175
+ if result.code == "LIFECYCLE-TRANSITION-001":
176
+ _print_transition_blockers(list(result.blockers))
177
+ return 1
178
+ if result.code == "LIFECYCLE-CHECK-001":
179
+ return _report_check_block(
180
+ "Lifecycle checks blocked start_task action",
181
+ result.blockers,
182
+ )
183
+ if result.code == "LIFECYCLE-CONTEXT-001":
184
+ return _report_missing_session_context()
185
+ if result.repository_error is not None:
186
+ return _report_lifecycle_repository_error(result)
187
+ print(
188
+ colored("Error: Task lifecycle failed", Colors.RED),
189
+ file=sys.stderr,
190
+ )
191
+ return 1
192
+
193
+
194
+ def _report_missing_session_context() -> int:
195
+ print(
196
+ colored(
197
+ "Error: Missing session context. Set "
198
+ "COWORK_FLOW_CONTEXT_ID or run inside a "
199
+ "supported host session.",
200
+ Colors.RED,
201
+ ),
202
+ file=sys.stderr,
203
+ )
204
+ return 1
205
+
206
+
207
+ def _report_start_success(
208
+ result: LifecycleResult,
209
+ repo_root: Path,
210
+ full_path: Path,
211
+ ) -> None:
212
+ task_dir = result.active_task_path or _display_task_path(
213
+ repo_root,
214
+ full_path,
215
+ )
216
+ print(
217
+ colored(
218
+ f"[OK] Active session task set to: {task_dir}",
219
+ Colors.GREEN,
220
+ )
221
+ )
222
+ print()
223
+ print(
224
+ colored(
225
+ "Fixed agents will load context from this task's jsonl files.",
226
+ Colors.BLUE,
227
+ )
228
+ )
229
+
230
+
231
+ def cmd_start(args: argparse.Namespace) -> int:
232
+ """Set the active task for this session."""
233
+ repo_root = get_repo_root()
234
+ task_input = args.dir
235
+ if not task_input:
236
+ print(
237
+ colored(
238
+ "Error: task directory or name required",
239
+ Colors.RED,
240
+ )
241
+ )
242
+ return 1
243
+
244
+ full_path = _resolve_start_task(task_input, repo_root)
245
+ if full_path is None:
246
+ return 1
247
+
248
+ service = TaskLifecycleService(repo_root)
249
+ if getattr(args, "auto", False):
250
+ return _run_auto_start(args, full_path, service)
251
+
252
+ result = service.start(full_path)
253
+ if not result.ok:
254
+ return _report_start_failure(result)
255
+
256
+ _report_start_success(result, repo_root, full_path)
257
+ if "after_start" in getattr(result, "emitted_events", ()):
258
+ _run_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
259
+ return 0
260
+
261
+
262
+ def cmd_review(args: argparse.Namespace) -> int:
263
+ """Mark a task ready for check."""
264
+ repo_root = get_repo_root()
265
+ execution_context = execution_context_from_namespace(args)
266
+ task_dir = _resolve_status_task_dir(args, repo_root)
267
+ if task_dir is None:
268
+ return 1
269
+
270
+ service = TaskLifecycleService(repo_root)
271
+ result = service.review(
272
+ task_dir,
273
+ execution_context=execution_context,
274
+ )
275
+ if not result.ok:
276
+ if result.code == "LIFECYCLE-TRANSITION-001":
277
+ _print_transition_blockers(list(result.blockers))
278
+ return 1
279
+ if result.code == "LIFECYCLE-CHECK-001":
280
+ return _report_check_block(
281
+ "Lifecycle checks blocked review",
282
+ result.blockers,
283
+ )
284
+ if result.repository_error is not None:
285
+ return _report_lifecycle_repository_error(result)
286
+ return 1
287
+
288
+ task_path = _display_task_path(repo_root, task_dir)
289
+ print(
290
+ colored(
291
+ f"[OK] Task marked for check: {task_path}",
292
+ Colors.GREEN,
293
+ )
294
+ )
295
+ print(f"Next: ./.cowork-flow/run task next {task_path}")
296
+ return 0
297
+
298
+
299
+ def cmd_complete(args: argparse.Namespace) -> int:
300
+ """Mark a task completed after final check."""
301
+ repo_root = get_repo_root()
302
+ execution_context = execution_context_from_namespace(args)
303
+ task_dir = _resolve_status_task_dir(args, repo_root)
304
+ if task_dir is None:
305
+ return 1
306
+
307
+ service = TaskLifecycleService(repo_root)
308
+ result = service.complete(
309
+ task_dir,
310
+ execution_context=execution_context,
311
+ )
312
+ if not result.ok:
313
+ if result.code == "LIFECYCLE-TRANSITION-001":
314
+ _print_transition_blockers(list(result.blockers))
315
+ print(
316
+ "Hint: run ./.cowork-flow/run task next <task-dir> "
317
+ "--run --intent review to mark the task ready for check",
318
+ file=sys.stderr,
319
+ )
320
+ return 1
321
+ if result.code == "LIFECYCLE-CHECK-001":
322
+ return _report_check_block(
323
+ "Lifecycle checks blocked completion",
324
+ result.blockers,
325
+ )
326
+ if result.repository_error is not None:
327
+ return _report_lifecycle_repository_error(result)
328
+ return 1
329
+
330
+ task_path = _display_task_path(repo_root, task_dir)
331
+ print(
332
+ colored(
333
+ f"[OK] Task marked completed: {task_path}",
334
+ Colors.GREEN,
335
+ )
336
+ )
337
+ print(f"Next: ./.cowork-flow/run task next {task_path}")
338
+ return 0
339
+
340
+
341
+ def cmd_finish(args: argparse.Namespace) -> int:
342
+ """Clear the active task for this session."""
343
+ repo_root = get_repo_root()
344
+ active = get_active_task(repo_root)
345
+ if not active.task_path:
346
+ print(
347
+ colored(
348
+ "No active task set for this session",
349
+ Colors.YELLOW,
350
+ )
351
+ )
352
+ return 0
353
+
354
+ task_json_path = repo_root / active.task_path / FILE_TASK_JSON
355
+ clear_active_task(repo_root)
356
+ print(
357
+ colored(
358
+ f"[OK] Cleared active session task (was: {active.task_path})",
359
+ Colors.GREEN,
360
+ )
361
+ )
362
+ emitted_events = ("after_finish",) if task_json_path.is_file() else ()
363
+ if "after_finish" in emitted_events:
364
+ _run_hooks("after_finish", task_json_path, repo_root)
365
+ return 0
366
+
367
+
368
+ def cmd_current(args: argparse.Namespace) -> int:
369
+ """Show the active task for this session."""
370
+ repo_root = get_repo_root()
371
+ active = get_active_task(repo_root)
372
+ if not active.context_key:
373
+ print(
374
+ colored(
375
+ "Error: Missing session context. Set "
376
+ "COWORK_FLOW_CONTEXT_ID or run inside a "
377
+ "supported host session.",
378
+ Colors.RED,
379
+ ),
380
+ file=sys.stderr,
381
+ )
382
+ return 1
383
+ if not active.task_path:
384
+ print("Active task: (none)")
385
+ return 0
386
+ print(f"Active task: {active.task_path}")
387
+ print(f"Source: {active.source}:{active.context_key}")
388
+ return 0