cowork-flow 0.0.19 → 0.0.21
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 +65 -20
- package/package.json +1 -1
- package/src/cli.js +109 -2
- package/src/commands/init.js +61 -4
- package/src/commands/sync.js +10 -1
- package/src/lib/copy-template.js +38 -7
- package/src/lib/platforms.js +73 -0
- package/template/.agent/skills/brainstorming/SKILL.md +13 -9
- package/template/.agent/skills/finish-work/SKILL.md +3 -3
- package/template/.agent/skills/meta/SKILL.md +1 -1
- package/template/.agent/skills/start/SKILL.md +38 -14
- package/template/.agent/skills/writing-plans/SKILL.md +1 -1
- package/template/.claude/agents/cowork-check.md +41 -0
- package/template/.claude/agents/cowork-implement.md +40 -0
- package/template/.claude/agents/cowork-research.md +40 -0
- package/template/.claude/commands/cowork-check.md +27 -0
- package/template/.claude/commands/cowork-implement.md +27 -0
- package/template/.claude/commands/cowork-research.md +27 -0
- package/template/.claude/hooks/inject-workflow-state.py +298 -0
- package/template/.claude/settings.json +24 -0
- package/template/.claude/skills/before-dev/SKILL.md +31 -0
- package/template/.claude/skills/brainstorming/SKILL.md +32 -0
- package/template/.claude/skills/break-loop/SKILL.md +26 -0
- package/template/.claude/skills/check/SKILL.md +29 -0
- package/template/.claude/skills/continue/SKILL.md +29 -0
- package/template/.claude/skills/finish-work/SKILL.md +32 -0
- package/template/.claude/skills/meta/SKILL.md +30 -0
- package/template/.claude/skills/python-design/SKILL.md +27 -0
- package/template/.claude/skills/start/SKILL.md +78 -0
- package/template/.claude/skills/update-spec/SKILL.md +39 -0
- package/template/.claude/skills/writing-plans/SKILL.md +70 -0
- package/template/.codex/agents/cowork-check.toml +17 -22
- package/template/.codex/agents/cowork-implement.toml +18 -23
- package/template/.codex/agents/cowork-research.toml +14 -19
- package/template/.codex/agents/default.toml +27 -0
- package/template/.codex/agents/explorer.toml +28 -0
- package/template/.codex/agents/worker.toml +29 -0
- package/template/.codex/hooks/inject-workflow-state.py +172 -90
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +35 -0
- package/template/.cowork-flow/adapters/codex/adapter.yaml +35 -0
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +33 -0
- package/template/.cowork-flow/config.yaml +4 -1
- package/template/.cowork-flow/scripts/add_session.py +20 -7
- package/template/.cowork-flow/scripts/common/active_task.py +212 -1
- package/template/.cowork-flow/scripts/common/entry_classifier.py +122 -0
- package/template/.cowork-flow/scripts/common/execution_context.py +10 -17
- package/template/.cowork-flow/scripts/common/readiness.py +249 -0
- package/template/.cowork-flow/scripts/doctor.py +417 -29
- package/template/.cowork-flow/scripts/project_context.py +230 -0
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/subagent.py +152 -238
- package/template/.cowork-flow/scripts/task.py +330 -17
- package/template/.cowork-flow/spec/adapter.schema.json +132 -0
- package/template/.cowork-flow/spec/capabilities.md +34 -0
- package/template/.cowork-flow/spec/entry-contract.md +45 -0
- package/template/.cowork-flow/spec/registry.json +54 -0
- package/template/.cowork-flow/spec/subagent-dispatch.md +77 -0
- package/template/.cowork-flow/spec/workflow-state-templates.md +66 -0
- package/template/.cowork-flow/workflow.md +154 -190
- package/template/.opencode/agents/cowork-check.md +46 -0
- package/template/.opencode/agents/cowork-implement.md +45 -0
- package/template/.opencode/agents/cowork-research.md +46 -0
- package/template/.opencode/commands/cowork-check.md +26 -0
- package/template/.opencode/commands/cowork-implement.md +26 -0
- package/template/.opencode/commands/cowork-research.md +26 -0
- package/template/.opencode/plugins/cowork-flow.js +317 -0
- package/template/CLAUDE.md +5 -0
- package/template/.agent/skills/entry-boundary/SKILL.md +0 -81
|
@@ -13,7 +13,18 @@ from .paths import DIR_WORKFLOW
|
|
|
13
13
|
|
|
14
14
|
DIR_RUNTIME = ".runtime"
|
|
15
15
|
DIR_SESSIONS = "sessions"
|
|
16
|
+
DIR_SUBAGENTS = "subagents"
|
|
16
17
|
FIELD_ACTIVE_TASK_PATH = "active_task_path"
|
|
18
|
+
FIELD_RUNTIME_CONTEXT_ID = "runtime_context_id"
|
|
19
|
+
FIELD_SCOPE = "scope"
|
|
20
|
+
SCOPE_MAIN = "main"
|
|
21
|
+
SCOPE_SUBAGENT = "subagent"
|
|
22
|
+
RUNTIME_CONTEXT_PROMPT_RE = re.compile(
|
|
23
|
+
r"(?im)^\s*cowork_runtime_context_id\s*:\s*([A-Za-z0-9._-]+)\s*$"
|
|
24
|
+
)
|
|
25
|
+
HOST_CONTEXT_PROMPT_RE = re.compile(
|
|
26
|
+
r"(?im)^\s*cowork_host_context_key\s*:\s*([A-Za-z0-9._-]+)\s*$"
|
|
27
|
+
)
|
|
17
28
|
|
|
18
29
|
|
|
19
30
|
@dataclass(frozen=True)
|
|
@@ -38,11 +49,23 @@ def _first_input_value(values: Mapping[str, object] | None, names: tuple[str, ..
|
|
|
38
49
|
return None
|
|
39
50
|
|
|
40
51
|
|
|
52
|
+
def _first_prompt_value(values: Mapping[str, object] | None) -> str | None:
|
|
53
|
+
return _first_input_value(values, ("prompt", "user_prompt", "userPrompt", "message", "input"))
|
|
54
|
+
|
|
55
|
+
|
|
41
56
|
def resolve_context_key(values: Mapping[str, object] | None = None) -> str | None:
|
|
42
57
|
explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
|
|
43
58
|
if explicit and explicit.strip():
|
|
44
59
|
return _sanitize(explicit)
|
|
45
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)}"
|
|
68
|
+
|
|
46
69
|
codex_session = os.environ.get("CODEX_SESSION_ID")
|
|
47
70
|
if codex_session and codex_session.strip():
|
|
48
71
|
return f"codex_{_sanitize(codex_session)}"
|
|
@@ -62,6 +85,26 @@ def resolve_context_key(values: Mapping[str, object] | None = None) -> str | Non
|
|
|
62
85
|
if input_explicit:
|
|
63
86
|
return _sanitize(input_explicit)
|
|
64
87
|
|
|
88
|
+
input_opencode_session = _first_input_value(
|
|
89
|
+
values,
|
|
90
|
+
(
|
|
91
|
+
"OPENCODE_SESSION_ID",
|
|
92
|
+
"opencode_session_id",
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
if input_opencode_session:
|
|
96
|
+
return f"opencode_{_sanitize(input_opencode_session)}"
|
|
97
|
+
|
|
98
|
+
input_claude_session = _first_input_value(
|
|
99
|
+
values,
|
|
100
|
+
(
|
|
101
|
+
"CLAUDE_SESSION_ID",
|
|
102
|
+
"claude_session_id",
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
if input_claude_session:
|
|
106
|
+
return f"claude_{_sanitize(input_claude_session)}"
|
|
107
|
+
|
|
65
108
|
input_session = _first_input_value(
|
|
66
109
|
values,
|
|
67
110
|
(
|
|
@@ -92,6 +135,10 @@ def sessions_dir(repo_root: Path) -> Path:
|
|
|
92
135
|
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SESSIONS
|
|
93
136
|
|
|
94
137
|
|
|
138
|
+
def subagent_contexts_dir(repo_root: Path) -> Path:
|
|
139
|
+
return repo_root / DIR_WORKFLOW / DIR_RUNTIME / DIR_SUBAGENTS
|
|
140
|
+
|
|
141
|
+
|
|
95
142
|
def _now() -> str:
|
|
96
143
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
97
144
|
|
|
@@ -100,6 +147,14 @@ def _session_path(repo_root: Path, context_key: str) -> Path:
|
|
|
100
147
|
return sessions_dir(repo_root) / f"{context_key}.json"
|
|
101
148
|
|
|
102
149
|
|
|
150
|
+
def logical_subagent_context_key(runtime_context_id: str) -> str:
|
|
151
|
+
return f"subagent_{_sanitize(runtime_context_id)}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def runtime_context_path(repo_root: Path, runtime_context_id: str) -> Path:
|
|
155
|
+
return subagent_contexts_dir(repo_root) / f"{_sanitize(runtime_context_id)}.json"
|
|
156
|
+
|
|
157
|
+
|
|
103
158
|
def _read_json(path: Path) -> dict:
|
|
104
159
|
try:
|
|
105
160
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -113,6 +168,161 @@ def _write_json(path: Path, data: dict) -> None:
|
|
|
113
168
|
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
114
169
|
|
|
115
170
|
|
|
171
|
+
def _platform_from_context_key(context_key: str) -> str:
|
|
172
|
+
if context_key.startswith("codex_"):
|
|
173
|
+
return "codex"
|
|
174
|
+
if context_key.startswith("opencode_"):
|
|
175
|
+
return "opencode"
|
|
176
|
+
if context_key.startswith("claude_"):
|
|
177
|
+
return "claude-code"
|
|
178
|
+
return "manual"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def resolve_runtime_context_id(values: Mapping[str, object] | None = None) -> str | None:
|
|
182
|
+
explicit = os.environ.get("COWORK_FLOW_RUNTIME_CONTEXT_ID")
|
|
183
|
+
if explicit and explicit.strip():
|
|
184
|
+
return _sanitize(explicit)
|
|
185
|
+
|
|
186
|
+
input_explicit = _first_input_value(
|
|
187
|
+
values,
|
|
188
|
+
(
|
|
189
|
+
"COWORK_FLOW_RUNTIME_CONTEXT_ID",
|
|
190
|
+
"cowork_runtime_context_id",
|
|
191
|
+
"runtime_context_id",
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
if input_explicit:
|
|
195
|
+
return _sanitize(input_explicit)
|
|
196
|
+
|
|
197
|
+
prompt = _first_prompt_value(values)
|
|
198
|
+
if prompt:
|
|
199
|
+
match = RUNTIME_CONTEXT_PROMPT_RE.search(prompt)
|
|
200
|
+
if match:
|
|
201
|
+
return _sanitize(match.group(1))
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def resolve_host_context_key(values: Mapping[str, object] | None = None) -> str | None:
|
|
206
|
+
explicit = os.environ.get("COWORK_FLOW_HOST_CONTEXT_KEY")
|
|
207
|
+
if explicit and explicit.strip():
|
|
208
|
+
return _sanitize(explicit)
|
|
209
|
+
|
|
210
|
+
input_explicit = _first_input_value(
|
|
211
|
+
values,
|
|
212
|
+
(
|
|
213
|
+
"cowork_host_context_key",
|
|
214
|
+
"host_context_key",
|
|
215
|
+
"COWORK_FLOW_HOST_CONTEXT_KEY",
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
if input_explicit:
|
|
219
|
+
return _sanitize(input_explicit)
|
|
220
|
+
|
|
221
|
+
prompt = _first_prompt_value(values)
|
|
222
|
+
if prompt:
|
|
223
|
+
match = HOST_CONTEXT_PROMPT_RE.search(prompt)
|
|
224
|
+
if match:
|
|
225
|
+
return _sanitize(match.group(1))
|
|
226
|
+
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def read_runtime_context(repo_root: Path, runtime_context_id: str) -> dict:
|
|
231
|
+
return _read_json(runtime_context_path(repo_root, runtime_context_id))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_runtime_context(repo_root: Path, runtime_context_id: str, data: dict) -> None:
|
|
235
|
+
_write_json(runtime_context_path(repo_root, runtime_context_id), data)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def write_subagent_logical_session(
|
|
239
|
+
repo_root: Path,
|
|
240
|
+
runtime_context_id: str,
|
|
241
|
+
task_path: str | None,
|
|
242
|
+
platform: str,
|
|
243
|
+
status: str = "pending_bind",
|
|
244
|
+
) -> str:
|
|
245
|
+
context_key = logical_subagent_context_key(runtime_context_id)
|
|
246
|
+
data: dict[str, object] = {
|
|
247
|
+
"schema_version": 2,
|
|
248
|
+
FIELD_SCOPE: SCOPE_SUBAGENT,
|
|
249
|
+
FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
|
|
250
|
+
"platform": platform,
|
|
251
|
+
"status": status,
|
|
252
|
+
"last_seen_at": _now(),
|
|
253
|
+
}
|
|
254
|
+
if task_path:
|
|
255
|
+
data[FIELD_ACTIVE_TASK_PATH] = task_path.replace("\\", "/")
|
|
256
|
+
_write_json(_session_path(repo_root, context_key), data)
|
|
257
|
+
return context_key
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def bind_runtime_context(
|
|
261
|
+
repo_root: Path,
|
|
262
|
+
runtime_context_id: str,
|
|
263
|
+
host_context_key: str | None = None,
|
|
264
|
+
values: Mapping[str, object] | None = None,
|
|
265
|
+
) -> dict | None:
|
|
266
|
+
context = read_runtime_context(repo_root, runtime_context_id)
|
|
267
|
+
if not context or context.get(FIELD_SCOPE) != SCOPE_SUBAGENT:
|
|
268
|
+
return None
|
|
269
|
+
if context.get("status") == "closed":
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
resolved_key = (
|
|
273
|
+
_sanitize(host_context_key)
|
|
274
|
+
if host_context_key
|
|
275
|
+
else resolve_host_context_key(values) or resolve_context_key(values)
|
|
276
|
+
)
|
|
277
|
+
if not resolved_key:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
existing_key = context.get("bound_context_key")
|
|
281
|
+
if isinstance(existing_key, str) and existing_key.strip() and existing_key != resolved_key:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
task_path = context.get("task_dir")
|
|
285
|
+
session: dict[str, object] = {
|
|
286
|
+
"schema_version": 2,
|
|
287
|
+
FIELD_SCOPE: SCOPE_SUBAGENT,
|
|
288
|
+
FIELD_RUNTIME_CONTEXT_ID: runtime_context_id,
|
|
289
|
+
"platform": _platform_from_context_key(resolved_key),
|
|
290
|
+
"status": "bound",
|
|
291
|
+
"last_seen_at": _now(),
|
|
292
|
+
}
|
|
293
|
+
if isinstance(task_path, str) and task_path.strip():
|
|
294
|
+
session[FIELD_ACTIVE_TASK_PATH] = task_path.strip()
|
|
295
|
+
_write_json(_session_path(repo_root, resolved_key), session)
|
|
296
|
+
|
|
297
|
+
context["status"] = "bound"
|
|
298
|
+
context["bound_context_key"] = resolved_key
|
|
299
|
+
if not context.get("bound_at"):
|
|
300
|
+
context["bound_at"] = _now()
|
|
301
|
+
context["last_seen_at"] = _now()
|
|
302
|
+
write_runtime_context(repo_root, runtime_context_id, context)
|
|
303
|
+
return context
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def close_runtime_context(repo_root: Path, runtime_context_id: str) -> bool:
|
|
307
|
+
context = read_runtime_context(repo_root, runtime_context_id)
|
|
308
|
+
if not context:
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
bound_context_key = context.get("bound_context_key")
|
|
312
|
+
for context_key in (bound_context_key, logical_subagent_context_key(runtime_context_id)):
|
|
313
|
+
if isinstance(context_key, str) and context_key.strip():
|
|
314
|
+
try:
|
|
315
|
+
_session_path(repo_root, context_key).unlink()
|
|
316
|
+
except FileNotFoundError:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
context["status"] = "closed"
|
|
320
|
+
context["closed_at"] = _now()
|
|
321
|
+
context["last_seen_at"] = _now()
|
|
322
|
+
write_runtime_context(repo_root, runtime_context_id, context)
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
116
326
|
def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
117
327
|
context_key = resolve_context_key()
|
|
118
328
|
if not context_key:
|
|
@@ -125,7 +335,8 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
|
125
335
|
_session_path(repo_root, context_key),
|
|
126
336
|
{
|
|
127
337
|
FIELD_ACTIVE_TASK_PATH: normalized,
|
|
128
|
-
|
|
338
|
+
FIELD_SCOPE: SCOPE_MAIN,
|
|
339
|
+
"platform": _platform_from_context_key(context_key),
|
|
129
340
|
"last_seen_at": _now(),
|
|
130
341
|
},
|
|
131
342
|
)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Classify cowork-flow hook entries before workflow-state injection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntryKind:
|
|
9
|
+
MAIN_SESSION = "MAIN_SESSION"
|
|
10
|
+
READ_ONLY = "READ_ONLY"
|
|
11
|
+
COMMAND_ONLY = "COMMAND_ONLY"
|
|
12
|
+
UNKNOWN = "UNKNOWN"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Classification:
|
|
17
|
+
entry_kind: str
|
|
18
|
+
confidence: float
|
|
19
|
+
source: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PROMPT_KEYS = ("prompt", "user_prompt", "userPrompt", "message", "input")
|
|
23
|
+
|
|
24
|
+
TASK_TERMS = (
|
|
25
|
+
"task:",
|
|
26
|
+
"topic:",
|
|
27
|
+
"focus:",
|
|
28
|
+
"任务:",
|
|
29
|
+
"任务:",
|
|
30
|
+
"主题:",
|
|
31
|
+
"目标:",
|
|
32
|
+
"审视",
|
|
33
|
+
"讨论",
|
|
34
|
+
"review",
|
|
35
|
+
"inspect",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
READ_ONLY_TERMS = (
|
|
39
|
+
"explain",
|
|
40
|
+
"what does",
|
|
41
|
+
"what is",
|
|
42
|
+
"why does",
|
|
43
|
+
"为什么",
|
|
44
|
+
"原因",
|
|
45
|
+
"解释",
|
|
46
|
+
"说明",
|
|
47
|
+
"read-only",
|
|
48
|
+
"inspect only",
|
|
49
|
+
"analysis only",
|
|
50
|
+
"只看",
|
|
51
|
+
"只读",
|
|
52
|
+
"分析一下",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
COMMAND_ONLY_TERMS = (
|
|
56
|
+
"git status",
|
|
57
|
+
"git diff",
|
|
58
|
+
"npm run",
|
|
59
|
+
".cowork-flow/run",
|
|
60
|
+
".cowork-flow\\run.cmd",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
MAIN_SESSION_TERMS = (
|
|
64
|
+
"main session",
|
|
65
|
+
"main agent",
|
|
66
|
+
"not a subagent",
|
|
67
|
+
"not a sub-agent",
|
|
68
|
+
"run full cowork-flow start",
|
|
69
|
+
"run full project start",
|
|
70
|
+
"继续",
|
|
71
|
+
"实现",
|
|
72
|
+
"提交",
|
|
73
|
+
"归档",
|
|
74
|
+
"修复",
|
|
75
|
+
"主会话",
|
|
76
|
+
"主 agent",
|
|
77
|
+
"主代理",
|
|
78
|
+
"不是子代理",
|
|
79
|
+
"不是子任务",
|
|
80
|
+
"start task",
|
|
81
|
+
"resume",
|
|
82
|
+
"implement",
|
|
83
|
+
"commit",
|
|
84
|
+
"archive",
|
|
85
|
+
"fix",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_prompt(hook_input: dict) -> str:
|
|
90
|
+
values: list[str] = []
|
|
91
|
+
for key in PROMPT_KEYS:
|
|
92
|
+
value = hook_input.get(key)
|
|
93
|
+
if isinstance(value, str) and value.strip():
|
|
94
|
+
values.append(value)
|
|
95
|
+
return "\n".join(values)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _contains_any(text: str, terms: tuple[str, ...]) -> bool:
|
|
99
|
+
return any(term in text for term in terms)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def classify_entry(hook_input: dict) -> Classification:
|
|
103
|
+
prompt = extract_prompt(hook_input)
|
|
104
|
+
if not prompt.strip():
|
|
105
|
+
return Classification(EntryKind.UNKNOWN, 0.0, "empty_prompt")
|
|
106
|
+
|
|
107
|
+
lowered = prompt.lower()
|
|
108
|
+
has_task = _contains_any(lowered, TASK_TERMS)
|
|
109
|
+
|
|
110
|
+
if _contains_any(lowered, READ_ONLY_TERMS) and not _contains_any(lowered, MAIN_SESSION_TERMS):
|
|
111
|
+
return Classification(EntryKind.READ_ONLY, 0.6, "read_only")
|
|
112
|
+
|
|
113
|
+
if _contains_any(lowered, COMMAND_ONLY_TERMS) and not has_task:
|
|
114
|
+
return Classification(EntryKind.COMMAND_ONLY, 0.6, "command_only")
|
|
115
|
+
|
|
116
|
+
if _contains_any(lowered, MAIN_SESSION_TERMS):
|
|
117
|
+
return Classification(EntryKind.MAIN_SESSION, 0.55, "main_session_heuristic")
|
|
118
|
+
|
|
119
|
+
if has_task:
|
|
120
|
+
return Classification(EntryKind.UNKNOWN, 0.35, "task_prompt_unbound")
|
|
121
|
+
|
|
122
|
+
return Classification(EntryKind.UNKNOWN, 0.3, "unclassified")
|
|
@@ -309,17 +309,15 @@ def build_subagent_resume_text(
|
|
|
309
309
|
forbidden_actions = context_data.get("forbiddenActions")
|
|
310
310
|
status_file = context_data.get("statusFile")
|
|
311
311
|
events_file = context_data.get("eventsFile")
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
dispatch_status = context_data.get("dispatchStatus")
|
|
312
|
+
runtime_context_id = context_data.get("runtimeContextId") or context_data.get("runtime_context_id")
|
|
313
|
+
runtime_context_status = context_data.get("runtimeContextStatus")
|
|
315
314
|
agent_type = context_data.get("agentType")
|
|
316
315
|
dispatch_reliability = context_data.get("dispatchReliability")
|
|
317
|
-
expected_ack = context_data.get("expectedAck")
|
|
318
316
|
lines = [
|
|
319
317
|
"========================================",
|
|
320
318
|
"COWORK-FLOW SUBAGENT RESUME",
|
|
321
319
|
"========================================",
|
|
322
|
-
"Use this only for a
|
|
320
|
+
"Use this only for a runtime-context subagent's own scoped recovery.",
|
|
323
321
|
"Do not switch back into the coordinator workflow from this entrypoint.",
|
|
324
322
|
"",
|
|
325
323
|
"## SUBAGENT CONTEXT",
|
|
@@ -328,18 +326,14 @@ def build_subagent_resume_text(
|
|
|
328
326
|
f"Role: {context.role or 'unknown'}",
|
|
329
327
|
f"Goal: {context.goal or 'unknown'}",
|
|
330
328
|
]
|
|
331
|
-
if isinstance(
|
|
332
|
-
lines.append(f"
|
|
333
|
-
if isinstance(
|
|
334
|
-
lines.append(f"
|
|
329
|
+
if isinstance(runtime_context_id, str) and runtime_context_id.strip():
|
|
330
|
+
lines.append(f"Runtime context: {runtime_context_id}")
|
|
331
|
+
if isinstance(runtime_context_status, str) and runtime_context_status.strip():
|
|
332
|
+
lines.append(f"Runtime context status: {runtime_context_status}")
|
|
335
333
|
if isinstance(agent_type, str) and agent_type.strip():
|
|
336
334
|
lines.append(f"Agent type: {agent_type}")
|
|
337
335
|
if isinstance(dispatch_reliability, str) and dispatch_reliability.strip():
|
|
338
336
|
lines.append(f"Dispatch reliability: {dispatch_reliability}")
|
|
339
|
-
if isinstance(ack_token, str) and ack_token.strip():
|
|
340
|
-
lines.append(f"ACK token: {ack_token}")
|
|
341
|
-
if isinstance(expected_ack, str) and expected_ack.strip():
|
|
342
|
-
lines.append(f"Expected ACK: {expected_ack}")
|
|
343
337
|
if context.context_file:
|
|
344
338
|
lines.append(f"Context file: {context.context_file}")
|
|
345
339
|
if isinstance(allowed_context, list) and allowed_context:
|
|
@@ -379,10 +373,9 @@ def build_subagent_resume_text(
|
|
|
379
373
|
lines.extend([
|
|
380
374
|
"",
|
|
381
375
|
"## RULES",
|
|
382
|
-
"-
|
|
383
|
-
"-
|
|
384
|
-
"-
|
|
385
|
-
"- Generic worker dispatch is best-effort only; no ACK means the coordinator must close or retry it.",
|
|
376
|
+
"- Execute only when the bound runtime context exists, is open, and names this agent type.",
|
|
377
|
+
"- If runtime context is missing, closed, invalid, or mismatched, report needs_context and do not execute it.",
|
|
378
|
+
"- Generic worker dispatch is advisory only and cannot complete formal Implement or Check.",
|
|
386
379
|
"- Read only prompt-named files and allowed context unless you ask for more context.",
|
|
387
380
|
"- Do not run task start, task finish, task archive, or unscoped resume.",
|
|
388
381
|
"- Stop only with success, needs_context, or blocked status evidence.",
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Readiness checks shared by task lifecycle commands."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .paths import DIR_ARCHIVE, DIR_CHANGES, DIR_TASKS, DIR_WORKFLOW, FILE_TASK_JSON, get_tasks_dir
|
|
10
|
+
from .task_utils import find_task_by_name
|
|
11
|
+
|
|
12
|
+
REQUIRED_L2_MARKERS = {
|
|
13
|
+
"goal and user value": (
|
|
14
|
+
("## goal", "## problem", "目标"),
|
|
15
|
+
("## benefits", "user value", "用户价值", "价值"),
|
|
16
|
+
),
|
|
17
|
+
"non-goals": (("## non-goals", "non-goals", "非目标"),),
|
|
18
|
+
"key assumptions": (("assumption", "assumptions", "关键假设"),),
|
|
19
|
+
"scope boundary": (("## scope", "scope boundary", "范围边界", "scope:"),),
|
|
20
|
+
"acceptance criteria": (("acceptance criteria", "## acceptance", "验收标准", "验收"),),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
VERIFICATION_COMMAND_MARKERS = (
|
|
24
|
+
"python -m unittest",
|
|
25
|
+
"npm test",
|
|
26
|
+
"npm run",
|
|
27
|
+
"git diff --check",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_text(path: Path) -> str:
|
|
32
|
+
try:
|
|
33
|
+
return path.read_text(encoding="utf-8").strip()
|
|
34
|
+
except (OSError, UnicodeDecodeError):
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_json(path: Path) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, json.JSONDecodeError):
|
|
42
|
+
return {}
|
|
43
|
+
return data if isinstance(data, dict) else {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_scalar(value: str) -> object:
|
|
47
|
+
if value == "null":
|
|
48
|
+
return None
|
|
49
|
+
if value == "true":
|
|
50
|
+
return True
|
|
51
|
+
if value == "false":
|
|
52
|
+
return False
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_metadata(path: Path) -> dict[str, object]:
|
|
57
|
+
data: dict[str, object] = {}
|
|
58
|
+
text = _read_text(path)
|
|
59
|
+
if not text:
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
for raw_line in text.splitlines():
|
|
63
|
+
line = raw_line.strip()
|
|
64
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
65
|
+
continue
|
|
66
|
+
key, value = line.split(":", 1)
|
|
67
|
+
data[key.strip()] = _parse_scalar(value.strip())
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _display_path(repo_root: Path, path: Path) -> str:
|
|
72
|
+
try:
|
|
73
|
+
return path.resolve().relative_to(repo_root.resolve()).as_posix()
|
|
74
|
+
except ValueError:
|
|
75
|
+
return str(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_link(repo_root: Path, base_dir: str, value: object) -> Path | None:
|
|
79
|
+
if not isinstance(value, str) or not value.strip():
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
raw_path = Path(value)
|
|
83
|
+
if raw_path.is_absolute():
|
|
84
|
+
return raw_path
|
|
85
|
+
|
|
86
|
+
direct = repo_root / raw_path
|
|
87
|
+
workflow_base = Path(DIR_WORKFLOW) / base_dir
|
|
88
|
+
if direct.exists() or raw_path.parts[:2] == workflow_base.parts:
|
|
89
|
+
return direct
|
|
90
|
+
return repo_root / workflow_base / raw_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _same_path(left: Path, right: Path) -> bool:
|
|
94
|
+
try:
|
|
95
|
+
return left.resolve() == right.resolve()
|
|
96
|
+
except OSError:
|
|
97
|
+
return left == right
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _task_ancestry(repo_root: Path, task_dir: Path) -> list[Path]:
|
|
101
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
102
|
+
ancestry = [task_dir]
|
|
103
|
+
seen = {task_dir.name}
|
|
104
|
+
current = task_dir
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
data = _read_json(current / FILE_TASK_JSON)
|
|
108
|
+
parent = data.get("parent")
|
|
109
|
+
if not isinstance(parent, str) or not parent.strip() or parent in seen:
|
|
110
|
+
break
|
|
111
|
+
parent_dir = find_task_by_name(parent, tasks_dir)
|
|
112
|
+
if parent_dir is None:
|
|
113
|
+
break
|
|
114
|
+
ancestry.append(parent_dir)
|
|
115
|
+
seen.add(parent)
|
|
116
|
+
current = parent_dir
|
|
117
|
+
|
|
118
|
+
return ancestry
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _task_tokens(repo_root: Path, ancestry: list[Path]) -> set[str]:
|
|
122
|
+
tokens: set[str] = set()
|
|
123
|
+
for path in ancestry:
|
|
124
|
+
tokens.add(path.name)
|
|
125
|
+
tokens.add(_display_path(repo_root, path))
|
|
126
|
+
return {token for token in tokens if token}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _plan_mentions_task(repo_root: Path, plan_path: Path | None, ancestry: list[Path]) -> bool:
|
|
130
|
+
if plan_path is None or not plan_path.is_file():
|
|
131
|
+
return False
|
|
132
|
+
text = _read_text(plan_path)
|
|
133
|
+
return any(token in text for token in _task_tokens(repo_root, ancestry))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _change_applies_to_task(
|
|
137
|
+
repo_root: Path,
|
|
138
|
+
metadata: dict[str, object],
|
|
139
|
+
task_dir: Path,
|
|
140
|
+
) -> tuple[bool, bool]:
|
|
141
|
+
ancestry = _task_ancestry(repo_root, task_dir)
|
|
142
|
+
linked_task = _resolve_link(repo_root, DIR_TASKS, metadata.get("task"))
|
|
143
|
+
if linked_task is not None and any(_same_path(linked_task, item) for item in ancestry):
|
|
144
|
+
return True, False
|
|
145
|
+
|
|
146
|
+
plan_path = _resolve_link(repo_root, "plans", metadata.get("plan"))
|
|
147
|
+
if _plan_mentions_task(repo_root, plan_path, ancestry):
|
|
148
|
+
return True, linked_task is None
|
|
149
|
+
|
|
150
|
+
return False, False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _has_all_marker_groups(text: str, groups: tuple[tuple[str, ...], ...]) -> bool:
|
|
154
|
+
lower = text.lower()
|
|
155
|
+
return all(any(marker.lower() in lower for marker in group) for group in groups)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _has_verification_command(plan_text: str) -> bool:
|
|
159
|
+
lower = plan_text.lower()
|
|
160
|
+
if "verification" not in lower and "验证" not in lower:
|
|
161
|
+
return False
|
|
162
|
+
return any(marker in lower for marker in VERIFICATION_COMMAND_MARKERS)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_l2_context(repo_root: Path, change_dir: Path, plan_path: Path | None, task_dir: Path) -> str:
|
|
166
|
+
parts = [
|
|
167
|
+
_read_text(change_dir / "proposal.md"),
|
|
168
|
+
_read_text(change_dir / "spec.md"),
|
|
169
|
+
_read_text(change_dir / "design.md"),
|
|
170
|
+
_read_text(task_dir / "prd.md"),
|
|
171
|
+
]
|
|
172
|
+
for ancestor in _task_ancestry(repo_root, task_dir)[1:]:
|
|
173
|
+
parts.append(_read_text(ancestor / "prd.md"))
|
|
174
|
+
if plan_path is not None:
|
|
175
|
+
parts.append(_read_text(plan_path))
|
|
176
|
+
return "\n\n".join(part for part in parts if part)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _append_missing_file_blocker(
|
|
180
|
+
blockers: list[str],
|
|
181
|
+
slug: str,
|
|
182
|
+
path: Path,
|
|
183
|
+
label: str,
|
|
184
|
+
) -> None:
|
|
185
|
+
if not _read_text(path):
|
|
186
|
+
blockers.append(f"L2 readiness ({slug}): {label} is missing or empty")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _iter_change_dirs(repo_root: Path) -> list[Path]:
|
|
190
|
+
changes_dir = repo_root / DIR_WORKFLOW / DIR_CHANGES
|
|
191
|
+
if not changes_dir.is_dir():
|
|
192
|
+
return []
|
|
193
|
+
return [
|
|
194
|
+
path
|
|
195
|
+
for path in sorted(changes_dir.iterdir())
|
|
196
|
+
if path.is_dir() and path.name != DIR_ARCHIVE
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def task_readiness_blockers(repo_root: Path, task_dir: Path) -> list[str]:
|
|
201
|
+
"""Return readiness blockers for work linked to L2 changes."""
|
|
202
|
+
blockers: list[str] = []
|
|
203
|
+
|
|
204
|
+
for change_dir in _iter_change_dirs(repo_root):
|
|
205
|
+
metadata = _read_metadata(change_dir / "change.yaml")
|
|
206
|
+
if metadata.get("level") != "L2" or metadata.get("status") == "archived":
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
applies, missing_task_link = _change_applies_to_task(repo_root, metadata, task_dir)
|
|
210
|
+
if not applies:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
slug = change_dir.name
|
|
214
|
+
if missing_task_link:
|
|
215
|
+
blockers.append(f"L2 readiness ({slug}): change.yaml task link is missing")
|
|
216
|
+
|
|
217
|
+
_append_missing_file_blocker(blockers, slug, change_dir / "proposal.md", "proposal.md")
|
|
218
|
+
_append_missing_file_blocker(blockers, slug, change_dir / "spec.md", "spec.md")
|
|
219
|
+
_append_missing_file_blocker(blockers, slug, change_dir / "design.md", "design.md")
|
|
220
|
+
|
|
221
|
+
plan_path = _resolve_link(repo_root, "plans", metadata.get("plan"))
|
|
222
|
+
if plan_path is None:
|
|
223
|
+
blockers.append(f"L2 readiness ({slug}): change.yaml plan link is missing")
|
|
224
|
+
plan_text = ""
|
|
225
|
+
elif not plan_path.exists():
|
|
226
|
+
blockers.append(
|
|
227
|
+
f"L2 readiness ({slug}): plan points to missing path: {_display_path(repo_root, plan_path)}"
|
|
228
|
+
)
|
|
229
|
+
plan_text = ""
|
|
230
|
+
else:
|
|
231
|
+
plan_text = _read_text(plan_path)
|
|
232
|
+
if not plan_text:
|
|
233
|
+
blockers.append(f"L2 readiness ({slug}): linked plan is empty")
|
|
234
|
+
|
|
235
|
+
linked_task = _resolve_link(repo_root, DIR_TASKS, metadata.get("task"))
|
|
236
|
+
if linked_task is not None and not linked_task.exists():
|
|
237
|
+
blockers.append(
|
|
238
|
+
f"L2 readiness ({slug}): task points to missing path: {_display_path(repo_root, linked_task)}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
context = _read_l2_context(repo_root, change_dir, plan_path, task_dir)
|
|
242
|
+
for label, marker_groups in REQUIRED_L2_MARKERS.items():
|
|
243
|
+
if not _has_all_marker_groups(context, marker_groups):
|
|
244
|
+
blockers.append(f"L2 readiness ({slug}): missing {label}")
|
|
245
|
+
|
|
246
|
+
if not _has_verification_command(plan_text):
|
|
247
|
+
blockers.append(f"L2 readiness ({slug}): linked plan is missing verification commands")
|
|
248
|
+
|
|
249
|
+
return blockers
|