cowork-flow 0.0.48 → 0.0.49

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 (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. package/template/skills/test-first/manifest.json +8 -4
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Read-only Batch inspect and recovery projections."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from batch_state_machine import BATCH_STEPS, phase_for_step
10
+
11
+
12
+ def failed_action(state: dict[str, Any]) -> dict[str, Any] | None:
13
+ """Return public facts for the action that paused Batch, if any."""
14
+
15
+ if state.get("phase") != "paused":
16
+ return None
17
+ action_results = dict(state.get("action_results") or {})
18
+ if not action_results:
19
+ return None
20
+ action_id, result = next(reversed(action_results.items()))
21
+ if not isinstance(result, dict):
22
+ return {"actionId": str(action_id)}
23
+ failed: dict[str, Any] = {"actionId": str(action_id)}
24
+ result_type = result.get("type")
25
+ if isinstance(result_type, str):
26
+ failed["type"] = result_type
27
+ outcome = result.get("outcome")
28
+ if isinstance(outcome, str):
29
+ failed["outcome"] = outcome
30
+ detail = result.get("detail")
31
+ if isinstance(detail, str) and detail.strip():
32
+ failed["detail"] = detail.strip()
33
+ return failed
34
+
35
+
36
+ def current_phase(
37
+ state: dict[str, Any],
38
+ failed_action_facts: dict[str, Any] | None,
39
+ ) -> str | None:
40
+ """Project the current user-visible Batch phase without mutating state."""
41
+
42
+ next_action = state.get("next_action")
43
+ if isinstance(next_action, dict):
44
+ phase = next_action.get("phase")
45
+ return str(phase) if isinstance(phase, str) else None
46
+ if failed_action_facts is not None:
47
+ action_type = failed_action_facts.get("type")
48
+ if isinstance(action_type, str) and action_type in BATCH_STEPS:
49
+ return phase_for_step(action_type)
50
+ task_name = state.get("current_task")
51
+ if isinstance(task_name, str):
52
+ task_phases = dict(state.get("task_phases") or {})
53
+ phases = task_phases.get(task_name)
54
+ if isinstance(phases, list) and phases:
55
+ phase = phases[-1]
56
+ return str(phase) if isinstance(phase, str) else None
57
+ phase = state.get("phase")
58
+ return str(phase) if isinstance(phase, str) else None
59
+
60
+
61
+ def recovery_facts(state: dict[str, Any]) -> dict[str, object]:
62
+ """Return read-only recovery hints for a paused Batch operation."""
63
+
64
+ if state.get("phase") != "paused":
65
+ return {}
66
+ operation_id = state.get("operation_id")
67
+ recovery: dict[str, object] = {}
68
+ if isinstance(operation_id, str) and operation_id.strip():
69
+ recovery["resumeCommand"] = f"batch-action resume {operation_id.strip()}"
70
+ task_name = state.get("current_task")
71
+ if isinstance(task_name, str) and task_name.strip():
72
+ recovery["retryTask"] = task_name.strip()
73
+ pause_reason = state.get("pause_reason")
74
+ if isinstance(pause_reason, str) and pause_reason.strip():
75
+ recovery["pausedReason"] = pause_reason.strip()
76
+ return recovery
77
+
78
+
79
+ def inspect_projection(operation_id: str, state: dict[str, Any]) -> dict[str, Any]:
80
+ """Build the public inspect payload without state mutation or IO."""
81
+
82
+ failed = failed_action(state)
83
+ return {
84
+ "operationId": operation_id,
85
+ "state": state.get("phase"),
86
+ "rootTask": state.get("root_task"),
87
+ "currentPhase": current_phase(state, failed),
88
+ "currentTask": state.get("current_task"),
89
+ "completedTasks": list(state.get("completed_tasks") or []),
90
+ "pausedReason": state.get("pause_reason"),
91
+ "failedAction": failed,
92
+ "nextAction": state.get("next_action"),
93
+ "recovery": recovery_facts(state),
94
+ }
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Pure in-memory Batch state transitions and action construction."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ BATCH_STEPS = (
10
+ "start_task",
11
+ "init_implement_context",
12
+ "await_implement_result",
13
+ "review_task",
14
+ "init_check_context",
15
+ "await_check_result",
16
+ "complete_task",
17
+ "archive_task",
18
+ "commit_task",
19
+ )
20
+ STEP_PHASES = {
21
+ "start_task": "start",
22
+ "await_implement_result": "implement",
23
+ "review_task": "review",
24
+ "await_check_result": "check",
25
+ "complete_task": "complete",
26
+ "archive_task": "archive",
27
+ "commit_task": "commit",
28
+ }
29
+ LIFECYCLE_STATUSES = {
30
+ "start_task": "in_progress",
31
+ "review_task": "review",
32
+ "complete_task": "completed",
33
+ "archive_task": "completed",
34
+ }
35
+ RUNTIME_ROLES = {
36
+ "init_implement_context": "implement",
37
+ "await_implement_result": "implement",
38
+ "init_check_context": "check",
39
+ "await_check_result": "check",
40
+ }
41
+
42
+
43
+ class BatchExecutionError(RuntimeError):
44
+ """Raised when Batch graph, state, or host input is invalid."""
45
+
46
+ def __init__(self, code: str, detail: str) -> None:
47
+ self.code = code
48
+ self.detail = detail
49
+ super().__init__(f"{code}: {detail}")
50
+
51
+
52
+ def phase_for_step(step: str) -> str:
53
+ """Return the public Batch phase for a step name."""
54
+
55
+ if "implement" in step:
56
+ return "implement"
57
+ if "check" in step:
58
+ return "check"
59
+ return STEP_PHASES[step]
60
+
61
+
62
+ def runtime_for(state: dict[str, Any], task_name: str, role: str) -> dict[str, Any]:
63
+ """Return a previously initialized runtime context reference."""
64
+
65
+ runtime_contexts = dict(state.get("runtime_contexts") or {})
66
+ task_contexts = dict(runtime_contexts.get(task_name) or {})
67
+ runtime = task_contexts.get(role)
68
+ if not isinstance(runtime, dict):
69
+ raise BatchExecutionError(
70
+ "BATCH-RUNTIME-MISSING-001",
71
+ f"runtime context was not initialized for {task_name}:{role}",
72
+ )
73
+ return runtime
74
+
75
+
76
+ def build_action(state: dict[str, Any], task_name: str, step: str) -> dict[str, Any]:
77
+ """Build the next host-neutral Batch action without external side effects."""
78
+
79
+ sequence = int(state.get("next_action_sequence") or 0) + 1
80
+ state["next_action_sequence"] = sequence
81
+ action: dict[str, Any] = {
82
+ "action_id": f"{state['operation_id']}:{sequence}:{task_name}:{step}",
83
+ "type": step,
84
+ "phase": phase_for_step(step),
85
+ "task": task_name,
86
+ "task_dir": f".cowork-flow/tasks/{task_name}",
87
+ }
88
+ role = RUNTIME_ROLES.get(step)
89
+ if step.startswith("init_") and role:
90
+ action.update(
91
+ {
92
+ "role": role,
93
+ "agent_type": f"cowork-{role}",
94
+ "title": f"{role.capitalize()} Batch task {task_name}",
95
+ }
96
+ )
97
+ if step.startswith("await_") and role:
98
+ runtime = runtime_for(state, task_name, role)
99
+ action.update(
100
+ {
101
+ "role": role,
102
+ "runtime_context_id": runtime["runtime_context_id"],
103
+ "host_context_key": runtime["host_context_key"],
104
+ }
105
+ )
106
+ return action
107
+
108
+
109
+ def advance_state(state: dict[str, Any]) -> bool:
110
+ """Advance Batch state in memory until it needs Host input or is complete."""
111
+
112
+ if state.get("phase") in {"completed", "paused"}:
113
+ return False
114
+ if isinstance(state.get("next_action"), dict):
115
+ return False
116
+
117
+ changed = False
118
+ completed_tasks = list(state.get("completed_tasks") or [])
119
+ task_steps = dict(state.get("task_steps") or {})
120
+ retry_count = dict(state.get("retry_count") or {})
121
+
122
+ for task_name in state["ordered_tasks"]:
123
+ if task_name in completed_tasks:
124
+ continue
125
+ if state.get("current_task") != task_name:
126
+ state["current_task"] = task_name
127
+ changed = True
128
+ completed_steps = list(task_steps.get(task_name) or [])
129
+ for step in BATCH_STEPS:
130
+ if step in completed_steps:
131
+ continue
132
+ action = build_action(state, task_name, step)
133
+ state["next_action"] = action
134
+ state["phase"] = "awaiting_host"
135
+ return True
136
+
137
+ completed_tasks.append(task_name)
138
+ state["completed_tasks"] = completed_tasks
139
+ retry_count[task_name] = 0
140
+ state["retry_count"] = retry_count
141
+ state["current_task"] = None
142
+ changed = True
143
+
144
+ state["phase"] = "completed"
145
+ state["current_task"] = None
146
+ state["next_action"] = None
147
+ state["pause_reason"] = None
148
+ return True or changed
149
+
150
+
151
+ def mark_step_completed(
152
+ state: dict[str, Any],
153
+ action: dict[str, Any],
154
+ result: dict[str, Any],
155
+ ) -> None:
156
+ """Record a verified Host action result in the in-memory Batch state."""
157
+
158
+ task_name = str(action["task"])
159
+ step = str(action["type"])
160
+ task_steps = dict(state.get("task_steps") or {})
161
+ completed_steps = list(task_steps.get(task_name) or [])
162
+ if step not in completed_steps:
163
+ completed_steps.append(step)
164
+ task_steps[task_name] = completed_steps
165
+ state["task_steps"] = task_steps
166
+
167
+ phase = STEP_PHASES.get(step)
168
+ if phase:
169
+ task_phases = dict(state.get("task_phases") or {})
170
+ completed_phases = list(task_phases.get(task_name) or [])
171
+ if phase not in completed_phases:
172
+ completed_phases.append(phase)
173
+ task_phases[task_name] = completed_phases
174
+ state["task_phases"] = task_phases
175
+
176
+ if step == "commit_task":
177
+ commits = dict(state.get("commits") or {})
178
+ commits[task_name] = result["commit_id"].strip()
179
+ state["commits"] = commits
180
+
181
+
182
+ def pause_state(
183
+ state: dict[str, Any],
184
+ action: dict[str, Any],
185
+ result: dict[str, Any],
186
+ detail: str,
187
+ ) -> dict[str, Any]:
188
+ """Move Batch state to paused after a fail-closed Host result."""
189
+
190
+ task_name = str(action["task"])
191
+ retry_count = dict(state.get("retry_count") or {})
192
+ retry_count[task_name] = int(retry_count.get(task_name, 0)) + 1
193
+ state["retry_count"] = retry_count
194
+ action_results = dict(state.get("action_results") or {})
195
+ action_results[result["action_id"]] = dict(result)
196
+ state["action_results"] = action_results
197
+ state["phase"] = "paused"
198
+ state["current_task"] = task_name
199
+ state["next_action"] = None
200
+ state["pause_reason"] = f"{task_name}:{action['type']}: {detail}"
201
+ return state
@@ -0,0 +1,326 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Batch Host-result verification separated from state advancement."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ from batch_state_machine import (
12
+ LIFECYCLE_STATUSES,
13
+ RUNTIME_ROLES,
14
+ BatchExecutionError,
15
+ runtime_for,
16
+ )
17
+
18
+ CommitVerifier = Callable[[str], bool]
19
+
20
+
21
+ def normalize_result(payload: dict[str, Any]) -> dict[str, Any]:
22
+ """Normalize a Host action result and fail closed on malformed input."""
23
+
24
+ if not isinstance(payload, dict):
25
+ raise BatchExecutionError(
26
+ "BATCH-RESULT-PAYLOAD-001",
27
+ "Host action result must be a JSON object",
28
+ )
29
+ result = dict(payload)
30
+ for key in ("action_id", "type", "outcome"):
31
+ value = result.get(key)
32
+ if not isinstance(value, str) or not value.strip():
33
+ raise BatchExecutionError(
34
+ "BATCH-RESULT-PAYLOAD-001",
35
+ f"Host action result is missing {key}",
36
+ )
37
+ result[key] = value.strip()
38
+ return result
39
+
40
+
41
+ def required_text(payload: dict[str, Any], key: str, detail: str) -> str:
42
+ value = payload.get(key)
43
+ if not isinstance(value, str) or not value.strip():
44
+ raise BatchExecutionError("BATCH-RESULT-PAYLOAD-001", detail)
45
+ return value.strip()
46
+
47
+
48
+ def optional_text(payload: dict[str, Any], key: str) -> str | None:
49
+ value = payload.get(key)
50
+ if isinstance(value, str) and value.strip():
51
+ return value.strip()
52
+ return None
53
+
54
+
55
+ class BatchActionVerifier:
56
+ """Verify Host facts for one Batch action without advancing the state machine."""
57
+
58
+ def __init__(
59
+ self,
60
+ repo_root: Path,
61
+ runtime_contexts: Any,
62
+ commit_verifier: CommitVerifier,
63
+ ) -> None:
64
+ self.repo_root = Path(repo_root)
65
+ self.runtime_contexts = runtime_contexts
66
+ self.commit_verifier = commit_verifier
67
+
68
+ def verify_success(
69
+ self,
70
+ state: dict[str, Any],
71
+ action: dict[str, Any],
72
+ result: dict[str, Any],
73
+ ) -> None:
74
+ action_type = str(action["type"])
75
+ task_name = str(action["task"])
76
+ if action_type == "archive_task":
77
+ self.verify_archive_result(task_name, result)
78
+ return
79
+ if action_type in LIFECYCLE_STATUSES:
80
+ self.verify_task_status(task_name, LIFECYCLE_STATUSES[action_type], result)
81
+ return
82
+ if action_type.startswith("init_"):
83
+ self.verify_runtime_initialized(
84
+ state,
85
+ task_name,
86
+ RUNTIME_ROLES[action_type],
87
+ result,
88
+ )
89
+ return
90
+ if action_type.startswith("await_"):
91
+ self.verify_runtime_completed(
92
+ state,
93
+ task_name,
94
+ RUNTIME_ROLES[action_type],
95
+ action,
96
+ result,
97
+ )
98
+ return
99
+ if action_type == "commit_task":
100
+ commit_id = required_text(result, "commit_id", "commit result is missing commit_id")
101
+ if not self.commit_verifier(commit_id):
102
+ raise BatchExecutionError(
103
+ "BATCH-COMMIT-VERIFY-001",
104
+ f"commit_id could not be verified: {commit_id}",
105
+ )
106
+ return
107
+ raise BatchExecutionError(
108
+ "BATCH-ACTION-UNKNOWN-001",
109
+ f"unsupported Batch action: {action_type}",
110
+ )
111
+
112
+ def verify_task_status(
113
+ self,
114
+ task_name: str,
115
+ expected_status: str,
116
+ result: dict[str, Any],
117
+ ) -> None:
118
+ reported = optional_text(result, "task_status")
119
+ if reported and reported != expected_status:
120
+ raise BatchExecutionError(
121
+ "BATCH-TASK-STATUS-001",
122
+ f"Host reported task status {reported}; expected {expected_status}",
123
+ )
124
+ task_path = self.repo_root / ".cowork-flow" / "tasks" / task_name / "task.json"
125
+ try:
126
+ task_data = json.loads(task_path.read_text(encoding="utf-8"))
127
+ except (OSError, json.JSONDecodeError) as error:
128
+ raise BatchExecutionError(
129
+ "BATCH-TASK-LOAD-001",
130
+ f"cannot load task status for {task_name}: {error}",
131
+ ) from error
132
+ actual = task_data.get("status")
133
+ if actual != expected_status:
134
+ raise BatchExecutionError(
135
+ "BATCH-TASK-STATUS-001",
136
+ f"task status for {task_name} is {actual}; expected {expected_status}",
137
+ )
138
+
139
+ def verify_archive_result(self, task_name: str, result: dict[str, Any]) -> None:
140
+ destination = required_text(
141
+ result,
142
+ "archive_destination",
143
+ "archive result is missing archive_destination",
144
+ )
145
+ destination_path = Path(destination)
146
+ if destination_path.is_absolute():
147
+ raise BatchExecutionError(
148
+ "BATCH-ARCHIVE-VERIFY-001",
149
+ f"archive_destination must be relative: {destination}",
150
+ )
151
+ archive_root = (self.repo_root / ".cowork-flow" / "tasks" / "archive").resolve()
152
+ archive_path = (self.repo_root / destination_path).resolve()
153
+ try:
154
+ archive_path.relative_to(archive_root)
155
+ except ValueError as error:
156
+ raise BatchExecutionError(
157
+ "BATCH-ARCHIVE-VERIFY-001",
158
+ "archive_destination must be under "
159
+ f".cowork-flow/tasks/archive: {destination}",
160
+ ) from error
161
+ if archive_path.name != task_name:
162
+ raise BatchExecutionError(
163
+ "BATCH-ARCHIVE-VERIFY-001",
164
+ f"archive_destination task name is {archive_path.name}; expected {task_name}",
165
+ )
166
+ if not archive_path.is_dir():
167
+ raise BatchExecutionError(
168
+ "BATCH-ARCHIVE-VERIFY-001",
169
+ f"archive_destination does not exist: {destination}",
170
+ )
171
+ task_json = archive_path / "task.json"
172
+ try:
173
+ task_data = json.loads(task_json.read_text(encoding="utf-8"))
174
+ except (OSError, json.JSONDecodeError) as error:
175
+ raise BatchExecutionError(
176
+ "BATCH-ARCHIVE-VERIFY-001",
177
+ f"archive_destination task.json cannot be loaded: {error}",
178
+ ) from error
179
+ logical_task_names = {task_name}
180
+ if len(task_name) > 6 and task_name[2] == "-" and task_name[5] == "-":
181
+ logical_task_names.add(task_name[6:])
182
+ if not any(task_data.get(field) in logical_task_names for field in ("name", "id")):
183
+ raise BatchExecutionError(
184
+ "BATCH-ARCHIVE-VERIFY-001",
185
+ "archive_destination task identity is "
186
+ f"name={task_data.get('name')} id={task_data.get('id')}; "
187
+ f"expected one of {sorted(logical_task_names)}",
188
+ )
189
+ if task_data.get("status") != "completed":
190
+ raise BatchExecutionError(
191
+ "BATCH-ARCHIVE-VERIFY-001",
192
+ f"archive_destination task status is {task_data.get('status')}; expected completed",
193
+ )
194
+
195
+ def verify_runtime_initialized(
196
+ self,
197
+ state: dict[str, Any],
198
+ task_name: str,
199
+ role: str,
200
+ result: dict[str, Any],
201
+ ) -> None:
202
+ runtime_context_id = required_text(
203
+ result,
204
+ "runtime_context_id",
205
+ "runtime initialization result is missing runtime_context_id",
206
+ )
207
+ host_context_key = required_text(
208
+ result,
209
+ "host_context_key",
210
+ "runtime initialization result is missing host_context_key",
211
+ )
212
+ context = self.runtime_contexts.load(runtime_context_id)
213
+ self.verify_runtime_identity(context, task_name, role, runtime_context_id)
214
+ status = context.get("status")
215
+ if status == "closed":
216
+ raise BatchExecutionError(
217
+ "BATCH-RUNTIME-CLOSED-001",
218
+ f"runtime context is already closed: {runtime_context_id}",
219
+ )
220
+ bound_key = context.get("bound_context_key")
221
+ if bound_key and bound_key != host_context_key:
222
+ raise BatchExecutionError(
223
+ "BATCH-RUNTIME-BIND-001",
224
+ f"runtime context {runtime_context_id} is bound to {bound_key}, not {host_context_key}",
225
+ )
226
+ runtime_contexts = dict(state.get("runtime_contexts") or {})
227
+ task_contexts = dict(runtime_contexts.get(task_name) or {})
228
+ task_contexts[role] = {
229
+ "runtime_context_id": runtime_context_id,
230
+ "host_context_key": host_context_key,
231
+ }
232
+ runtime_contexts[task_name] = task_contexts
233
+ state["runtime_contexts"] = runtime_contexts
234
+
235
+ def verify_runtime_completed(
236
+ self,
237
+ state: dict[str, Any],
238
+ task_name: str,
239
+ role: str,
240
+ action: dict[str, Any],
241
+ result: dict[str, Any],
242
+ ) -> None:
243
+ runtime = runtime_for(state, task_name, role)
244
+ runtime_context_id = required_text(
245
+ result,
246
+ "runtime_context_id",
247
+ "runtime result is missing runtime_context_id",
248
+ )
249
+ host_context_key = required_text(
250
+ result,
251
+ "host_context_key",
252
+ "runtime result is missing host_context_key",
253
+ )
254
+ if runtime_context_id != runtime["runtime_context_id"]:
255
+ raise BatchExecutionError(
256
+ "BATCH-RUNTIME-ID-001",
257
+ "runtime_context_id does not match initialized context: "
258
+ f"{runtime_context_id}; expected {runtime['runtime_context_id']}",
259
+ )
260
+ if host_context_key != runtime["host_context_key"]:
261
+ raise BatchExecutionError(
262
+ "BATCH-RUNTIME-BIND-001",
263
+ "host_context_key does not match initialized context: "
264
+ f"{host_context_key}; expected {runtime['host_context_key']}",
265
+ )
266
+ if action.get("runtime_context_id") != runtime_context_id:
267
+ raise BatchExecutionError(
268
+ "BATCH-RUNTIME-ID-001",
269
+ "runtime_context_id does not match pending action: "
270
+ f"{runtime_context_id}; expected {action.get('runtime_context_id')}",
271
+ )
272
+ context = self.runtime_contexts.load(runtime_context_id)
273
+ self.verify_runtime_identity(context, task_name, role, runtime_context_id)
274
+ status = context.get("status")
275
+ if status not in {"bound", "closed"}:
276
+ raise BatchExecutionError(
277
+ "BATCH-RUNTIME-BIND-001",
278
+ f"runtime context {runtime_context_id} is not bound",
279
+ )
280
+ if context.get("bound_context_key") != host_context_key:
281
+ raise BatchExecutionError(
282
+ "BATCH-RUNTIME-BIND-001",
283
+ f"runtime context {runtime_context_id} bound_context_key does not match result: "
284
+ f"{context.get('bound_context_key')}; expected {host_context_key}",
285
+ )
286
+ if status == "bound":
287
+ if not self.runtime_contexts.close(runtime_context_id):
288
+ raise BatchExecutionError(
289
+ "BATCH-RUNTIME-CLOSE-001",
290
+ f"runtime context could not be closed: {runtime_context_id}",
291
+ )
292
+ closed = self.runtime_contexts.load(runtime_context_id)
293
+ if closed.get("status") != "closed":
294
+ raise BatchExecutionError(
295
+ "BATCH-RUNTIME-CLOSE-001",
296
+ f"runtime context did not close: {runtime_context_id}",
297
+ )
298
+
299
+ @staticmethod
300
+ def verify_runtime_identity(
301
+ context: dict[str, Any],
302
+ task_name: str,
303
+ role: str,
304
+ runtime_context_id: str,
305
+ ) -> None:
306
+ if not context:
307
+ raise BatchExecutionError(
308
+ "BATCH-RUNTIME-MISSING-001",
309
+ f"runtime context does not exist: {runtime_context_id}",
310
+ )
311
+ if context.get("scope") != "subagent":
312
+ raise BatchExecutionError(
313
+ "BATCH-RUNTIME-SCOPE-001",
314
+ f"runtime context is not subagent-scoped: {runtime_context_id}",
315
+ )
316
+ if context.get("role") != role:
317
+ raise BatchExecutionError(
318
+ "BATCH-RUNTIME-ROLE-001",
319
+ f"runtime context role is not {role}: {runtime_context_id}",
320
+ )
321
+ expected_task_dir = f".cowork-flow/tasks/{task_name}"
322
+ if context.get("task_dir") != expected_task_dir:
323
+ raise BatchExecutionError(
324
+ "BATCH-RUNTIME-TASK-001",
325
+ f"runtime context task_dir is not {expected_task_dir}",
326
+ )
@@ -41,5 +41,8 @@ Use the `task next` route payload and its `activatedSkill`/`recommendedSkill` fi
41
41
  Select at most one public Skill. If the payload has no Skill, follow the allowed
42
42
  operation directly or report the blocker; never guess a second workflow entry.
43
43
 
44
+ Before editing for implement, read `implement.jsonl` and every listed `file` entry.
45
+ If `test-first` is listed, read it and write the red test before production code.
46
+
44
47
  Public Skills carry workflow guidance. Runtime Gates carry hard enforcement.
45
48
  Do not recreate an internal protocol layer or another process authority.