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,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
|