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,365 @@
1
+ #!/usr/bin/env python3
2
+ """Transactional runtime-context application service."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from common.storage.state_store import StateStore, StateStoreError
12
+ from common.storage.unit_of_work import (
13
+ FaultInjector,
14
+ UnitOfWork,
15
+ UnitOfWorkError,
16
+ )
17
+ from common.task.active_task import (
18
+ FIELD_ACTIVE_TASK_PATH,
19
+ FIELD_RUNTIME_CONTEXT_ID,
20
+ FIELD_SCOPE,
21
+ SCOPE_SUBAGENT,
22
+ logical_subagent_context_key,
23
+ runtime_context_path,
24
+ sessions_dir,
25
+ )
26
+
27
+
28
+ class RuntimeContextError(RuntimeError):
29
+ """Raised when runtime-context state cannot be read or changed."""
30
+
31
+ def __init__(self, code: str, detail: str) -> None:
32
+ self.code = code
33
+ self.detail = detail
34
+ super().__init__(f"{code}: {detail}")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RuntimeContextInitResult:
39
+ """Files created for one runtime-context initialization."""
40
+
41
+ context: dict
42
+ logical_context_key: str
43
+
44
+
45
+ class RuntimeContextService:
46
+ """Coordinate runtime-context and session state through one UoW."""
47
+
48
+ def __init__(
49
+ self,
50
+ repo_root: Path,
51
+ *,
52
+ state_store: StateStore | None = None,
53
+ fault_injector: FaultInjector | None = None,
54
+ ) -> None:
55
+ self.repo_root = Path(repo_root)
56
+ self.state_store = state_store or StateStore()
57
+ self.fault_injector = fault_injector
58
+
59
+ def recover(self) -> tuple[str, ...]:
60
+ try:
61
+ return UnitOfWork.recover_all(
62
+ self.repo_root,
63
+ state_store=self.state_store,
64
+ )
65
+ except UnitOfWorkError as error:
66
+ raise RuntimeContextError(
67
+ "RUNTIME-RECOVERY-001",
68
+ error.detail,
69
+ ) from error
70
+
71
+ def load(self, runtime_context_id: str) -> dict:
72
+ self.recover()
73
+ snapshot = self._load(runtime_context_id)
74
+ return snapshot.data if snapshot.exists else {}
75
+
76
+ def initialize(
77
+ self,
78
+ runtime_context_id: str,
79
+ context: dict,
80
+ ) -> RuntimeContextInitResult:
81
+ self.recover()
82
+ context_path = runtime_context_path(
83
+ self.repo_root,
84
+ runtime_context_id,
85
+ )
86
+ logical_context_key = logical_subagent_context_key(
87
+ runtime_context_id
88
+ )
89
+ logical_path = self._session_path(logical_context_key)
90
+ context_snapshot = self._load_path(context_path)
91
+ logical_snapshot = self._load_path(logical_path)
92
+
93
+ if context_snapshot.exists and context_snapshot.data != context:
94
+ raise RuntimeContextError(
95
+ "RUNTIME-INIT-001",
96
+ f"runtime context already exists: {runtime_context_id}",
97
+ )
98
+ if context_snapshot.exists and logical_snapshot.exists:
99
+ return RuntimeContextInitResult(
100
+ context=context_snapshot.data,
101
+ logical_context_key=logical_context_key,
102
+ )
103
+
104
+ unit = self._unit(
105
+ self._operation_id("init", runtime_context_id),
106
+ "runtime-context-init",
107
+ )
108
+ unit.replace(context_path, context)
109
+ unit.replace(
110
+ logical_path,
111
+ self._logical_session(runtime_context_id, context),
112
+ )
113
+ self._commit(unit)
114
+ return RuntimeContextInitResult(
115
+ context=dict(context),
116
+ logical_context_key=logical_context_key,
117
+ )
118
+
119
+ def update(
120
+ self,
121
+ runtime_context_id: str,
122
+ *,
123
+ status: str,
124
+ note: str | None = None,
125
+ ) -> dict | None:
126
+ self.recover()
127
+ path = runtime_context_path(self.repo_root, runtime_context_id)
128
+ snapshot = self._load_path(path)
129
+ if not snapshot.exists:
130
+ return None
131
+ context = dict(snapshot.data)
132
+ context["status"] = status
133
+ context["updated_at"] = self._now()
134
+ if note:
135
+ context["note"] = note
136
+ operation_id = self._operation_id(
137
+ "update",
138
+ runtime_context_id,
139
+ status,
140
+ str(snapshot.revision),
141
+ )
142
+ try:
143
+ self.state_store.replace(
144
+ path,
145
+ context,
146
+ expected_revision=snapshot.revision,
147
+ operation_id=operation_id,
148
+ )
149
+ except StateStoreError as error:
150
+ raise RuntimeContextError(
151
+ "RUNTIME-UPDATE-001",
152
+ error.detail,
153
+ ) from error
154
+ return context
155
+
156
+ def bind(
157
+ self,
158
+ runtime_context_id: str,
159
+ host_context_key: str,
160
+ ) -> dict | None:
161
+ self.recover()
162
+ context_path = runtime_context_path(
163
+ self.repo_root,
164
+ runtime_context_id,
165
+ )
166
+ snapshot = self._load_path(context_path)
167
+ if not snapshot.exists:
168
+ return None
169
+ context = dict(snapshot.data)
170
+ if context.get(FIELD_SCOPE) != SCOPE_SUBAGENT:
171
+ return None
172
+ if context.get("status") == "closed":
173
+ return None
174
+
175
+ existing_key = context.get("bound_context_key")
176
+ if (
177
+ isinstance(existing_key, str)
178
+ and existing_key.strip()
179
+ and existing_key != host_context_key
180
+ ):
181
+ raise RuntimeContextError(
182
+ "RUNTIME-BIND-001",
183
+ "runtime context "
184
+ f"{runtime_context_id} already bound to {existing_key}",
185
+ )
186
+ if existing_key == host_context_key and context.get("status") == "bound":
187
+ return context
188
+
189
+ now = self._now()
190
+ session = {
191
+ "schema_version": 2,
192
+ FIELD_SCOPE: SCOPE_SUBAGENT,
193
+ FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
194
+ "platform": self._platform_from_context_key(host_context_key),
195
+ "status": "bound",
196
+ "last_seen_at": now,
197
+ }
198
+ task_path = context.get("task_dir")
199
+ if isinstance(task_path, str) and task_path.strip():
200
+ session[FIELD_ACTIVE_TASK_PATH] = task_path.strip()
201
+
202
+ updated = dict(context)
203
+ updated["status"] = "bound"
204
+ updated["bound_context_key"] = host_context_key
205
+ if not updated.get("bound_at"):
206
+ updated["bound_at"] = now
207
+ updated["last_seen_at"] = now
208
+
209
+ unit = self._unit(
210
+ self._operation_id(
211
+ "bind",
212
+ runtime_context_id,
213
+ host_context_key,
214
+ ),
215
+ "runtime-context-bind",
216
+ )
217
+ unit.replace(self._session_path(host_context_key), session)
218
+ unit.replace(context_path, updated)
219
+ self._commit(unit)
220
+ return updated
221
+
222
+ def close(self, runtime_context_id: str) -> bool:
223
+ self.recover()
224
+ context_path = runtime_context_path(
225
+ self.repo_root,
226
+ runtime_context_id,
227
+ )
228
+ snapshot = self._load_path(context_path)
229
+ if not snapshot.exists:
230
+ return False
231
+ context = dict(snapshot.data)
232
+ if context.get("status") == "closed":
233
+ return True
234
+
235
+ bound_context_key = context.get("bound_context_key")
236
+ updated = dict(context)
237
+ updated["status"] = "closed"
238
+ updated["closed_at"] = self._now()
239
+ updated["last_seen_at"] = updated["closed_at"]
240
+
241
+ unit = self._unit(
242
+ self._operation_id(
243
+ "close",
244
+ runtime_context_id,
245
+ str(bound_context_key or ""),
246
+ ),
247
+ "runtime-context-close",
248
+ )
249
+ deleted_paths: set[Path] = set()
250
+ if isinstance(bound_context_key, str) and bound_context_key.strip():
251
+ bound_path = self._session_path(bound_context_key)
252
+ unit.delete(bound_path)
253
+ deleted_paths.add(bound_path)
254
+ logical_path = self._session_path(
255
+ logical_subagent_context_key(runtime_context_id)
256
+ )
257
+ if logical_path not in deleted_paths:
258
+ unit.delete(logical_path)
259
+ unit.replace(context_path, updated)
260
+ self._commit(unit)
261
+ return True
262
+
263
+ def replace(self, runtime_context_id: str, context: dict) -> dict:
264
+ self.recover()
265
+ path = runtime_context_path(self.repo_root, runtime_context_id)
266
+ snapshot = self._load_path(path)
267
+ operation_id = self._operation_id(
268
+ "replace",
269
+ runtime_context_id,
270
+ str(snapshot.revision),
271
+ )
272
+ try:
273
+ self.state_store.replace(
274
+ path,
275
+ context,
276
+ expected_revision=snapshot.revision,
277
+ operation_id=operation_id,
278
+ )
279
+ except StateStoreError as error:
280
+ raise RuntimeContextError(
281
+ "RUNTIME-SAVE-001",
282
+ error.detail,
283
+ ) from error
284
+ return dict(context)
285
+
286
+ def _load(self, runtime_context_id: str):
287
+ return self._load_path(
288
+ runtime_context_path(self.repo_root, runtime_context_id)
289
+ )
290
+
291
+ def _load_path(self, path: Path):
292
+ try:
293
+ return self.state_store.load(path, missing_ok=True)
294
+ except StateStoreError as error:
295
+ raise RuntimeContextError(
296
+ "RUNTIME-LOAD-001",
297
+ error.detail,
298
+ ) from error
299
+
300
+ def _unit(self, operation_id: str, kind: str) -> UnitOfWork:
301
+ return UnitOfWork(
302
+ self.repo_root,
303
+ operation_id=operation_id,
304
+ kind=kind,
305
+ state_store=self.state_store,
306
+ fault_injector=self.fault_injector,
307
+ )
308
+
309
+ @staticmethod
310
+ def _commit(unit: UnitOfWork) -> None:
311
+ try:
312
+ unit.commit()
313
+ except UnitOfWorkError as error:
314
+ raise RuntimeContextError(
315
+ "RUNTIME-UOW-001",
316
+ error.detail,
317
+ ) from error
318
+
319
+ def _session_path(self, context_key: str) -> Path:
320
+ return sessions_dir(self.repo_root) / f"{context_key}.json"
321
+
322
+ def _logical_session(
323
+ self,
324
+ runtime_context_id: str,
325
+ context: dict,
326
+ ) -> dict:
327
+ session: dict[str, object] = {
328
+ "schema_version": 2,
329
+ FIELD_SCOPE: SCOPE_SUBAGENT,
330
+ FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
331
+ "platform": str(context.get("host") or "manual"),
332
+ "status": "pending_bind",
333
+ "last_seen_at": self._now(),
334
+ }
335
+ task_path = context.get("task_dir")
336
+ if isinstance(task_path, str) and task_path.strip():
337
+ session[FIELD_ACTIVE_TASK_PATH] = task_path.strip()
338
+ return session
339
+
340
+ @staticmethod
341
+ def _operation_id(kind: str, *parts: str) -> str:
342
+ identity = "|".join(parts)
343
+ digest = hashlib.sha256(
344
+ identity.encode("utf-8")
345
+ ).hexdigest()[:16]
346
+ return f"runtime-{kind}-{digest}"
347
+
348
+ @staticmethod
349
+ def _platform_from_context_key(context_key: str) -> str:
350
+ if context_key.startswith("codex_"):
351
+ return "codex"
352
+ if context_key.startswith("opencode_"):
353
+ return "opencode"
354
+ if context_key.startswith("claude_"):
355
+ return "claude-code"
356
+ return "manual"
357
+
358
+ @staticmethod
359
+ def _now() -> str:
360
+ return (
361
+ datetime.now(timezone.utc)
362
+ .replace(microsecond=0)
363
+ .isoformat()
364
+ .replace("+00:00", "Z")
365
+ )
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """Task archive application service."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from common.core.paths import DIR_ARCHIVE, get_tasks_dir
11
+ from common.task.active_task import clear_task_from_sessions
12
+ from common.task.archive_utils import archive_directory_resumable
13
+ from common.task.task_repository import TaskRepository, TaskRepositoryError
14
+ from common.task.task_utils import find_task_by_name
15
+
16
+
17
+ DONE_STATUSES = ("completed", "done")
18
+
19
+
20
+ class TaskArchiveError(RuntimeError):
21
+ """Raised when a task archive cannot complete safely."""
22
+
23
+ def __init__(self, code: str, path: Path, detail: str) -> None:
24
+ self.code = code
25
+ self.path = path
26
+ self.detail = detail
27
+ super().__init__(f"{code}: {detail}: {path}")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TaskArchiveResult:
32
+ source: Path
33
+ destination: Path
34
+ task_name: str
35
+ archived_at: str
36
+
37
+
38
+ class TaskArchiveService:
39
+ """Archive a completed task and reconcile active relationships."""
40
+
41
+ def __init__(
42
+ self,
43
+ repo_root: Path,
44
+ *,
45
+ repository: TaskRepository | None = None,
46
+ ) -> None:
47
+ self.repo_root = Path(repo_root)
48
+ self.tasks_dir = get_tasks_dir(self.repo_root)
49
+ self.repository = repository or TaskRepository(self.repo_root)
50
+
51
+ def archive(
52
+ self,
53
+ task: str | Path,
54
+ *,
55
+ archived_at: str | None = None,
56
+ ) -> TaskArchiveResult:
57
+ task_dir = self.repository.resolve(task)
58
+ if not task_dir.is_dir():
59
+ raise TaskArchiveError(
60
+ "TASK-ARCHIVE-NOT-FOUND-001",
61
+ task_dir,
62
+ "task directory does not exist",
63
+ )
64
+
65
+ try:
66
+ task_data = self.repository.load(task_dir)
67
+ except TaskRepositoryError as error:
68
+ raise TaskArchiveError(
69
+ "TASK-ARCHIVE-LOAD-001",
70
+ error.path,
71
+ error.detail,
72
+ ) from error
73
+
74
+ status = task_data.get("status", "unknown")
75
+ if status not in DONE_STATUSES:
76
+ raise TaskArchiveError(
77
+ "TASK-ARCHIVE-STATUS-001",
78
+ task_dir,
79
+ f"task status is {status}",
80
+ )
81
+
82
+ archive_date = archived_at or datetime.now().strftime("%Y-%m-%d")
83
+ destination = (
84
+ self.tasks_dir
85
+ / DIR_ARCHIVE
86
+ / archive_date[:7]
87
+ / task_dir.name
88
+ )
89
+ relationship_updates = self._relationship_updates(
90
+ task_dir.name,
91
+ task_data,
92
+ )
93
+
94
+ move_result = archive_directory_resumable(task_dir, destination)
95
+ if not move_result.ok:
96
+ raise TaskArchiveError(
97
+ "TASK-ARCHIVE-MOVE-001",
98
+ destination,
99
+ move_result.message,
100
+ )
101
+
102
+ try:
103
+ self.repository.save(
104
+ destination,
105
+ {
106
+ "status": "completed",
107
+ "completedAt": archive_date,
108
+ },
109
+ )
110
+ self._apply_relationship_updates(relationship_updates)
111
+ except (TaskRepositoryError, TaskArchiveError) as error:
112
+ self._rollback(
113
+ task_dir,
114
+ destination,
115
+ task_data,
116
+ relationship_updates,
117
+ )
118
+ if isinstance(error, TaskArchiveError):
119
+ raise
120
+ raise TaskArchiveError(
121
+ "TASK-ARCHIVE-WRITE-001",
122
+ error.path,
123
+ error.detail,
124
+ ) from error
125
+
126
+ clear_task_from_sessions(
127
+ self.repo_root,
128
+ task_dir.relative_to(self.repo_root).as_posix(),
129
+ )
130
+ return TaskArchiveResult(
131
+ source=task_dir,
132
+ destination=destination,
133
+ task_name=task_dir.name,
134
+ archived_at=archive_date,
135
+ )
136
+
137
+ def _relationship_updates(
138
+ self,
139
+ task_name: str,
140
+ task_data: dict,
141
+ ) -> list[tuple[Path, dict, dict]]:
142
+ updates: list[tuple[Path, dict, dict]] = []
143
+ parent_name = task_data.get("parent")
144
+ if isinstance(parent_name, str) and parent_name.strip():
145
+ parent_dir = find_task_by_name(parent_name, self.tasks_dir)
146
+ if parent_dir is not None:
147
+ try:
148
+ parent_data = self.repository.load(parent_dir)
149
+ except TaskRepositoryError:
150
+ parent_data = {}
151
+ if parent_data:
152
+ children = list(parent_data.get("children") or [])
153
+ if task_name in children:
154
+ children.remove(task_name)
155
+ updates.append(
156
+ (
157
+ parent_dir,
158
+ parent_data,
159
+ {"children": children},
160
+ )
161
+ )
162
+
163
+ for child_name in task_data.get("children") or []:
164
+ child_dir = find_task_by_name(str(child_name), self.tasks_dir)
165
+ if child_dir is None:
166
+ continue
167
+ try:
168
+ child_data = self.repository.load(child_dir)
169
+ except TaskRepositoryError:
170
+ continue
171
+ updates.append(
172
+ (
173
+ child_dir,
174
+ child_data,
175
+ {"parent": None},
176
+ )
177
+ )
178
+ return updates
179
+
180
+ def _apply_relationship_updates(
181
+ self,
182
+ updates: list[tuple[Path, dict, dict]],
183
+ ) -> None:
184
+ applied: list[tuple[Path, dict]] = []
185
+ try:
186
+ for task_dir, original, changes in updates:
187
+ self.repository.save(task_dir, changes)
188
+ applied.append((task_dir, original))
189
+ except TaskRepositoryError as error:
190
+ for task_dir, original in reversed(applied):
191
+ try:
192
+ self.repository.replace(task_dir, original)
193
+ except TaskRepositoryError:
194
+ pass
195
+ raise TaskArchiveError(
196
+ "TASK-ARCHIVE-RELATIONSHIP-001",
197
+ error.path,
198
+ error.detail,
199
+ ) from error
200
+
201
+ def _rollback(
202
+ self,
203
+ source: Path,
204
+ destination: Path,
205
+ task_data: dict,
206
+ relationship_updates: list[tuple[Path, dict, dict]],
207
+ ) -> None:
208
+ for task_dir, original, _ in relationship_updates:
209
+ try:
210
+ self.repository.replace(task_dir, original)
211
+ except TaskRepositoryError:
212
+ pass
213
+
214
+ if destination.is_dir() and not source.exists():
215
+ archive_directory_resumable(destination, source)
216
+ if source.is_dir():
217
+ try:
218
+ self.repository.replace(source, task_data)
219
+ except TaskRepositoryError:
220
+ pass