cowork-flow 0.0.37 → 0.0.39
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/README.md +27 -14
- package/package.json +1 -1
- package/src/commands/init.js +39 -28
- package/src/commands/sync.js +2 -2
- package/src/lib/asset-plan.js +127 -0
- package/src/lib/copy-template.js +101 -141
- package/src/lib/host-assets.js +231 -0
- package/src/lib/plan-applier.js +329 -0
- package/src/lib/platforms.js +15 -76
- package/src/lib/skill-registry.js +503 -0
- package/template/.claude/agents/cowork-check.md +10 -1
- package/template/.claude/agents/cowork-implement.md +11 -2
- package/template/.claude/hooks/inject-workflow-state.py +51 -254
- package/template/.codex/agents/cowork-check.toml +12 -3
- package/template/.codex/agents/cowork-implement.toml +11 -2
- package/template/.codex/agents/default.toml +3 -2
- package/template/.codex/agents/explorer.toml +3 -2
- package/template/.codex/agents/worker.toml +2 -1
- package/template/.codex/hooks/inject-workflow-state.py +46 -265
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/__init__.py +1 -0
- package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
- package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
- package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
- package/template/.cowork-flow/scripts/application/task_context.py +765 -0
- package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
- package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
- package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
- package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
- package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
- package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
- package/template/.cowork-flow/scripts/commands/task.py +535 -1953
- package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
- package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
- package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
- package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
- package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
- package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
- package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
- package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
- package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
- package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
- package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
- package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
- package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
- package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
- package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
- package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
- package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
- package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
- package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
- package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
- package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
- package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
- package/template/.cowork-flow/scripts/run.py +24 -0
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
- package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
- package/template/.cowork-flow/spec/protocols/review.md +34 -0
- package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
- package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
- package/template/.cowork-flow/spec/runtime/index.md +8 -0
- package/template/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
- package/template/.cowork-flow/workflow.md +18 -13
- package/template/.opencode/agents/cowork-check.md +11 -0
- package/template/.opencode/agents/cowork-implement.md +16 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +3 -85
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
- package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
- package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
- package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
- package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
- package/template/.zcode/scaffold/AGENTS.md +18 -8
- package/template/AGENTS.md +1 -1
- package/template/skills/batch-mode/SKILL.md +140 -76
- package/template/skills/brainstorming/SKILL.md +2 -2
- package/template/skills/cowork-flow/SKILL.md +42 -0
- package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
- package/template/skills/party-mode/SKILL.md +48 -119
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
- package/template/skills/before-dev/SKILL.md +0 -41
- package/template/skills/check/SKILL.md +0 -53
- package/template/skills/check/simplification-guide.md +0 -47
- package/template/skills/continue/SKILL.md +0 -29
- package/template/skills/doubt-review/SKILL.md +0 -118
- package/template/skills/finish-work/SKILL.md +0 -32
- package/template/skills/party-mode-v2/SKILL.md +0 -75
- package/template/skills/start/SKILL.md +0 -78
- package/template/skills/tdd/SKILL.md +0 -74
- package/template/skills/update-spec/SKILL.md +0 -39
- package/template/skills/using-cowork-flow/SKILL.md +0 -80
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task lifecycle application service."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from common.gates.gates import GateRunner
|
|
14
|
+
from common.storage.unit_of_work import (
|
|
15
|
+
FaultInjector,
|
|
16
|
+
UnitOfWork,
|
|
17
|
+
UnitOfWorkError,
|
|
18
|
+
)
|
|
19
|
+
from common.task.active_task import build_active_task_session
|
|
20
|
+
from common.task.state_machine import transition_blockers
|
|
21
|
+
from common.task.task_repository import TaskRepository, TaskRepositoryError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class LifecycleStage:
|
|
26
|
+
"""Immutable behavior differences between lifecycle stages."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
target_status: str
|
|
30
|
+
gate_scope: str
|
|
31
|
+
activates_session: bool = False
|
|
32
|
+
records_completion_date: bool = False
|
|
33
|
+
includes_coding_summary: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
START_STAGE = LifecycleStage(
|
|
37
|
+
name="start",
|
|
38
|
+
target_status="in_progress",
|
|
39
|
+
gate_scope="task_start",
|
|
40
|
+
activates_session=True,
|
|
41
|
+
)
|
|
42
|
+
REVIEW_STAGE = LifecycleStage(
|
|
43
|
+
name="review",
|
|
44
|
+
target_status="review",
|
|
45
|
+
gate_scope="task_review",
|
|
46
|
+
includes_coding_summary=True,
|
|
47
|
+
)
|
|
48
|
+
COMPLETE_STAGE = LifecycleStage(
|
|
49
|
+
name="complete",
|
|
50
|
+
target_status="completed",
|
|
51
|
+
gate_scope="task_complete",
|
|
52
|
+
records_completion_date=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class LifecyclePreflightFailure:
|
|
58
|
+
"""Structured stage-specific preflight failure."""
|
|
59
|
+
|
|
60
|
+
code: str
|
|
61
|
+
title: str
|
|
62
|
+
blockers: tuple[str, ...]
|
|
63
|
+
hint: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class LifecycleResult:
|
|
68
|
+
"""Structured lifecycle outcome for delivery-layer rendering."""
|
|
69
|
+
|
|
70
|
+
ok: bool
|
|
71
|
+
code: str
|
|
72
|
+
stage: LifecycleStage
|
|
73
|
+
task_dir: Path
|
|
74
|
+
blockers: tuple[str, ...] = ()
|
|
75
|
+
title: str = ""
|
|
76
|
+
hint: str = ""
|
|
77
|
+
gate_result: object | None = None
|
|
78
|
+
summary: str = ""
|
|
79
|
+
active_task_path: str | None = None
|
|
80
|
+
repository_error: TaskRepositoryError | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
Preflight = Callable[[Path], Optional[LifecyclePreflightFailure]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TaskLifecycleService:
|
|
87
|
+
"""Run lifecycle stages through one fail-closed execution pipeline."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
repo_root: Path,
|
|
92
|
+
*,
|
|
93
|
+
repository: TaskRepository | None = None,
|
|
94
|
+
gate_runner: GateRunner | None = None,
|
|
95
|
+
fault_injector: FaultInjector | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.repo_root = Path(repo_root)
|
|
98
|
+
self.repository = repository or TaskRepository(self.repo_root)
|
|
99
|
+
self.gate_runner = gate_runner or GateRunner(self.repo_root)
|
|
100
|
+
self.fault_injector = fault_injector
|
|
101
|
+
|
|
102
|
+
def start(
|
|
103
|
+
self,
|
|
104
|
+
task: str | Path,
|
|
105
|
+
*,
|
|
106
|
+
preflight: Preflight | None = None,
|
|
107
|
+
) -> LifecycleResult:
|
|
108
|
+
return self.execute(START_STAGE, task, preflight=preflight)
|
|
109
|
+
|
|
110
|
+
def review(
|
|
111
|
+
self,
|
|
112
|
+
task: str | Path,
|
|
113
|
+
*,
|
|
114
|
+
allow_spec_file_modifications: bool = False,
|
|
115
|
+
) -> LifecycleResult:
|
|
116
|
+
return self.execute(
|
|
117
|
+
REVIEW_STAGE,
|
|
118
|
+
task,
|
|
119
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def complete(
|
|
123
|
+
self,
|
|
124
|
+
task: str | Path,
|
|
125
|
+
*,
|
|
126
|
+
completed_at: str | None = None,
|
|
127
|
+
) -> LifecycleResult:
|
|
128
|
+
return self.execute(
|
|
129
|
+
COMPLETE_STAGE,
|
|
130
|
+
task,
|
|
131
|
+
completed_at=completed_at,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def execute(
|
|
135
|
+
self,
|
|
136
|
+
stage: LifecycleStage,
|
|
137
|
+
task: str | Path,
|
|
138
|
+
*,
|
|
139
|
+
preflight: Preflight | None = None,
|
|
140
|
+
allow_spec_file_modifications: bool = False,
|
|
141
|
+
completed_at: str | None = None,
|
|
142
|
+
) -> LifecycleResult:
|
|
143
|
+
"""Resolve, preflight, validate, gate, and persist one transition."""
|
|
144
|
+
task_dir = self.repository.resolve(task)
|
|
145
|
+
prepared = self._prepare_transition(stage, task_dir, preflight)
|
|
146
|
+
if isinstance(prepared, LifecycleResult):
|
|
147
|
+
return prepared
|
|
148
|
+
task_data, already_at_target = prepared
|
|
149
|
+
|
|
150
|
+
gated = self._run_validated_gate(
|
|
151
|
+
stage,
|
|
152
|
+
task_dir,
|
|
153
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
154
|
+
)
|
|
155
|
+
if isinstance(gated, LifecycleResult):
|
|
156
|
+
return gated
|
|
157
|
+
gate_result, summary = gated
|
|
158
|
+
|
|
159
|
+
if already_at_target:
|
|
160
|
+
return self._validated_idempotent_result(
|
|
161
|
+
stage,
|
|
162
|
+
task_dir,
|
|
163
|
+
gate_result,
|
|
164
|
+
summary,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return self._persist_transition_result(
|
|
168
|
+
stage,
|
|
169
|
+
task_dir,
|
|
170
|
+
task_data,
|
|
171
|
+
gate_result,
|
|
172
|
+
summary,
|
|
173
|
+
completed_at,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def _prepare_transition(
|
|
177
|
+
self,
|
|
178
|
+
stage: LifecycleStage,
|
|
179
|
+
task_dir: Path,
|
|
180
|
+
preflight: Preflight | None,
|
|
181
|
+
) -> tuple[dict, bool] | LifecycleResult:
|
|
182
|
+
task_data_or_failure = self._load_transition_task(stage, task_dir)
|
|
183
|
+
if isinstance(task_data_or_failure, LifecycleResult):
|
|
184
|
+
return task_data_or_failure
|
|
185
|
+
task_data = task_data_or_failure
|
|
186
|
+
|
|
187
|
+
already_at_target = task_data.get("status") == stage.target_status
|
|
188
|
+
idempotent = self._early_idempotent_result(
|
|
189
|
+
stage,
|
|
190
|
+
task_dir,
|
|
191
|
+
already_at_target,
|
|
192
|
+
)
|
|
193
|
+
if idempotent is not None:
|
|
194
|
+
return idempotent
|
|
195
|
+
|
|
196
|
+
preflight_failure = self._run_preflight(stage, task_dir, preflight)
|
|
197
|
+
if preflight_failure is not None:
|
|
198
|
+
return preflight_failure
|
|
199
|
+
|
|
200
|
+
transition_failure = self._validate_transition(stage, task_dir, task_data)
|
|
201
|
+
if transition_failure is not None:
|
|
202
|
+
return transition_failure
|
|
203
|
+
return task_data, already_at_target
|
|
204
|
+
|
|
205
|
+
def _run_validated_gate(
|
|
206
|
+
self,
|
|
207
|
+
stage: LifecycleStage,
|
|
208
|
+
task_dir: Path,
|
|
209
|
+
*,
|
|
210
|
+
allow_spec_file_modifications: bool,
|
|
211
|
+
) -> tuple[object, str] | LifecycleResult:
|
|
212
|
+
gate_result_or_failure = self._run_stage_gate(
|
|
213
|
+
stage,
|
|
214
|
+
task_dir,
|
|
215
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
216
|
+
)
|
|
217
|
+
if isinstance(gate_result_or_failure, LifecycleResult):
|
|
218
|
+
return gate_result_or_failure
|
|
219
|
+
gate_result = gate_result_or_failure
|
|
220
|
+
return gate_result, self._stage_summary(stage, task_dir)
|
|
221
|
+
|
|
222
|
+
def _validated_idempotent_result(
|
|
223
|
+
self,
|
|
224
|
+
stage: LifecycleStage,
|
|
225
|
+
task_dir: Path,
|
|
226
|
+
gate_result: object,
|
|
227
|
+
summary: str,
|
|
228
|
+
) -> LifecycleResult:
|
|
229
|
+
return LifecycleResult(
|
|
230
|
+
ok=True,
|
|
231
|
+
code="LIFECYCLE-IDEMPOTENT-VALIDATED",
|
|
232
|
+
stage=stage,
|
|
233
|
+
task_dir=task_dir,
|
|
234
|
+
gate_result=gate_result,
|
|
235
|
+
summary=summary,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def _persist_transition_result(
|
|
239
|
+
self,
|
|
240
|
+
stage: LifecycleStage,
|
|
241
|
+
task_dir: Path,
|
|
242
|
+
task_data: dict,
|
|
243
|
+
gate_result: object,
|
|
244
|
+
summary: str,
|
|
245
|
+
completed_at: str | None,
|
|
246
|
+
) -> LifecycleResult:
|
|
247
|
+
session_state_or_failure = self._build_session_state(
|
|
248
|
+
stage,
|
|
249
|
+
task_dir,
|
|
250
|
+
gate_result,
|
|
251
|
+
summary,
|
|
252
|
+
)
|
|
253
|
+
if isinstance(session_state_or_failure, LifecycleResult):
|
|
254
|
+
return session_state_or_failure
|
|
255
|
+
session_state, active_task_path = session_state_or_failure
|
|
256
|
+
|
|
257
|
+
commit_failure = self._commit_transition(
|
|
258
|
+
stage,
|
|
259
|
+
task_dir,
|
|
260
|
+
task_data,
|
|
261
|
+
self._persisted_task_data(stage, task_data, completed_at),
|
|
262
|
+
session_state,
|
|
263
|
+
gate_result,
|
|
264
|
+
summary,
|
|
265
|
+
)
|
|
266
|
+
if commit_failure is not None:
|
|
267
|
+
return commit_failure
|
|
268
|
+
|
|
269
|
+
return LifecycleResult(
|
|
270
|
+
ok=True,
|
|
271
|
+
code="LIFECYCLE-OK",
|
|
272
|
+
stage=stage,
|
|
273
|
+
task_dir=task_dir,
|
|
274
|
+
gate_result=gate_result,
|
|
275
|
+
summary=summary,
|
|
276
|
+
active_task_path=active_task_path,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _load_transition_task(
|
|
280
|
+
self,
|
|
281
|
+
stage: LifecycleStage,
|
|
282
|
+
task_dir: Path,
|
|
283
|
+
) -> dict | LifecycleResult:
|
|
284
|
+
if not task_dir.is_dir():
|
|
285
|
+
return self._failure(
|
|
286
|
+
stage,
|
|
287
|
+
task_dir,
|
|
288
|
+
"LIFECYCLE-TASK-001",
|
|
289
|
+
title="Task not found",
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
UnitOfWork.recover_all(self.repo_root)
|
|
293
|
+
except UnitOfWorkError as error:
|
|
294
|
+
return self._failure(
|
|
295
|
+
stage,
|
|
296
|
+
task_dir,
|
|
297
|
+
"LIFECYCLE-RECOVERY-001",
|
|
298
|
+
title=error.detail,
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
return self.repository.load(task_dir)
|
|
302
|
+
except TaskRepositoryError as error:
|
|
303
|
+
return self._repository_failure(stage, task_dir, error)
|
|
304
|
+
|
|
305
|
+
def _early_idempotent_result(
|
|
306
|
+
self,
|
|
307
|
+
stage: LifecycleStage,
|
|
308
|
+
task_dir: Path,
|
|
309
|
+
already_at_target: bool,
|
|
310
|
+
) -> LifecycleResult | None:
|
|
311
|
+
if not already_at_target or stage.name == REVIEW_STAGE.name:
|
|
312
|
+
return None
|
|
313
|
+
return LifecycleResult(
|
|
314
|
+
ok=True,
|
|
315
|
+
code="LIFECYCLE-IDEMPOTENT",
|
|
316
|
+
stage=stage,
|
|
317
|
+
task_dir=task_dir,
|
|
318
|
+
active_task_path=(
|
|
319
|
+
self._display_task_path(task_dir)
|
|
320
|
+
if stage.activates_session
|
|
321
|
+
else None
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _run_preflight(
|
|
326
|
+
self,
|
|
327
|
+
stage: LifecycleStage,
|
|
328
|
+
task_dir: Path,
|
|
329
|
+
preflight: Preflight | None,
|
|
330
|
+
) -> LifecycleResult | None:
|
|
331
|
+
if preflight is None:
|
|
332
|
+
return None
|
|
333
|
+
failure = preflight(task_dir)
|
|
334
|
+
if failure is None:
|
|
335
|
+
return None
|
|
336
|
+
return self._failure(
|
|
337
|
+
stage,
|
|
338
|
+
task_dir,
|
|
339
|
+
failure.code,
|
|
340
|
+
title=failure.title,
|
|
341
|
+
blockers=failure.blockers,
|
|
342
|
+
hint=failure.hint,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def _validate_transition(
|
|
346
|
+
self,
|
|
347
|
+
stage: LifecycleStage,
|
|
348
|
+
task_dir: Path,
|
|
349
|
+
task_data: dict,
|
|
350
|
+
) -> LifecycleResult | None:
|
|
351
|
+
blockers = transition_blockers(
|
|
352
|
+
task_data.get("status"),
|
|
353
|
+
stage.target_status,
|
|
354
|
+
)
|
|
355
|
+
if not blockers:
|
|
356
|
+
return None
|
|
357
|
+
return self._failure(
|
|
358
|
+
stage,
|
|
359
|
+
task_dir,
|
|
360
|
+
"LIFECYCLE-TRANSITION-001",
|
|
361
|
+
blockers=tuple(blockers),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _run_stage_gate(
|
|
365
|
+
self,
|
|
366
|
+
stage: LifecycleStage,
|
|
367
|
+
task_dir: Path,
|
|
368
|
+
*,
|
|
369
|
+
allow_spec_file_modifications: bool,
|
|
370
|
+
) -> object | LifecycleResult:
|
|
371
|
+
gate_result = self.gate_runner.run(
|
|
372
|
+
stage.gate_scope,
|
|
373
|
+
task_dir,
|
|
374
|
+
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
375
|
+
)
|
|
376
|
+
if not gate_result.blocked:
|
|
377
|
+
return gate_result
|
|
378
|
+
return self._failure(
|
|
379
|
+
stage,
|
|
380
|
+
task_dir,
|
|
381
|
+
"LIFECYCLE-GATE-001",
|
|
382
|
+
gate_result=gate_result,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _stage_summary(self, stage: LifecycleStage, task_dir: Path) -> str:
|
|
386
|
+
if not stage.includes_coding_summary:
|
|
387
|
+
return ""
|
|
388
|
+
return self.gate_runner.coding_standards_summary(task_dir)
|
|
389
|
+
|
|
390
|
+
def _build_session_state(
|
|
391
|
+
self,
|
|
392
|
+
stage: LifecycleStage,
|
|
393
|
+
task_dir: Path,
|
|
394
|
+
gate_result: object,
|
|
395
|
+
summary: str,
|
|
396
|
+
) -> tuple[object | None, str | None] | LifecycleResult:
|
|
397
|
+
if not stage.activates_session:
|
|
398
|
+
return None, None
|
|
399
|
+
active_task_path = self._display_task_path(task_dir)
|
|
400
|
+
session_state = build_active_task_session(
|
|
401
|
+
self.repo_root,
|
|
402
|
+
active_task_path,
|
|
403
|
+
)
|
|
404
|
+
if session_state is None:
|
|
405
|
+
return self._failure(
|
|
406
|
+
stage,
|
|
407
|
+
task_dir,
|
|
408
|
+
"LIFECYCLE-CONTEXT-001",
|
|
409
|
+
gate_result=gate_result,
|
|
410
|
+
summary=summary,
|
|
411
|
+
)
|
|
412
|
+
return session_state, session_state[2].task_path
|
|
413
|
+
|
|
414
|
+
def _persisted_task_data(
|
|
415
|
+
self,
|
|
416
|
+
stage: LifecycleStage,
|
|
417
|
+
task_data: dict,
|
|
418
|
+
completed_at: str | None,
|
|
419
|
+
) -> dict:
|
|
420
|
+
persisted = dict(task_data)
|
|
421
|
+
persisted["status"] = stage.target_status
|
|
422
|
+
if stage.records_completion_date:
|
|
423
|
+
persisted["completedAt"] = (
|
|
424
|
+
completed_at or datetime.now().strftime("%Y-%m-%d")
|
|
425
|
+
)
|
|
426
|
+
return persisted
|
|
427
|
+
|
|
428
|
+
def _commit_transition(
|
|
429
|
+
self,
|
|
430
|
+
stage: LifecycleStage,
|
|
431
|
+
task_dir: Path,
|
|
432
|
+
task_data: dict,
|
|
433
|
+
persisted: dict,
|
|
434
|
+
session_state: object | None,
|
|
435
|
+
gate_result: object,
|
|
436
|
+
summary: str,
|
|
437
|
+
) -> LifecycleResult | None:
|
|
438
|
+
operation_id = self._operation_id(stage, task_dir, task_data)
|
|
439
|
+
unit = UnitOfWork(
|
|
440
|
+
self.repo_root,
|
|
441
|
+
operation_id=operation_id,
|
|
442
|
+
kind=f"task-lifecycle-{stage.name}",
|
|
443
|
+
fault_injector=self.fault_injector,
|
|
444
|
+
)
|
|
445
|
+
if session_state is not None:
|
|
446
|
+
unit.replace(session_state[0], session_state[1])
|
|
447
|
+
unit.replace(
|
|
448
|
+
self.repository.task_json_path(task_dir),
|
|
449
|
+
persisted,
|
|
450
|
+
)
|
|
451
|
+
try:
|
|
452
|
+
unit.commit()
|
|
453
|
+
except UnitOfWorkError as error:
|
|
454
|
+
return self._failure(
|
|
455
|
+
stage,
|
|
456
|
+
task_dir,
|
|
457
|
+
"LIFECYCLE-UOW-001",
|
|
458
|
+
title=error.detail,
|
|
459
|
+
gate_result=gate_result,
|
|
460
|
+
summary=summary,
|
|
461
|
+
)
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def _operation_id(
|
|
465
|
+
self,
|
|
466
|
+
stage: LifecycleStage,
|
|
467
|
+
task_dir: Path,
|
|
468
|
+
task_data: dict,
|
|
469
|
+
) -> str:
|
|
470
|
+
identity = "|".join(
|
|
471
|
+
(
|
|
472
|
+
str(task_dir.resolve()),
|
|
473
|
+
str(task_data.get("createdAt") or ""),
|
|
474
|
+
str(task_data.get("status") or ""),
|
|
475
|
+
stage.target_status,
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
digest = hashlib.sha256(
|
|
479
|
+
identity.encode("utf-8")
|
|
480
|
+
).hexdigest()[:16]
|
|
481
|
+
return f"task-{stage.name}-{digest}"
|
|
482
|
+
|
|
483
|
+
def _display_task_path(self, task_dir: Path) -> str:
|
|
484
|
+
try:
|
|
485
|
+
return task_dir.resolve().relative_to(
|
|
486
|
+
self.repo_root.resolve()
|
|
487
|
+
).as_posix()
|
|
488
|
+
except ValueError:
|
|
489
|
+
return str(task_dir)
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _failure(
|
|
493
|
+
stage: LifecycleStage,
|
|
494
|
+
task_dir: Path,
|
|
495
|
+
code: str,
|
|
496
|
+
*,
|
|
497
|
+
blockers: tuple[str, ...] = (),
|
|
498
|
+
title: str = "",
|
|
499
|
+
hint: str = "",
|
|
500
|
+
gate_result: object | None = None,
|
|
501
|
+
summary: str = "",
|
|
502
|
+
) -> LifecycleResult:
|
|
503
|
+
return LifecycleResult(
|
|
504
|
+
ok=False,
|
|
505
|
+
code=code,
|
|
506
|
+
stage=stage,
|
|
507
|
+
task_dir=task_dir,
|
|
508
|
+
blockers=blockers,
|
|
509
|
+
title=title,
|
|
510
|
+
hint=hint,
|
|
511
|
+
gate_result=gate_result,
|
|
512
|
+
summary=summary,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
@staticmethod
|
|
516
|
+
def _repository_failure(
|
|
517
|
+
stage: LifecycleStage,
|
|
518
|
+
task_dir: Path,
|
|
519
|
+
error: TaskRepositoryError,
|
|
520
|
+
*,
|
|
521
|
+
gate_result: object | None = None,
|
|
522
|
+
summary: str = "",
|
|
523
|
+
) -> LifecycleResult:
|
|
524
|
+
return LifecycleResult(
|
|
525
|
+
ok=False,
|
|
526
|
+
code=error.code,
|
|
527
|
+
stage=stage,
|
|
528
|
+
task_dir=task_dir,
|
|
529
|
+
gate_result=gate_result,
|
|
530
|
+
summary=summary,
|
|
531
|
+
repository_error=error,
|
|
532
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Task tree application service."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from common.core.paths import DIR_ARCHIVE, get_tasks_dir
|
|
10
|
+
from common.task.task_repository import TaskRepository, TaskRepositoryError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DONE_STATUSES = ("completed", "done")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskTreeError(RuntimeError):
|
|
17
|
+
"""Raised when a task relationship mutation cannot be completed."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, code: str, path: Path, detail: str) -> None:
|
|
20
|
+
self.code = code
|
|
21
|
+
self.path = path
|
|
22
|
+
self.detail = detail
|
|
23
|
+
super().__init__(f"{code}: {detail}: {path}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TaskNode:
|
|
28
|
+
name: str
|
|
29
|
+
status: str
|
|
30
|
+
assignee: str
|
|
31
|
+
children: tuple[str, ...]
|
|
32
|
+
parent: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TaskTreeService:
|
|
36
|
+
"""Manage task parent-child relationships and hierarchy queries."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
repo_root: Path,
|
|
41
|
+
*,
|
|
42
|
+
repository: TaskRepository | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.repo_root = Path(repo_root)
|
|
45
|
+
self.tasks_dir = get_tasks_dir(self.repo_root)
|
|
46
|
+
self.repository = repository or TaskRepository(self.repo_root)
|
|
47
|
+
|
|
48
|
+
def link(self, parent: str | Path, child: str | Path) -> None:
|
|
49
|
+
parent_dir, parent_data = self._load(parent, "parent")
|
|
50
|
+
child_dir, child_data = self._load(child, "child")
|
|
51
|
+
existing_parent = child_data.get("parent")
|
|
52
|
+
if isinstance(existing_parent, str) and existing_parent.strip():
|
|
53
|
+
raise TaskTreeError(
|
|
54
|
+
"TASK-TREE-PARENT-001",
|
|
55
|
+
child_dir,
|
|
56
|
+
f"child already has parent {existing_parent}",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
children = list(parent_data.get("children") or [])
|
|
60
|
+
if child_dir.name not in children:
|
|
61
|
+
children.append(child_dir.name)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
self.repository.save(parent_dir, {"children": children})
|
|
65
|
+
self.repository.save(child_dir, {"parent": parent_dir.name})
|
|
66
|
+
except TaskRepositoryError as error:
|
|
67
|
+
try:
|
|
68
|
+
self.repository.replace(parent_dir, parent_data)
|
|
69
|
+
self.repository.replace(child_dir, child_data)
|
|
70
|
+
except TaskRepositoryError:
|
|
71
|
+
pass
|
|
72
|
+
raise TaskTreeError(
|
|
73
|
+
"TASK-TREE-WRITE-001",
|
|
74
|
+
error.path,
|
|
75
|
+
error.detail,
|
|
76
|
+
) from error
|
|
77
|
+
|
|
78
|
+
def unlink(self, parent: str | Path, child: str | Path) -> None:
|
|
79
|
+
parent_dir, parent_data = self._load(parent, "parent")
|
|
80
|
+
child_dir, child_data = self._load(child, "child")
|
|
81
|
+
children = list(parent_data.get("children") or [])
|
|
82
|
+
if child_dir.name in children:
|
|
83
|
+
children.remove(child_dir.name)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
self.repository.save(parent_dir, {"children": children})
|
|
87
|
+
self.repository.save(child_dir, {"parent": None})
|
|
88
|
+
except TaskRepositoryError as error:
|
|
89
|
+
try:
|
|
90
|
+
self.repository.replace(parent_dir, parent_data)
|
|
91
|
+
self.repository.replace(child_dir, child_data)
|
|
92
|
+
except TaskRepositoryError:
|
|
93
|
+
pass
|
|
94
|
+
raise TaskTreeError(
|
|
95
|
+
"TASK-TREE-WRITE-001",
|
|
96
|
+
error.path,
|
|
97
|
+
error.detail,
|
|
98
|
+
) from error
|
|
99
|
+
|
|
100
|
+
def active_nodes(self) -> dict[str, TaskNode]:
|
|
101
|
+
nodes: dict[str, TaskNode] = {}
|
|
102
|
+
if not self.tasks_dir.is_dir():
|
|
103
|
+
return nodes
|
|
104
|
+
|
|
105
|
+
for task_dir in sorted(self.tasks_dir.iterdir()):
|
|
106
|
+
if not task_dir.is_dir() or task_dir.name == DIR_ARCHIVE:
|
|
107
|
+
continue
|
|
108
|
+
try:
|
|
109
|
+
data = self.repository.load(task_dir)
|
|
110
|
+
except TaskRepositoryError:
|
|
111
|
+
data = {}
|
|
112
|
+
nodes[task_dir.name] = TaskNode(
|
|
113
|
+
name=task_dir.name,
|
|
114
|
+
status=str(data.get("status") or "unknown"),
|
|
115
|
+
assignee=str(data.get("assignee") or "-"),
|
|
116
|
+
children=tuple(data.get("children") or ()),
|
|
117
|
+
parent=data.get("parent"),
|
|
118
|
+
)
|
|
119
|
+
return nodes
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def root_names(nodes: dict[str, TaskNode]) -> tuple[str, ...]:
|
|
123
|
+
return tuple(
|
|
124
|
+
sorted(name for name, node in nodes.items() if not node.parent)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def children_progress(
|
|
129
|
+
children: tuple[str, ...] | list[str],
|
|
130
|
+
nodes: dict[str, TaskNode],
|
|
131
|
+
) -> tuple[int, int]:
|
|
132
|
+
done = sum(
|
|
133
|
+
1
|
|
134
|
+
for child in children
|
|
135
|
+
if nodes.get(child)
|
|
136
|
+
and nodes[child].status in DONE_STATUSES
|
|
137
|
+
)
|
|
138
|
+
return done, len(children)
|
|
139
|
+
|
|
140
|
+
def archived_tasks(
|
|
141
|
+
self,
|
|
142
|
+
month: str | None = None,
|
|
143
|
+
) -> dict[str, tuple[str, ...]]:
|
|
144
|
+
archive_dir = self.tasks_dir / DIR_ARCHIVE
|
|
145
|
+
if month:
|
|
146
|
+
month_dir = archive_dir / month
|
|
147
|
+
return {
|
|
148
|
+
month: self._directory_names(month_dir)
|
|
149
|
+
} if month_dir.is_dir() else {}
|
|
150
|
+
if not archive_dir.is_dir():
|
|
151
|
+
return {}
|
|
152
|
+
return {
|
|
153
|
+
month_dir.name: self._directory_names(month_dir)
|
|
154
|
+
for month_dir in sorted(archive_dir.iterdir())
|
|
155
|
+
if month_dir.is_dir()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def _load(
|
|
159
|
+
self,
|
|
160
|
+
task: str | Path,
|
|
161
|
+
role: str,
|
|
162
|
+
) -> tuple[Path, dict]:
|
|
163
|
+
task_dir = self.repository.resolve(task)
|
|
164
|
+
try:
|
|
165
|
+
return task_dir, self.repository.load(task_dir)
|
|
166
|
+
except TaskRepositoryError as error:
|
|
167
|
+
raise TaskTreeError(
|
|
168
|
+
f"TASK-TREE-{role.upper()}-LOAD-001",
|
|
169
|
+
task_dir,
|
|
170
|
+
f"{role} task metadata cannot be loaded",
|
|
171
|
+
) from error
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _directory_names(directory: Path) -> tuple[str, ...]:
|
|
175
|
+
return tuple(
|
|
176
|
+
path.name
|
|
177
|
+
for path in sorted(directory.iterdir())
|
|
178
|
+
if path.is_dir()
|
|
179
|
+
)
|