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,214 @@
1
+ #!/usr/bin/env python3
2
+ """Recoverable Unit of Work for local JSON state."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from common.storage.operation_log import OperationLog
11
+ from common.storage.state_store import StateStore, StateStoreError
12
+
13
+
14
+ class UnitOfWorkError(RuntimeError):
15
+ """Raised when a multi-file state operation cannot converge."""
16
+
17
+ def __init__(self, operation_id: str, detail: str) -> None:
18
+ self.operation_id = operation_id
19
+ self.detail = detail
20
+ super().__init__(f"{operation_id}: {detail}")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StateMutation:
25
+ path: Path
26
+ action: str
27
+ expected_revision: int
28
+ before: dict
29
+ after: dict | None
30
+
31
+
32
+ FaultInjector = Callable[[int, StateMutation], None]
33
+
34
+
35
+ class UnitOfWork:
36
+ """Stage state mutations and recover interrupted commits."""
37
+
38
+ def __init__(
39
+ self,
40
+ repo_root: Path,
41
+ *,
42
+ operation_id: str,
43
+ kind: str,
44
+ state_store: StateStore | None = None,
45
+ operation_log: OperationLog | None = None,
46
+ fault_injector: FaultInjector | None = None,
47
+ ) -> None:
48
+ self.repo_root = Path(repo_root)
49
+ self.operation_id = operation_id
50
+ self.kind = kind
51
+ self.state_store = state_store or StateStore()
52
+ self.operation_log = operation_log or OperationLog(
53
+ self.repo_root,
54
+ state_store=self.state_store,
55
+ )
56
+ self.fault_injector = fault_injector
57
+ self.mutations: list[StateMutation] = []
58
+
59
+ def replace(self, path: str | Path, data: dict) -> None:
60
+ target = Path(path)
61
+ snapshot = self.state_store.load(target, missing_ok=True)
62
+ self.mutations.append(
63
+ StateMutation(
64
+ path=target,
65
+ action="replace",
66
+ expected_revision=snapshot.revision,
67
+ before=snapshot.data,
68
+ after=dict(data),
69
+ )
70
+ )
71
+
72
+ def delete(self, path: str | Path) -> None:
73
+ target = Path(path)
74
+ snapshot = self.state_store.load(target, missing_ok=True)
75
+ self.mutations.append(
76
+ StateMutation(
77
+ path=target,
78
+ action="delete",
79
+ expected_revision=snapshot.revision,
80
+ before=snapshot.data,
81
+ after=None,
82
+ )
83
+ )
84
+
85
+ def commit(self) -> dict:
86
+ path = self.operation_log.path(self.operation_id)
87
+ if path.is_file():
88
+ record = self.operation_log.load(self.operation_id)
89
+ else:
90
+ record = self.operation_log.create(
91
+ self.operation_id,
92
+ self._record(),
93
+ )
94
+ return self._apply(record)
95
+
96
+ @classmethod
97
+ def recover_all(
98
+ cls,
99
+ repo_root: Path,
100
+ *,
101
+ state_store: StateStore | None = None,
102
+ ) -> tuple[str, ...]:
103
+ root = Path(repo_root)
104
+ store = state_store or StateStore()
105
+ operation_log = OperationLog(root, state_store=store)
106
+ recovered: list[str] = []
107
+ for record in operation_log.pending():
108
+ operation_id = str(record["operation_id"])
109
+ unit = cls(
110
+ root,
111
+ operation_id=operation_id,
112
+ kind=str(record.get("kind") or "recovery"),
113
+ state_store=store,
114
+ operation_log=operation_log,
115
+ )
116
+ unit._apply(record)
117
+ recovered.append(operation_id)
118
+ return tuple(recovered)
119
+
120
+ def _record(self) -> dict:
121
+ return {
122
+ "schema_version": 1,
123
+ "operation_id": self.operation_id,
124
+ "kind": self.kind,
125
+ "phase": "prepared",
126
+ "applied": [],
127
+ "participants": [
128
+ {
129
+ "path": str(mutation.path),
130
+ "action": mutation.action,
131
+ "expected_revision": mutation.expected_revision,
132
+ "before": mutation.before,
133
+ "after": mutation.after,
134
+ }
135
+ for mutation in self.mutations
136
+ ],
137
+ }
138
+
139
+ def _apply(self, record: dict) -> dict:
140
+ phase = record.get("phase")
141
+ if phase == "committed":
142
+ return record
143
+ if phase == "conflicted":
144
+ raise UnitOfWorkError(
145
+ self.operation_id,
146
+ "operation is already conflicted",
147
+ )
148
+
149
+ record["phase"] = "applying"
150
+ self.operation_log.save(self.operation_id, record)
151
+ applied = {
152
+ int(index)
153
+ for index in record.get("applied", [])
154
+ }
155
+ participants = list(record.get("participants") or [])
156
+ for index, participant in enumerate(participants):
157
+ if index in applied:
158
+ continue
159
+ mutation = self._mutation(participant)
160
+ try:
161
+ self._apply_mutation(mutation)
162
+ except StateStoreError as error:
163
+ record["phase"] = "conflicted"
164
+ record["error"] = {
165
+ "code": error.code,
166
+ "path": str(error.path),
167
+ "detail": error.detail,
168
+ }
169
+ self.operation_log.save(self.operation_id, record)
170
+ raise UnitOfWorkError(
171
+ self.operation_id,
172
+ error.detail,
173
+ ) from error
174
+
175
+ if self.fault_injector is not None:
176
+ self.fault_injector(index, mutation)
177
+
178
+ applied.add(index)
179
+ record["applied"] = sorted(applied)
180
+ self.operation_log.save(self.operation_id, record)
181
+
182
+ record["phase"] = "committed"
183
+ record.pop("error", None)
184
+ self.operation_log.save(self.operation_id, record)
185
+ return record
186
+
187
+ def _apply_mutation(self, mutation: StateMutation) -> None:
188
+ if mutation.action == "delete":
189
+ self.state_store.delete(
190
+ mutation.path,
191
+ expected_revision=mutation.expected_revision,
192
+ operation_id=self.operation_id,
193
+ )
194
+ return
195
+ self.state_store.replace(
196
+ mutation.path,
197
+ mutation.after or {},
198
+ expected_revision=mutation.expected_revision,
199
+ operation_id=self.operation_id,
200
+ )
201
+
202
+ @staticmethod
203
+ def _mutation(participant: dict) -> StateMutation:
204
+ return StateMutation(
205
+ path=Path(str(participant["path"])),
206
+ action=str(participant["action"]),
207
+ expected_revision=int(participant["expected_revision"]),
208
+ before=dict(participant.get("before") or {}),
209
+ after=(
210
+ dict(participant["after"])
211
+ if isinstance(participant.get("after"), dict)
212
+ else None
213
+ ),
214
+ )
@@ -54,88 +54,50 @@ def _first_prompt_value(values: Mapping[str, object] | None) -> str | None:
54
54
 
55
55
 
56
56
  def resolve_context_key(values: Mapping[str, object] | None = None) -> str | None:
57
- explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
58
- if explicit and explicit.strip():
59
- return _sanitize(explicit)
60
-
61
- opencode_session = os.environ.get("OPENCODE_SESSION_ID")
62
- if opencode_session and opencode_session.strip():
63
- return f"opencode_{_sanitize(opencode_session)}"
64
-
65
- claude_session = os.environ.get("CLAUDE_SESSION_ID")
66
- if claude_session and claude_session.strip():
67
- return f"claude_{_sanitize(claude_session)}"
57
+ return _resolve_env_context_key() or _resolve_input_context_key(values)
68
58
 
69
- claude_code_session = os.environ.get("CLAUDE_CODE_SESSION_ID")
70
- if claude_code_session and claude_code_session.strip():
71
- return f"claude_{_sanitize(claude_code_session)}"
72
59
 
73
- codex_session = os.environ.get("CODEX_SESSION_ID")
74
- if codex_session and codex_session.strip():
75
- return f"codex_{_sanitize(codex_session)}"
60
+ def _prefixed_context_key(prefix: str, raw: str | None) -> str | None:
61
+ if raw and raw.strip():
62
+ return f"{prefix}_{_sanitize(raw)}"
63
+ return None
76
64
 
77
- codex_thread = os.environ.get("CODEX_THREAD_ID")
78
- if codex_thread and codex_thread.strip():
79
- return f"codex_{_sanitize(codex_thread)}"
80
65
 
81
- input_explicit = _first_input_value(
82
- values,
83
- (
84
- "COWORK_FLOW_CONTEXT_ID",
85
- "cowork_flow_context_id",
86
- "context_id",
87
- ),
88
- )
89
- if input_explicit:
90
- return _sanitize(input_explicit)
91
-
92
- input_opencode_session = _first_input_value(
93
- values,
94
- (
95
- "OPENCODE_SESSION_ID",
96
- "opencode_session_id",
97
- "sessionID",
98
- "sessionId",
99
- ),
100
- )
101
- if input_opencode_session:
102
- return f"opencode_{_sanitize(input_opencode_session)}"
66
+ def _resolve_env_context_key() -> str | None:
67
+ explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
68
+ if explicit and explicit.strip():
69
+ return _sanitize(explicit)
103
70
 
104
- input_claude_session = _first_input_value(
105
- values,
106
- (
107
- "CLAUDE_SESSION_ID",
108
- "claude_session_id",
109
- "CLAUDE_CODE_SESSION_ID",
110
- "claude_code_session_id",
111
- ),
112
- )
113
- if input_claude_session:
114
- return f"claude_{_sanitize(input_claude_session)}"
71
+ for prefix, env_name in (
72
+ ("opencode", "OPENCODE_SESSION_ID"),
73
+ ("claude", "CLAUDE_SESSION_ID"),
74
+ ("claude", "CLAUDE_CODE_SESSION_ID"),
75
+ ("codex", "CODEX_SESSION_ID"),
76
+ ("codex", "CODEX_THREAD_ID"),
77
+ ):
78
+ context_key = _prefixed_context_key(prefix, os.environ.get(env_name))
79
+ if context_key:
80
+ return context_key
81
+ return None
115
82
 
116
- input_session = _first_input_value(
117
- values,
118
- (
119
- "CODEX_SESSION_ID",
120
- "codex_session_id",
121
- "session_id",
122
- ),
123
- )
124
- if input_session:
125
- return f"codex_{_sanitize(input_session)}"
126
83
 
127
- input_thread = _first_input_value(
84
+ def _resolve_input_context_key(values: Mapping[str, object] | None) -> str | None:
85
+ explicit = _first_input_value(
128
86
  values,
129
- (
130
- "CODEX_THREAD_ID",
131
- "codex_thread_id",
132
- "thread_id",
133
- "conversation_id",
134
- ),
87
+ ("COWORK_FLOW_CONTEXT_ID", "cowork_flow_context_id", "context_id"),
135
88
  )
136
- if input_thread:
137
- return f"codex_{_sanitize(input_thread)}"
89
+ if explicit:
90
+ return _sanitize(explicit)
138
91
 
92
+ for prefix, names in (
93
+ ("opencode", ("OPENCODE_SESSION_ID", "opencode_session_id", "sessionID", "sessionId")),
94
+ ("claude", ("CLAUDE_SESSION_ID", "claude_session_id", "CLAUDE_CODE_SESSION_ID", "claude_code_session_id")),
95
+ ("codex", ("CODEX_SESSION_ID", "codex_session_id", "session_id")),
96
+ ("codex", ("CODEX_THREAD_ID", "codex_thread_id", "thread_id", "conversation_id")),
97
+ ):
98
+ context_key = _prefixed_context_key(prefix, _first_input_value(values, names))
99
+ if context_key:
100
+ return context_key
139
101
  return None
140
102
 
141
103
 
@@ -169,13 +131,6 @@ def _read_json(path: Path) -> dict:
169
131
  except FileNotFoundError:
170
132
  return {}
171
133
  except (json.JSONDecodeError, OSError):
172
- # Corrupt file — remove to avoid orphan state
173
- import sys
174
- print(f"Warning: Corrupt JSON cleaned up: {path}", file=sys.stderr)
175
- try:
176
- path.unlink(missing_ok=True)
177
- except OSError:
178
- pass
179
134
  return {}
180
135
  return data if isinstance(data, dict) else {}
181
136
 
@@ -249,11 +204,15 @@ def resolve_host_context_key(values: Mapping[str, object] | None = None) -> str
249
204
 
250
205
 
251
206
  def read_runtime_context(repo_root: Path, runtime_context_id: str) -> dict:
252
- return _read_json(runtime_context_path(repo_root, runtime_context_id))
207
+ from application.runtime_context_service import RuntimeContextService
208
+
209
+ return RuntimeContextService(repo_root).load(runtime_context_id)
253
210
 
254
211
 
255
212
  def write_runtime_context(repo_root: Path, runtime_context_id: str, data: dict) -> None:
256
- _write_json(runtime_context_path(repo_root, runtime_context_id), data)
213
+ from application.runtime_context_service import RuntimeContextService
214
+
215
+ RuntimeContextService(repo_root).replace(runtime_context_id, data)
257
216
 
258
217
 
259
218
  def write_subagent_logical_session(
@@ -284,12 +243,6 @@ def bind_runtime_context(
284
243
  host_context_key: str | None = None,
285
244
  values: Mapping[str, object] | None = None,
286
245
  ) -> dict | None:
287
- context = read_runtime_context(repo_root, runtime_context_id)
288
- if not context or context.get(FIELD_SCOPE) != SCOPE_SUBAGENT:
289
- return None
290
- if context.get("status") == "closed":
291
- return None
292
-
293
246
  resolved_key = (
294
247
  _sanitize(host_context_key)
295
248
  if host_context_key
@@ -298,53 +251,31 @@ def bind_runtime_context(
298
251
  if not resolved_key:
299
252
  return None
300
253
 
301
- existing_key = context.get("bound_context_key")
302
- if isinstance(existing_key, str) and existing_key.strip() and existing_key != resolved_key:
303
- return None
304
-
305
- task_path = context.get("task_dir")
306
- session: dict[str, object] = {
307
- "schema_version": 2,
308
- FIELD_SCOPE: SCOPE_SUBAGENT,
309
- FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
310
- "platform": _platform_from_context_key(resolved_key),
311
- "status": "bound",
312
- "last_seen_at": _now(),
313
- }
314
- if isinstance(task_path, str) and task_path.strip():
315
- session[FIELD_ACTIVE_TASK_PATH] = task_path.strip()
316
- _write_json(_session_path(repo_root, resolved_key), session)
254
+ from application.runtime_context_service import (
255
+ RuntimeContextError,
256
+ RuntimeContextService,
257
+ )
317
258
 
318
- context["status"] = "bound"
319
- context["bound_context_key"] = resolved_key
320
- if not context.get("bound_at"):
321
- context["bound_at"] = _now()
322
- context["last_seen_at"] = _now()
323
- write_runtime_context(repo_root, runtime_context_id, context)
324
- return context
259
+ try:
260
+ return RuntimeContextService(repo_root).bind(
261
+ runtime_context_id,
262
+ resolved_key,
263
+ )
264
+ except RuntimeContextError:
265
+ return None
325
266
 
326
267
 
327
268
  def close_runtime_context(repo_root: Path, runtime_context_id: str) -> bool:
328
- context = read_runtime_context(repo_root, runtime_context_id)
329
- if not context:
330
- return False
269
+ from application.runtime_context_service import RuntimeContextService
331
270
 
332
- bound_context_key = context.get("bound_context_key")
333
- for context_key in (bound_context_key, logical_subagent_context_key(runtime_context_id)):
334
- if isinstance(context_key, str) and context_key.strip():
335
- try:
336
- _session_path(repo_root, context_key).unlink()
337
- except OSError:
338
- pass
271
+ return RuntimeContextService(repo_root).close(runtime_context_id)
339
272
 
340
- context["status"] = "closed"
341
- context["closed_at"] = _now()
342
- context["last_seen_at"] = _now()
343
- write_runtime_context(repo_root, runtime_context_id, context)
344
- return True
345
273
 
346
-
347
- def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
274
+ def build_active_task_session(
275
+ repo_root: Path,
276
+ task_path: str,
277
+ ) -> tuple[Path, dict, ActiveTask] | None:
278
+ """Build the session state needed to activate a task."""
348
279
  context_key = resolve_context_key()
349
280
  if not context_key:
350
281
  return None
@@ -352,16 +283,26 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
352
283
  target = repo_root / normalized
353
284
  if not target.is_dir():
354
285
  return None
355
- _write_json(
286
+ data = {
287
+ FIELD_ACTIVE_TASK_PATH: normalized,
288
+ FIELD_SCOPE: SCOPE_MAIN,
289
+ "platform": _platform_from_context_key(context_key),
290
+ "last_seen_at": _now(),
291
+ }
292
+ return (
356
293
  _session_path(repo_root, context_key),
357
- {
358
- FIELD_ACTIVE_TASK_PATH: normalized,
359
- FIELD_SCOPE: SCOPE_MAIN,
360
- "platform": _platform_from_context_key(context_key),
361
- "last_seen_at": _now(),
362
- },
294
+ data,
295
+ ActiveTask(normalized, context_key, "session"),
363
296
  )
364
- return ActiveTask(normalized, context_key, "session")
297
+
298
+
299
+ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
300
+ prepared = build_active_task_session(repo_root, task_path)
301
+ if prepared is None:
302
+ return None
303
+ session_path, data, active = prepared
304
+ _write_json(session_path, data)
305
+ return active
365
306
 
366
307
 
367
308
  def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
@@ -375,6 +316,23 @@ def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None)
375
316
  return ActiveTask(None, context_key, "empty-session")
376
317
 
377
318
 
319
+ def is_main_session(repo_root: Path, values: Mapping[str, object] | None = None) -> bool:
320
+ """Return whether the current host session is an unscoped main session."""
321
+ context_key = resolve_context_key(values)
322
+ if not context_key:
323
+ return False
324
+ data = _read_json(_session_path(repo_root, context_key))
325
+ scope = data.get(FIELD_SCOPE)
326
+ historical_unscoped_main_session = (
327
+ scope is None
328
+ and not context_key.startswith("subagent_")
329
+ )
330
+ return (
331
+ (scope == SCOPE_MAIN or historical_unscoped_main_session)
332
+ and not data.get(FIELD_RUNTIME_CONTEXT_ID)
333
+ )
334
+
335
+
378
336
  def clear_active_task(repo_root: Path) -> ActiveTask:
379
337
  active = get_active_task(repo_root)
380
338
  if active.context_key:
@@ -1,54 +1,32 @@
1
1
  #!/usr/bin/env python3
2
- """Batch mode task lifecycle scheduler.
3
-
4
- Provides autonomous iteration over implement.jsonl tasks
5
- while keeping each task's independent gate verification.
6
- """
2
+ """Public entry for the recoverable task-graph Batch scheduler."""
7
3
 
8
4
  from __future__ import annotations
9
5
 
10
6
  import argparse
7
+ import json
8
+ import sys
11
9
  from pathlib import Path
12
10
 
11
+ from application.batch_execution import (
12
+ BatchExecutionError,
13
+ BatchExecutionService,
14
+ )
15
+
16
+
17
+ BATCH_APPROVAL_REQUIRED_CODE = "BATCH-APPROVAL-REQUIRED"
18
+ BATCH_REJECTED_EXIT_CODE = 2
19
+
13
20
 
14
21
  def confirm_batch_eligible(
15
22
  repo_root: Path,
16
23
  task_dir: Path,
17
24
  args: argparse.Namespace,
18
25
  ) -> tuple[bool, str]:
19
- """Check if batch mode can start.
20
-
21
- Returns (ok, reason). ok=False blocks entry.
22
- """
23
- # 1. Workflow state check: must be in_progress or review
24
- try:
25
- from common.task.active_task import load_active_task
26
- active = load_active_task(repo_root)
27
- status = active.get("status", "") if active else ""
28
- except Exception:
29
- status = ""
30
- if status not in ("in_progress", "review"):
31
- return False, f"Workflow status is '{status}', expected in_progress/review"
32
-
33
- # 2. implement.jsonl exists
34
- impl_file = task_dir / "implement.jsonl"
35
- if not impl_file.is_file():
36
- return False, f"implement.jsonl not found at {impl_file}"
37
-
38
- text = impl_file.read_text(encoding="utf-8").strip()
39
- if not text:
40
- return False, "implement.jsonl is empty"
41
-
42
- # 3. User approval check
43
- if not getattr(args, "auto", False):
44
- return False, "Batch mode not requested (--auto flag missing)"
45
-
26
+ """Require explicit approval before creating Batch runtime state."""
27
+ del repo_root, task_dir
46
28
  if not getattr(args, "approved", False):
47
- return False, (
48
- "User approval missing. "
49
- "Plan must be explicitly approved in writing-plans step."
50
- )
51
-
29
+ return False, "Batch mode requires --approved"
52
30
  return True, ""
53
31
 
54
32
 
@@ -57,89 +35,24 @@ def run_batch_entry(
57
35
  first_task_dir: Path,
58
36
  args: argparse.Namespace,
59
37
  ) -> int:
60
- """Batch mode entry point.
61
-
62
- Loads task sequence from implement.jsonl and iterates
63
- through start -> implement -> check -> commit -> complete.
64
- """
65
- ok, reason = confirm_batch_eligible(repo_root, first_task_dir, args)
66
- if not ok:
67
- print(f"Batch mode ineligible: {reason}", file=__import__("sys").stderr)
68
- return 1
69
-
70
- impl_file = first_task_dir / "implement.jsonl"
71
- tasks = _load_task_sequence(impl_file)
72
- if not tasks:
73
- print("No tasks found in implement.jsonl", file=__import__("sys").stderr)
74
- return 1
75
-
76
- results = []
77
- for task_ref in tasks:
78
- result = _run_single_task_lifecycle(repo_root, task_ref, args)
79
- results.append(result)
80
- if result.get("status") == "paused":
81
- _report_batch_pause(result)
82
- return 0
83
-
84
- return run_post_batch_verification(repo_root)
85
-
86
-
87
- def run_post_batch_verification(repo_root: Path) -> int:
88
- """Run full test suite + git log check + print final report."""
89
- import subprocess
38
+ """Create or load Batch state and publish its next Host action."""
39
+ eligible, detail = confirm_batch_eligible(
40
+ repo_root,
41
+ first_task_dir,
42
+ args,
43
+ )
44
+ if not eligible:
45
+ print(
46
+ f"Error [{BATCH_APPROVAL_REQUIRED_CODE}]: {detail}",
47
+ file=sys.stderr,
48
+ )
49
+ return BATCH_REJECTED_EXIT_CODE
90
50
  try:
91
- result = subprocess.run(
92
- ["python", "-m", "pytest", "--tb=short", "-q"],
93
- cwd=str(repo_root), capture_output=True, text=True,
94
- encoding="utf-8", timeout=60,
51
+ state = BatchExecutionService(repo_root).start(
52
+ first_task_dir.name
95
53
  )
96
- print(result.stdout)
97
- if result.returncode != 0:
98
- print("Full test suite FAILED", file=__import__("sys").stderr)
99
- return 1
100
- except (subprocess.SubprocessError, FileNotFoundError, TimeoutError) as exc:
101
- print(f"Test verification skipped: {exc}")
102
-
103
- print("\n## Batch Mode Report")
104
- print("- All tasks completed successfully")
105
- print("- Full test suite passed")
54
+ except BatchExecutionError as error:
55
+ print(f"Error [{error.code}]: {error.detail}", file=sys.stderr)
56
+ return BATCH_REJECTED_EXIT_CODE
57
+ print(json.dumps(state, ensure_ascii=False, indent=2))
106
58
  return 0
107
-
108
-
109
- def _load_task_sequence(impl_file: Path) -> list[str]:
110
- """Load task directory paths or names from implement.jsonl."""
111
- import json
112
- tasks = []
113
- for line in impl_file.read_text(encoding="utf-8").splitlines():
114
- line = line.strip()
115
- if not line:
116
- continue
117
- try:
118
- data = json.loads(line)
119
- except json.JSONDecodeError:
120
- continue
121
- task_ref = data.get("taskDir") or data.get("task") or data.get("dir")
122
- if task_ref:
123
- tasks.append(task_ref)
124
- return tasks
125
-
126
-
127
- def _run_single_task_lifecycle(
128
- repo_root: Path,
129
- task_ref: str,
130
- args: argparse.Namespace,
131
- ) -> dict:
132
- """Run start -> implement -> check -> commit for one task."""
133
- return {"task": task_ref, "status": "completed"}
134
-
135
-
136
- def _report_batch_pause(result: dict) -> None:
137
- """Print Batch Mode Paused report."""
138
- print("\n## Batch Mode Paused")
139
- print(f"Task: {result.get('task', '?')}")
140
- print(f"Reason: {result.get('reason', '?')}")
141
- print(f"Current state: {result.get('state', '?')}")
142
- print("\nOptions:")
143
- print("1. Skip this task -> mark skipped, continue next")
144
- print("2. Manual takeover -> exit batch")
145
- print("3. Abort -> exit batch, keep completed commits")