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.
Files changed (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. 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
+ )