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.
Files changed (69) hide show
  1. package/README.md +65 -20
  2. package/package.json +1 -1
  3. package/src/cli.js +109 -2
  4. package/src/commands/init.js +61 -4
  5. package/src/commands/sync.js +10 -1
  6. package/src/lib/copy-template.js +38 -7
  7. package/src/lib/platforms.js +73 -0
  8. package/template/.agent/skills/brainstorming/SKILL.md +13 -9
  9. package/template/.agent/skills/finish-work/SKILL.md +3 -3
  10. package/template/.agent/skills/meta/SKILL.md +1 -1
  11. package/template/.agent/skills/start/SKILL.md +38 -14
  12. package/template/.agent/skills/writing-plans/SKILL.md +1 -1
  13. package/template/.claude/agents/cowork-check.md +41 -0
  14. package/template/.claude/agents/cowork-implement.md +40 -0
  15. package/template/.claude/agents/cowork-research.md +40 -0
  16. package/template/.claude/commands/cowork-check.md +27 -0
  17. package/template/.claude/commands/cowork-implement.md +27 -0
  18. package/template/.claude/commands/cowork-research.md +27 -0
  19. package/template/.claude/hooks/inject-workflow-state.py +298 -0
  20. package/template/.claude/settings.json +24 -0
  21. package/template/.claude/skills/before-dev/SKILL.md +31 -0
  22. package/template/.claude/skills/brainstorming/SKILL.md +32 -0
  23. package/template/.claude/skills/break-loop/SKILL.md +26 -0
  24. package/template/.claude/skills/check/SKILL.md +29 -0
  25. package/template/.claude/skills/continue/SKILL.md +29 -0
  26. package/template/.claude/skills/finish-work/SKILL.md +32 -0
  27. package/template/.claude/skills/meta/SKILL.md +30 -0
  28. package/template/.claude/skills/python-design/SKILL.md +27 -0
  29. package/template/.claude/skills/start/SKILL.md +78 -0
  30. package/template/.claude/skills/update-spec/SKILL.md +39 -0
  31. package/template/.claude/skills/writing-plans/SKILL.md +70 -0
  32. package/template/.codex/agents/cowork-check.toml +17 -22
  33. package/template/.codex/agents/cowork-implement.toml +18 -23
  34. package/template/.codex/agents/cowork-research.toml +14 -19
  35. package/template/.codex/agents/default.toml +27 -0
  36. package/template/.codex/agents/explorer.toml +28 -0
  37. package/template/.codex/agents/worker.toml +29 -0
  38. package/template/.codex/hooks/inject-workflow-state.py +172 -90
  39. package/template/.cowork-flow/.version +1 -1
  40. package/template/.cowork-flow/adapters/claude-code/adapter.yaml +35 -0
  41. package/template/.cowork-flow/adapters/codex/adapter.yaml +35 -0
  42. package/template/.cowork-flow/adapters/opencode/adapter.yaml +33 -0
  43. package/template/.cowork-flow/config.yaml +4 -1
  44. package/template/.cowork-flow/scripts/add_session.py +20 -7
  45. package/template/.cowork-flow/scripts/common/active_task.py +212 -1
  46. package/template/.cowork-flow/scripts/common/entry_classifier.py +122 -0
  47. package/template/.cowork-flow/scripts/common/execution_context.py +10 -17
  48. package/template/.cowork-flow/scripts/common/readiness.py +249 -0
  49. package/template/.cowork-flow/scripts/doctor.py +417 -29
  50. package/template/.cowork-flow/scripts/project_context.py +230 -0
  51. package/template/.cowork-flow/scripts/run.py +3 -0
  52. package/template/.cowork-flow/scripts/subagent.py +152 -238
  53. package/template/.cowork-flow/scripts/task.py +330 -17
  54. package/template/.cowork-flow/spec/adapter.schema.json +132 -0
  55. package/template/.cowork-flow/spec/capabilities.md +34 -0
  56. package/template/.cowork-flow/spec/entry-contract.md +45 -0
  57. package/template/.cowork-flow/spec/registry.json +54 -0
  58. package/template/.cowork-flow/spec/subagent-dispatch.md +77 -0
  59. package/template/.cowork-flow/spec/workflow-state-templates.md +66 -0
  60. package/template/.cowork-flow/workflow.md +154 -190
  61. package/template/.opencode/agents/cowork-check.md +46 -0
  62. package/template/.opencode/agents/cowork-implement.md +45 -0
  63. package/template/.opencode/agents/cowork-research.md +46 -0
  64. package/template/.opencode/commands/cowork-check.md +26 -0
  65. package/template/.opencode/commands/cowork-implement.md +26 -0
  66. package/template/.opencode/commands/cowork-research.md +26 -0
  67. package/template/.opencode/plugins/cowork-flow.js +317 -0
  68. package/template/CLAUDE.md +5 -0
  69. 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
- "platform": "codex" if context_key.startswith("codex_") else "manual",
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
- dispatch_id = context_data.get("dispatchId")
313
- ack_token = context_data.get("ackToken")
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 delegated subagent's own scoped recovery.",
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(dispatch_id, str) and dispatch_id.strip():
332
- lines.append(f"Dispatch ID: {dispatch_id}")
333
- if isinstance(dispatch_status, str) and dispatch_status.strip():
334
- lines.append(f"Dispatch status: {dispatch_status}")
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
- "- If you have not acknowledged this dispatch, first return only: COWORK_ACK <dispatch_id> <ack_token>.",
383
- "- Execute only after the coordinator sends EXECUTE <dispatch_id> for this same dispatch.",
384
- "- If a message names a different dispatch_id, report needs_context and do not execute it.",
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