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,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
|
-
|
|
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
|
-
|
|
74
|
-
if
|
|
75
|
-
return f"
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
if not context:
|
|
330
|
-
return False
|
|
269
|
+
from application.runtime_context_service import RuntimeContextService
|
|
331
270
|
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if not
|
|
67
|
-
print(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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")
|