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,743 @@
1
+ #!/usr/bin/env python3
2
+ """Recoverable task-graph Batch execution state machine."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ from application.runtime_context_service import (
13
+ RuntimeContextError,
14
+ RuntimeContextService,
15
+ )
16
+ from application.task_tree import TaskTreeService
17
+ from common.storage.state_store import StateStore, StateStoreError
18
+
19
+
20
+ BATCH_STEPS = (
21
+ "start_task",
22
+ "init_implement_context",
23
+ "await_implement_result",
24
+ "review_task",
25
+ "init_check_context",
26
+ "await_check_result",
27
+ "complete_task",
28
+ "commit_task",
29
+ )
30
+ STEP_PHASES = {
31
+ "start_task": "start",
32
+ "await_implement_result": "implement",
33
+ "review_task": "review",
34
+ "await_check_result": "check",
35
+ "complete_task": "complete",
36
+ "commit_task": "commit",
37
+ }
38
+ LIFECYCLE_STATUSES = {
39
+ "start_task": "in_progress",
40
+ "review_task": "review",
41
+ "complete_task": "completed",
42
+ }
43
+ RUNTIME_ROLES = {
44
+ "init_implement_context": "implement",
45
+ "await_implement_result": "implement",
46
+ "init_check_context": "check",
47
+ "await_check_result": "check",
48
+ }
49
+ CommitVerifier = Callable[[str], bool]
50
+
51
+
52
+ class BatchExecutionError(RuntimeError):
53
+ """Raised when Batch graph, state, or host input is invalid."""
54
+
55
+ def __init__(self, code: str, detail: str) -> None:
56
+ self.code = code
57
+ self.detail = detail
58
+ super().__init__(f"{code}: {detail}")
59
+
60
+
61
+ class BatchExecutionService:
62
+ """Publish and verify one host-neutral Batch action at a time."""
63
+
64
+ def __init__(
65
+ self,
66
+ repo_root: Path,
67
+ *,
68
+ state_store: StateStore | None = None,
69
+ commit_verifier: CommitVerifier | None = None,
70
+ ) -> None:
71
+ self.repo_root = Path(repo_root)
72
+ self.state_store = state_store or StateStore()
73
+ self.tree = TaskTreeService(self.repo_root)
74
+ self.runtime_contexts = RuntimeContextService(
75
+ self.repo_root,
76
+ state_store=self.state_store,
77
+ )
78
+ self.commit_verifier = commit_verifier or self._verify_git_commit
79
+
80
+ def start(self, root_task: str) -> dict:
81
+ ordered_tasks, graph_digest = self._plan(root_task)
82
+ operation_id = f"batch-{root_task}"
83
+ path = self._state_path(operation_id)
84
+ snapshot = self._load(path)
85
+ if snapshot.exists:
86
+ state = dict(snapshot.data)
87
+ if state.get("graph_digest") != graph_digest:
88
+ raise BatchExecutionError(
89
+ "BATCH-GRAPH-CHANGED-001",
90
+ "task graph changed after Batch state was created",
91
+ )
92
+ return self._advance(path, state)
93
+
94
+ state = {
95
+ "schema_version": 2,
96
+ "operation_id": operation_id,
97
+ "root_task": root_task,
98
+ "graph_digest": graph_digest,
99
+ "ordered_tasks": list(ordered_tasks),
100
+ "current_task": None,
101
+ "completed_tasks": [],
102
+ "commits": {},
103
+ "retry_count": {},
104
+ "task_phases": {},
105
+ "task_steps": {},
106
+ "runtime_contexts": {},
107
+ "action_results": {},
108
+ "next_action_sequence": 0,
109
+ "next_action": None,
110
+ "phase": "ready",
111
+ "pause_reason": None,
112
+ }
113
+ self._save(path, state)
114
+ return self._advance(path, state)
115
+
116
+ def resume(self, operation_id: str) -> dict:
117
+ path = self._state_path(operation_id)
118
+ snapshot = self._load(path)
119
+ if not snapshot.exists:
120
+ raise BatchExecutionError(
121
+ "BATCH-STATE-MISSING-001",
122
+ f"Batch state does not exist: {operation_id}",
123
+ )
124
+ state = dict(snapshot.data)
125
+ self._assert_graph_unchanged(state)
126
+ if state.get("phase") == "paused":
127
+ state["phase"] = "running"
128
+ state["pause_reason"] = None
129
+ state["next_action"] = None
130
+ self._save(path, state)
131
+ return self._advance(path, state)
132
+
133
+ def record_result(self, operation_id: str, payload: dict) -> dict:
134
+ path = self._state_path(operation_id)
135
+ snapshot = self._load(path)
136
+ if not snapshot.exists:
137
+ raise BatchExecutionError(
138
+ "BATCH-STATE-MISSING-001",
139
+ f"Batch state does not exist: {operation_id}",
140
+ )
141
+ state = dict(snapshot.data)
142
+ self._assert_graph_unchanged(state)
143
+ result = self._normalize_result(payload)
144
+ action_id = result["action_id"]
145
+ previous = dict(state.get("action_results") or {}).get(action_id)
146
+ if previous is not None:
147
+ if previous != result:
148
+ raise BatchExecutionError(
149
+ "BATCH-RESULT-CONFLICT-001",
150
+ f"action result changed after recording: {action_id}",
151
+ )
152
+ return state
153
+
154
+ action = state.get("next_action")
155
+ if not isinstance(action, dict):
156
+ raise BatchExecutionError(
157
+ "BATCH-ACTION-MISSING-001",
158
+ "Batch is not waiting for a Host action",
159
+ )
160
+ if action_id != action.get("action_id"):
161
+ raise BatchExecutionError(
162
+ "BATCH-ACTION-MISMATCH-001",
163
+ f"expected action {action.get('action_id')}, got {action_id}",
164
+ )
165
+ if result["type"] != action.get("type"):
166
+ raise BatchExecutionError(
167
+ "BATCH-ACTION-TYPE-001",
168
+ f"expected action type {action.get('type')}, "
169
+ f"got {result['type']}",
170
+ )
171
+
172
+ if result["outcome"] != "success":
173
+ detail = self._optional_text(result, "detail")
174
+ return self._pause(
175
+ path,
176
+ state,
177
+ action,
178
+ result,
179
+ detail or "Host action reported failure",
180
+ )
181
+
182
+ try:
183
+ self._verify_success(state, action, result)
184
+ except (BatchExecutionError, RuntimeContextError) as error:
185
+ detail = getattr(error, "detail", str(error))
186
+ return self._pause(
187
+ path,
188
+ state,
189
+ action,
190
+ result,
191
+ detail,
192
+ )
193
+
194
+ action_results = dict(state.get("action_results") or {})
195
+ action_results[action_id] = result
196
+ state["action_results"] = action_results
197
+ self._mark_step_completed(state, action, result)
198
+ state["next_action"] = None
199
+ state["phase"] = "running"
200
+ state["pause_reason"] = None
201
+ self._save(path, state)
202
+ return self._advance(path, state)
203
+
204
+ def _advance(self, path: Path, state: dict) -> dict:
205
+ if state.get("phase") in {"completed", "paused"}:
206
+ return state
207
+ if isinstance(state.get("next_action"), dict):
208
+ return state
209
+
210
+ completed_tasks = list(state.get("completed_tasks") or [])
211
+ task_steps = dict(state.get("task_steps") or {})
212
+ retry_count = dict(state.get("retry_count") or {})
213
+
214
+ for task_name in state["ordered_tasks"]:
215
+ if task_name in completed_tasks:
216
+ continue
217
+ state["current_task"] = task_name
218
+ completed_steps = list(task_steps.get(task_name) or [])
219
+ for step in BATCH_STEPS:
220
+ if step in completed_steps:
221
+ continue
222
+ action = self._build_action(state, task_name, step)
223
+ state["next_action"] = action
224
+ state["phase"] = "awaiting_host"
225
+ self._save(path, state)
226
+ return state
227
+
228
+ completed_tasks.append(task_name)
229
+ state["completed_tasks"] = completed_tasks
230
+ retry_count[task_name] = 0
231
+ state["retry_count"] = retry_count
232
+ state["current_task"] = None
233
+ self._save(path, state)
234
+
235
+ state["phase"] = "completed"
236
+ state["current_task"] = None
237
+ state["next_action"] = None
238
+ state["pause_reason"] = None
239
+ self._save(path, state)
240
+ return state
241
+
242
+ def _build_action(
243
+ self,
244
+ state: dict,
245
+ task_name: str,
246
+ step: str,
247
+ ) -> dict:
248
+ sequence = int(state.get("next_action_sequence") or 0) + 1
249
+ state["next_action_sequence"] = sequence
250
+ action = {
251
+ "action_id": (
252
+ f"{state['operation_id']}:{sequence}:{task_name}:{step}"
253
+ ),
254
+ "type": step,
255
+ "phase": self._phase_for_step(step),
256
+ "task": task_name,
257
+ "task_dir": f".cowork-flow/tasks/{task_name}",
258
+ }
259
+ role = RUNTIME_ROLES.get(step)
260
+ if step.startswith("init_") and role:
261
+ action.update(
262
+ {
263
+ "role": role,
264
+ "agent_type": f"cowork-{role}",
265
+ "title": (
266
+ f"{role.capitalize()} Batch task {task_name}"
267
+ ),
268
+ }
269
+ )
270
+ if step.startswith("await_") and role:
271
+ runtime = self._runtime_for(state, task_name, role)
272
+ action.update(
273
+ {
274
+ "role": role,
275
+ "runtime_context_id": runtime["runtime_context_id"],
276
+ "host_context_key": runtime["host_context_key"],
277
+ }
278
+ )
279
+ return action
280
+
281
+ def _verify_success(
282
+ self,
283
+ state: dict,
284
+ action: dict,
285
+ result: dict,
286
+ ) -> None:
287
+ action_type = str(action["type"])
288
+ task_name = str(action["task"])
289
+ if action_type in LIFECYCLE_STATUSES:
290
+ self._verify_task_status(
291
+ task_name,
292
+ LIFECYCLE_STATUSES[action_type],
293
+ result,
294
+ )
295
+ return
296
+ if action_type.startswith("init_"):
297
+ self._verify_runtime_initialized(
298
+ state,
299
+ task_name,
300
+ RUNTIME_ROLES[action_type],
301
+ result,
302
+ )
303
+ return
304
+ if action_type.startswith("await_"):
305
+ self._verify_runtime_completed(
306
+ state,
307
+ task_name,
308
+ RUNTIME_ROLES[action_type],
309
+ action,
310
+ result,
311
+ )
312
+ return
313
+ if action_type == "commit_task":
314
+ commit_id = self._required_text(
315
+ result,
316
+ "commit_id",
317
+ "commit result is missing commit_id",
318
+ )
319
+ if not self.commit_verifier(commit_id):
320
+ raise BatchExecutionError(
321
+ "BATCH-COMMIT-VERIFY-001",
322
+ f"commit could not be verified: {commit_id}",
323
+ )
324
+ return
325
+ raise BatchExecutionError(
326
+ "BATCH-ACTION-UNKNOWN-001",
327
+ f"unsupported Batch action: {action_type}",
328
+ )
329
+
330
+ def _verify_task_status(
331
+ self,
332
+ task_name: str,
333
+ expected_status: str,
334
+ result: dict,
335
+ ) -> None:
336
+ reported = self._optional_text(result, "task_status")
337
+ if reported and reported != expected_status:
338
+ raise BatchExecutionError(
339
+ "BATCH-TASK-STATUS-001",
340
+ f"Host reported task status {reported}; "
341
+ f"expected {expected_status}",
342
+ )
343
+ task_path = (
344
+ self.repo_root
345
+ / ".cowork-flow"
346
+ / "tasks"
347
+ / task_name
348
+ / "task.json"
349
+ )
350
+ try:
351
+ task_data = json.loads(
352
+ task_path.read_text(encoding="utf-8")
353
+ )
354
+ except (OSError, json.JSONDecodeError) as error:
355
+ raise BatchExecutionError(
356
+ "BATCH-TASK-LOAD-001",
357
+ f"cannot load task status for {task_name}: {error}",
358
+ ) from error
359
+ actual = task_data.get("status")
360
+ if actual != expected_status:
361
+ raise BatchExecutionError(
362
+ "BATCH-TASK-STATUS-001",
363
+ f"task status for {task_name} is {actual}; "
364
+ f"expected {expected_status}",
365
+ )
366
+
367
+ def _verify_runtime_initialized(
368
+ self,
369
+ state: dict,
370
+ task_name: str,
371
+ role: str,
372
+ result: dict,
373
+ ) -> None:
374
+ runtime_context_id = self._required_text(
375
+ result,
376
+ "runtime_context_id",
377
+ "runtime initialization result is missing runtime_context_id",
378
+ )
379
+ host_context_key = self._required_text(
380
+ result,
381
+ "host_context_key",
382
+ "runtime initialization result is missing host_context_key",
383
+ )
384
+ context = self.runtime_contexts.load(runtime_context_id)
385
+ self._verify_runtime_identity(
386
+ context,
387
+ task_name,
388
+ role,
389
+ runtime_context_id,
390
+ )
391
+ status = context.get("status")
392
+ if status == "closed":
393
+ raise BatchExecutionError(
394
+ "BATCH-RUNTIME-CLOSED-001",
395
+ f"runtime context is already closed: {runtime_context_id}",
396
+ )
397
+ bound_key = context.get("bound_context_key")
398
+ if bound_key and bound_key != host_context_key:
399
+ raise BatchExecutionError(
400
+ "BATCH-RUNTIME-BIND-001",
401
+ f"runtime context {runtime_context_id} is bound to "
402
+ f"{bound_key}, not {host_context_key}",
403
+ )
404
+ runtime_contexts = dict(state.get("runtime_contexts") or {})
405
+ task_contexts = dict(runtime_contexts.get(task_name) or {})
406
+ task_contexts[role] = {
407
+ "runtime_context_id": runtime_context_id,
408
+ "host_context_key": host_context_key,
409
+ }
410
+ runtime_contexts[task_name] = task_contexts
411
+ state["runtime_contexts"] = runtime_contexts
412
+
413
+ def _verify_runtime_completed(
414
+ self,
415
+ state: dict,
416
+ task_name: str,
417
+ role: str,
418
+ action: dict,
419
+ result: dict,
420
+ ) -> None:
421
+ runtime = self._runtime_for(state, task_name, role)
422
+ runtime_context_id = self._required_text(
423
+ result,
424
+ "runtime_context_id",
425
+ "runtime result is missing runtime_context_id",
426
+ )
427
+ host_context_key = self._required_text(
428
+ result,
429
+ "host_context_key",
430
+ "runtime result is missing host_context_key",
431
+ )
432
+ if runtime_context_id != runtime["runtime_context_id"]:
433
+ raise BatchExecutionError(
434
+ "BATCH-RUNTIME-ID-001",
435
+ "runtime result does not match initialized context",
436
+ )
437
+ if host_context_key != runtime["host_context_key"]:
438
+ raise BatchExecutionError(
439
+ "BATCH-RUNTIME-BIND-001",
440
+ "runtime result does not match expected host context",
441
+ )
442
+ if action.get("runtime_context_id") != runtime_context_id:
443
+ raise BatchExecutionError(
444
+ "BATCH-RUNTIME-ID-001",
445
+ "pending action runtime context changed",
446
+ )
447
+ context = self.runtime_contexts.load(runtime_context_id)
448
+ self._verify_runtime_identity(
449
+ context,
450
+ task_name,
451
+ role,
452
+ runtime_context_id,
453
+ )
454
+ status = context.get("status")
455
+ if status not in {"bound", "closed"}:
456
+ raise BatchExecutionError(
457
+ "BATCH-RUNTIME-BIND-001",
458
+ f"runtime context {runtime_context_id} is not bound",
459
+ )
460
+ if context.get("bound_context_key") != host_context_key:
461
+ raise BatchExecutionError(
462
+ "BATCH-RUNTIME-BIND-001",
463
+ f"runtime context {runtime_context_id} has unexpected "
464
+ "bound_context_key",
465
+ )
466
+ if status == "bound":
467
+ if not self.runtime_contexts.close(runtime_context_id):
468
+ raise BatchExecutionError(
469
+ "BATCH-RUNTIME-CLOSE-001",
470
+ "runtime context could not be closed: "
471
+ f"{runtime_context_id}",
472
+ )
473
+ closed = self.runtime_contexts.load(runtime_context_id)
474
+ if closed.get("status") != "closed":
475
+ raise BatchExecutionError(
476
+ "BATCH-RUNTIME-CLOSE-001",
477
+ f"runtime context did not close: {runtime_context_id}",
478
+ )
479
+
480
+ @staticmethod
481
+ def _verify_runtime_identity(
482
+ context: dict,
483
+ task_name: str,
484
+ role: str,
485
+ runtime_context_id: str,
486
+ ) -> None:
487
+ if not context:
488
+ raise BatchExecutionError(
489
+ "BATCH-RUNTIME-MISSING-001",
490
+ f"runtime context does not exist: {runtime_context_id}",
491
+ )
492
+ if context.get("scope") != "subagent":
493
+ raise BatchExecutionError(
494
+ "BATCH-RUNTIME-SCOPE-001",
495
+ f"runtime context is not subagent-scoped: "
496
+ f"{runtime_context_id}",
497
+ )
498
+ if context.get("role") != role:
499
+ raise BatchExecutionError(
500
+ "BATCH-RUNTIME-ROLE-001",
501
+ f"runtime context role is not {role}: "
502
+ f"{runtime_context_id}",
503
+ )
504
+ expected_task_dir = f".cowork-flow/tasks/{task_name}"
505
+ if context.get("task_dir") != expected_task_dir:
506
+ raise BatchExecutionError(
507
+ "BATCH-RUNTIME-TASK-001",
508
+ f"runtime context task_dir is not {expected_task_dir}",
509
+ )
510
+
511
+ def _mark_step_completed(
512
+ self,
513
+ state: dict,
514
+ action: dict,
515
+ result: dict,
516
+ ) -> None:
517
+ task_name = str(action["task"])
518
+ step = str(action["type"])
519
+ task_steps = dict(state.get("task_steps") or {})
520
+ completed_steps = list(task_steps.get(task_name) or [])
521
+ if step not in completed_steps:
522
+ completed_steps.append(step)
523
+ task_steps[task_name] = completed_steps
524
+ state["task_steps"] = task_steps
525
+
526
+ phase = STEP_PHASES.get(step)
527
+ if phase:
528
+ task_phases = dict(state.get("task_phases") or {})
529
+ completed_phases = list(task_phases.get(task_name) or [])
530
+ if phase not in completed_phases:
531
+ completed_phases.append(phase)
532
+ task_phases[task_name] = completed_phases
533
+ state["task_phases"] = task_phases
534
+
535
+ if step == "commit_task":
536
+ commits = dict(state.get("commits") or {})
537
+ commits[task_name] = result["commit_id"].strip()
538
+ state["commits"] = commits
539
+
540
+ def _pause(
541
+ self,
542
+ path: Path,
543
+ state: dict,
544
+ action: dict,
545
+ result: dict,
546
+ detail: str,
547
+ ) -> dict:
548
+ task_name = str(action["task"])
549
+ retry_count = dict(state.get("retry_count") or {})
550
+ retry_count[task_name] = int(retry_count.get(task_name, 0)) + 1
551
+ state["retry_count"] = retry_count
552
+ action_results = dict(state.get("action_results") or {})
553
+ action_results[result["action_id"]] = dict(result)
554
+ state["action_results"] = action_results
555
+ state["phase"] = "paused"
556
+ state["current_task"] = task_name
557
+ state["next_action"] = None
558
+ state["pause_reason"] = (
559
+ f"{task_name}:{action['type']}: {detail}"
560
+ )
561
+ self._save(path, state)
562
+ return state
563
+
564
+ def _runtime_for(
565
+ self,
566
+ state: dict,
567
+ task_name: str,
568
+ role: str,
569
+ ) -> dict:
570
+ runtime_contexts = dict(state.get("runtime_contexts") or {})
571
+ task_contexts = dict(runtime_contexts.get(task_name) or {})
572
+ runtime = task_contexts.get(role)
573
+ if not isinstance(runtime, dict):
574
+ raise BatchExecutionError(
575
+ "BATCH-RUNTIME-MISSING-001",
576
+ f"runtime context was not initialized for "
577
+ f"{task_name}:{role}",
578
+ )
579
+ return runtime
580
+
581
+ def _assert_graph_unchanged(self, state: dict) -> None:
582
+ root_task = state.get("root_task")
583
+ if not isinstance(root_task, str) or not root_task.strip():
584
+ raise BatchExecutionError(
585
+ "BATCH-STATE-ROOT-001",
586
+ "Batch state is missing root_task",
587
+ )
588
+ _, graph_digest = self._plan(root_task)
589
+ if state.get("graph_digest") != graph_digest:
590
+ raise BatchExecutionError(
591
+ "BATCH-GRAPH-CHANGED-001",
592
+ "task graph changed after Batch state was created",
593
+ )
594
+
595
+ def _plan(self, root_task: str) -> tuple[tuple[str, ...], str]:
596
+ nodes = self.tree.active_nodes()
597
+ if root_task not in nodes:
598
+ raise BatchExecutionError(
599
+ "BATCH-GRAPH-MISSING-001",
600
+ f"root task does not exist: {root_task}",
601
+ )
602
+ ordered: list[str] = []
603
+ visiting: set[str] = set()
604
+ visited: set[str] = set()
605
+
606
+ def visit(name: str) -> None:
607
+ if name in visiting:
608
+ raise BatchExecutionError(
609
+ "BATCH-GRAPH-CYCLE-001",
610
+ f"task graph cycle detected at {name}",
611
+ )
612
+ if name in visited:
613
+ return
614
+ node = nodes.get(name)
615
+ if node is None:
616
+ raise BatchExecutionError(
617
+ "BATCH-GRAPH-MISSING-001",
618
+ f"task graph references missing task: {name}",
619
+ )
620
+ visiting.add(name)
621
+ for child in node.children:
622
+ visit(child)
623
+ visiting.remove(name)
624
+ visited.add(name)
625
+ if not node.children:
626
+ ordered.append(name)
627
+
628
+ visit(root_task)
629
+ graph_payload = {
630
+ name: list(nodes[name].children)
631
+ for name in sorted(visited)
632
+ }
633
+ digest = hashlib.sha256(
634
+ json.dumps(
635
+ graph_payload,
636
+ sort_keys=True,
637
+ separators=(",", ":"),
638
+ ).encode("utf-8")
639
+ ).hexdigest()
640
+ return tuple(ordered), digest
641
+
642
+ def _verify_git_commit(self, commit_id: str) -> bool:
643
+ try:
644
+ completed = subprocess.run(
645
+ [
646
+ "git",
647
+ "-C",
648
+ str(self.repo_root),
649
+ "cat-file",
650
+ "-e",
651
+ f"{commit_id}^{{commit}}",
652
+ ],
653
+ check=False,
654
+ capture_output=True,
655
+ encoding="utf-8",
656
+ errors="replace",
657
+ )
658
+ except OSError:
659
+ return False
660
+ return completed.returncode == 0
661
+
662
+ def _state_path(self, operation_id: str) -> Path:
663
+ return (
664
+ self.repo_root
665
+ / ".cowork-flow"
666
+ / "runtime"
667
+ / "batches"
668
+ / f"{operation_id}.json"
669
+ )
670
+
671
+ def _load(self, path: Path):
672
+ try:
673
+ return self.state_store.load(path, missing_ok=True)
674
+ except StateStoreError as error:
675
+ raise BatchExecutionError(
676
+ "BATCH-STATE-LOAD-001",
677
+ error.detail,
678
+ ) from error
679
+
680
+ def _save(self, path: Path, state: dict) -> None:
681
+ try:
682
+ snapshot = self.state_store.load(path, missing_ok=True)
683
+ self.state_store.replace(
684
+ path,
685
+ state,
686
+ expected_revision=snapshot.revision,
687
+ operation_id=(
688
+ f"{state['operation_id']}:checkpoint:"
689
+ f"{snapshot.revision + 1}"
690
+ ),
691
+ )
692
+ except StateStoreError as error:
693
+ raise BatchExecutionError(
694
+ "BATCH-STATE-SAVE-001",
695
+ error.detail,
696
+ ) from error
697
+
698
+ @staticmethod
699
+ def _phase_for_step(step: str) -> str:
700
+ if "implement" in step:
701
+ return "implement"
702
+ if "check" in step:
703
+ return "check"
704
+ return STEP_PHASES[step]
705
+
706
+ @staticmethod
707
+ def _normalize_result(payload: dict) -> dict:
708
+ if not isinstance(payload, dict):
709
+ raise BatchExecutionError(
710
+ "BATCH-RESULT-PAYLOAD-001",
711
+ "Host action result must be a JSON object",
712
+ )
713
+ result = dict(payload)
714
+ for key in ("action_id", "type", "outcome"):
715
+ value = result.get(key)
716
+ if not isinstance(value, str) or not value.strip():
717
+ raise BatchExecutionError(
718
+ "BATCH-RESULT-PAYLOAD-001",
719
+ f"Host action result is missing {key}",
720
+ )
721
+ result[key] = value.strip()
722
+ return result
723
+
724
+ @staticmethod
725
+ def _required_text(
726
+ payload: dict,
727
+ key: str,
728
+ detail: str,
729
+ ) -> str:
730
+ value = payload.get(key)
731
+ if not isinstance(value, str) or not value.strip():
732
+ raise BatchExecutionError(
733
+ "BATCH-RESULT-PAYLOAD-001",
734
+ detail,
735
+ )
736
+ return value.strip()
737
+
738
+ @staticmethod
739
+ def _optional_text(payload: dict, key: str) -> str | None:
740
+ value = payload.get(key)
741
+ if isinstance(value, str) and value.strip():
742
+ return value.strip()
743
+ return None