cowork-flow 2.3.0 → 2.4.1

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 (63) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.en.md +34 -18
  3. package/README.md +8 -5
  4. package/docs/architecture.en.md +95 -0
  5. package/docs/architecture.md +7 -3
  6. package/docs/direction.en.md +71 -0
  7. package/docs/direction.md +6 -1
  8. package/docs/hosts.en.md +299 -0
  9. package/docs/hosts.md +3 -1
  10. package/docs/index.en.md +30 -0
  11. package/docs/index.md +2 -0
  12. package/docs/release.en.md +108 -0
  13. package/docs/release.md +2 -0
  14. package/package.json +2 -2
  15. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/presets/claude-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  17. package/presets/codex/.codex-plugin/plugin.json +1 -1
  18. package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  19. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  20. package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  21. package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  22. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  23. package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  24. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  25. package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  26. package/src/commands/init.js +129 -2
  27. package/src/commands/registry.js +3 -1
  28. package/src/commands/source-refresh.js +5 -1
  29. package/src/commands/sync.js +3 -0
  30. package/src/lib/copy-template.js +23 -5
  31. package/src/lib/host-assets.js +117 -2
  32. package/src/lib/language.js +162 -0
  33. package/template/.cowork-flow/.version +1 -1
  34. package/template/.cowork-flow/config.yaml +8 -0
  35. package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
  36. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
  37. package/template/.cowork-flow/scripts/infra/config.py +85 -0
  38. package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +228 -0
  39. package/template/.cowork-flow/scripts/services/fact_view.py +27 -15
  40. package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
  41. package/template/.cowork-flow/scripts/services/plan_schema.py +182 -0
  42. package/template/.cowork-flow/scripts/services/task_context.py +84 -12
  43. package/template/.cowork-flow/scripts/services/task_creation.py +89 -30
  44. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
  45. package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
  46. package/template/.cowork-flow/spec/contracts/decision-anchor.md +35 -19
  47. package/template/.cowork-flow/spec/contracts/plan-binding.md +27 -4
  48. package/template/.cowork-flow/spec/index.md +26 -26
  49. package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
  50. package/template/.cowork-flow/spec/references/index.md +16 -16
  51. package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
  52. package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
  53. package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
  54. package/template/.cowork-flow/spec/runtime/contract-registry.json +1 -0
  55. package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
  56. package/template/.cowork-flow/spec/runtime/index.md +10 -12
  57. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
  58. package/template/.cowork-flow/spec/schemas/index.md +5 -5
  59. package/template/.cowork-flow/templates/plan-template.md +91 -0
  60. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +169 -0
  61. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
  62. package/template/skills/runtime-health/scripts/doctor.py +110 -0
  63. package/template/skills/task-planning/SKILL.md +21 -94
@@ -17,23 +17,31 @@ from datetime import datetime, timezone
17
17
  from pathlib import Path
18
18
  from typing import Any
19
19
 
20
+ from services.decision_anchor_schema import (
21
+ ACCEPTANCE,
22
+ ACCEPTANCE_RE,
23
+ GOAL,
24
+ HEADING_RE,
25
+ REJECTED,
26
+ SCOPE,
27
+ VERIFICATION,
28
+ canonical_section,
29
+ is_spec_check_line,
30
+ )
31
+
20
32
  GOAL_TRUNCATE = 300
21
33
  VERIFY_COMMAND_TRUNCATE = 120
22
34
  VERIFY_COMMAND_MAX = 10
23
35
  SCOPE_BOUNDARY_TRUNCATE = 160
24
- ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
25
- ACCEPTANCE_RE = re.compile(
26
- r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
27
- )
28
36
  REJECTED_RE = re.compile(r"^\s*-\s*\*\*(.+?)\*\*")
29
- _HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
30
37
 
31
38
 
32
39
  def parse_decision_anchor(text: str) -> dict[str, Any]:
33
40
  """Extract goal / acceptance criteria / rejected option names / declared
34
41
  verification commands / scope boundary from a decision-anchor.md body.
35
- Section format is frozen by spec/contracts/decision-anchor.md; parsing is
36
- line-level and degrades to empty values when sections are missing."""
42
+ Section headings may be the Chinese set or the English aliases (see
43
+ spec/contracts/decision-anchor.md); parsing is line-level and degrades to
44
+ empty values when sections are missing."""
37
45
  goal_lines: list[str] = []
38
46
  acceptance: list[dict[str, str]] = []
39
47
  rejected: list[str] = []
@@ -41,15 +49,14 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
41
49
  scope_boundary = ""
42
50
  section: str | None = None
43
51
  for raw in text.splitlines():
44
- heading = _HEADING_RE.match(raw.strip())
52
+ heading = HEADING_RE.match(raw.strip())
45
53
  if heading:
46
- name = heading.group(1)
47
- section = name if name in ANCHOR_SECTIONS else None
54
+ section = canonical_section(heading.group(1))
48
55
  continue
49
- if section == "目标":
56
+ if section == GOAL:
50
57
  if raw.strip():
51
58
  goal_lines.append(raw.strip())
52
- elif section == "验收标准":
59
+ elif section == ACCEPTANCE:
53
60
  match = ACCEPTANCE_RE.match(raw)
54
61
  if match:
55
62
  acceptance.append(
@@ -59,17 +66,22 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
59
66
  "checked": match.group(1) in ("x", "X"),
60
67
  }
61
68
  )
62
- elif section == "被拒方案":
69
+ elif section == REJECTED:
63
70
  match = REJECTED_RE.match(raw)
64
71
  if match:
65
72
  rejected.append(match.group(1).strip())
66
- elif section == "验证命令":
73
+ elif section == VERIFICATION:
67
74
  command = raw.strip()
68
75
  if command.startswith("- "):
69
76
  command = command[2:].strip()
77
+ if command.startswith("<!--"):
78
+ # Scaffold instructions and author notes are not commands.
79
+ continue
80
+ if is_spec_check_line(command):
81
+ continue
70
82
  if command and len(verify_commands) < VERIFY_COMMAND_MAX:
71
83
  verify_commands.append(command[:VERIFY_COMMAND_TRUNCATE])
72
- elif section == "范围边界":
84
+ elif section == SCOPE:
73
85
  if raw.strip() and not scope_boundary:
74
86
  scope_boundary = raw.strip()[:SCOPE_BOUNDARY_TRUNCATE]
75
87
  return {
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from infra.files import read_text_utf8
11
11
  from infra.paths import DIR_WORKFLOW
12
+ from services.plan_schema import plan_template_blockers as plan_text_blockers
12
13
  from services.task_repository import TaskRepository
13
14
 
14
15
 
@@ -165,3 +166,36 @@ def plan_file_blockers(repo_root: Path, task_data: dict) -> list[str]:
165
166
  return [f"planFile is empty: {error.normalized}"]
166
167
  return [error.detail]
167
168
  return []
169
+
170
+
171
+ def plan_template_blockers(
172
+ repo_root: Path,
173
+ task_data: dict,
174
+ language: str,
175
+ ) -> list[str]:
176
+ """Structure blockers for an already-bound, non-empty plan."""
177
+ meta = task_data.get("meta")
178
+ plan_file = meta.get("planFile") if isinstance(meta, dict) else None
179
+ if not isinstance(plan_file, str) or not plan_file.strip():
180
+ return []
181
+ try:
182
+ bound = bind_plan_file(
183
+ repo_root,
184
+ plan_file,
185
+ allow_absolute=False,
186
+ require_exists=True,
187
+ require_non_empty=True,
188
+ )
189
+ except PlanBindingError:
190
+ # plan_file_blockers already reported the binding fact.
191
+ return []
192
+ text = read_text_utf8(bound.path)
193
+ if not text.strip():
194
+ return []
195
+ dev_type = task_data.get("dev_type")
196
+ if not isinstance(dev_type, str) or not dev_type.strip():
197
+ dev_type = None
198
+ return [
199
+ f"plan {bound.normalized}: {message}"
200
+ for message in plan_text_blockers(text, language, dev_type=dev_type)
201
+ ]
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ """Plan template language and structure checks for strict task start.
3
+
4
+ The checks are intentionally structural: a plan must use the configured
5
+ language's title marker, the six template sections, task-heading prefixes and a
6
+ filled spec-check line. Prose, step status, acceptance text and evidence
7
+ matching stay out of scope.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from services.decision_anchor_schema import (
15
+ DEFAULT_LANGUAGE,
16
+ is_spec_check_result_line,
17
+ spec_check_example,
18
+ spec_check_label,
19
+ structural_lines,
20
+ )
21
+
22
+ PLAN_TITLE_MARKERS: dict[str, str] = {
23
+ "zh-CN": "实施计划",
24
+ "en-US": "Implementation Plan",
25
+ }
26
+
27
+ PLAN_SECTION_HEADINGS: dict[str, tuple[str, ...]] = {
28
+ "zh-CN": (
29
+ "目标",
30
+ "验收标准",
31
+ "全局约束",
32
+ "文件边界",
33
+ "任务",
34
+ "最终验证",
35
+ ),
36
+ "en-US": (
37
+ "Goal",
38
+ "Acceptance Criteria",
39
+ "Global Constraints",
40
+ "File Boundaries",
41
+ "Tasks",
42
+ "Final Verification",
43
+ ),
44
+ }
45
+
46
+ PLAN_TASK_PREFIXES: dict[str, str] = {
47
+ "zh-CN": "任务",
48
+ "en-US": "Task",
49
+ }
50
+
51
+ H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
52
+ H2_RE = re.compile(r"^##(?!#)\s+(.+?)\s*$")
53
+ H3_RE = re.compile(r"^###\s+(.+?)\s*$")
54
+
55
+
56
+ def _language(language: str) -> str:
57
+ return language if language in PLAN_SECTION_HEADINGS else DEFAULT_LANGUAGE
58
+
59
+
60
+ def _headings(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
61
+ h1: list[str] = []
62
+ h2: list[str] = []
63
+ h3: list[str] = []
64
+ for raw in lines:
65
+ line = raw.strip()
66
+ match = H3_RE.match(line)
67
+ if match:
68
+ h3.append(match.group(1))
69
+ continue
70
+ match = H2_RE.match(line)
71
+ if match:
72
+ h2.append(match.group(1))
73
+ continue
74
+ match = H1_RE.match(line)
75
+ if match:
76
+ h1.append(match.group(1))
77
+ return h1, h2, h3
78
+
79
+
80
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
81
+ body: list[str] = []
82
+ inside = False
83
+ for raw in lines:
84
+ line = raw.strip()
85
+ match = H2_RE.match(line)
86
+ if match:
87
+ inside = match.group(1) == heading
88
+ continue
89
+ if inside:
90
+ body.append(raw)
91
+ return body
92
+
93
+
94
+ def _task_prefix_blocker(heading: str, language: str, prefix: str) -> str | None:
95
+ lowered = heading.lower()
96
+ if not (heading.startswith("任务") or lowered.startswith("task")):
97
+ return None
98
+ if heading.startswith(prefix):
99
+ return None
100
+ return (
101
+ f"plan task heading '### {heading}' must use the {language} prefix "
102
+ f"'### {prefix} N:'"
103
+ if language == "zh-CN"
104
+ else f"plan task heading '### {heading}' must use the {language} prefix "
105
+ f"'### {prefix} N:'"
106
+ )
107
+
108
+
109
+ def plan_template_blockers(
110
+ text: str,
111
+ language: str,
112
+ *,
113
+ dev_type: str | None = None,
114
+ ) -> list[str]:
115
+ """Why a plan does not match the configured-language template shape."""
116
+ if not text.strip():
117
+ return ["plan is empty"]
118
+ resolved = _language(language)
119
+ expected = PLAN_SECTION_HEADINGS[resolved]
120
+ other = "en-US" if resolved == "zh-CN" else "zh-CN"
121
+ other_headings = set(PLAN_SECTION_HEADINGS[other])
122
+ lines = structural_lines(text)
123
+ h1, h2, h3 = _headings(lines)
124
+ blockers: list[str] = []
125
+
126
+ marker = PLAN_TITLE_MARKERS[resolved]
127
+ if not h1:
128
+ blockers.append(
129
+ f"plan is missing a top-level title; expected: # <功能> {marker}"
130
+ )
131
+ elif marker not in h1[0]:
132
+ blockers.append(
133
+ f"plan title must contain '{marker}' for {resolved}; "
134
+ f"expected: # <功能> {marker}"
135
+ )
136
+
137
+ wrong_language = [heading for heading in h2 if heading in other_headings]
138
+ if wrong_language:
139
+ blockers.append(
140
+ f"plan uses {other} plan headings in a {resolved} project: "
141
+ + ", ".join(f"## {heading}" for heading in wrong_language)
142
+ + "; expected: "
143
+ + ", ".join(f"## {heading}" for heading in expected)
144
+ )
145
+ else:
146
+ missing = [heading for heading in expected if heading not in h2]
147
+ if missing:
148
+ blockers.append(
149
+ f"plan is missing required {resolved} template heading(s): "
150
+ + ", ".join(f"## {heading}" for heading in missing)
151
+ )
152
+ else:
153
+ positions = [h2.index(heading) for heading in expected]
154
+ if positions != sorted(positions):
155
+ blockers.append(
156
+ f"plan template headings are out of order; expected: "
157
+ + " -> ".join(f"## {heading}" for heading in expected)
158
+ )
159
+
160
+ prefix = PLAN_TASK_PREFIXES[resolved]
161
+ for heading in h3:
162
+ blocker = _task_prefix_blocker(heading, resolved, prefix)
163
+ if blocker is not None:
164
+ blockers.append(blocker)
165
+ break
166
+
167
+ final_heading = expected[-1]
168
+ if final_heading in h2:
169
+ body = _section_lines(lines, final_heading)
170
+ if not any(
171
+ spec_check_label(resolved) in line
172
+ and is_spec_check_result_line(line, resolved)
173
+ for line in body
174
+ ):
175
+ blockers.append(
176
+ f"plan `## {final_heading}` is missing a filled spec-check "
177
+ f"result; expected a line starting with '{spec_check_label(resolved)}' "
178
+ f"and containing 'pass' or 'not_applicable', e.g. "
179
+ f"{spec_check_example(resolved, dev_type)}"
180
+ )
181
+
182
+ return blockers
@@ -7,6 +7,7 @@ import json
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
 
10
+ from infra.config import get_language
10
11
  from infra.files import read_text_utf8
11
12
  from infra.paths import FILE_TASK_JSON
12
13
  from services.context_discovery import (
@@ -32,7 +33,22 @@ from services.context_paths import (
32
33
  normalize_context_path,
33
34
  prepare_context_entry,
34
35
  )
35
- from services.plan_binding import normalize_plan_file, plan_file_blockers
36
+ from services.decision_anchor_schema import (
37
+ REQUIRED_SECTIONS,
38
+ VERIFICATION,
39
+ heading_for,
40
+ headings,
41
+ is_spec_check_result_line,
42
+ present_sections,
43
+ section_headings,
44
+ spec_check_example,
45
+ verification_section_lines,
46
+ )
47
+ from services.plan_binding import (
48
+ normalize_plan_file,
49
+ plan_file_blockers,
50
+ plan_template_blockers,
51
+ )
36
52
  from services.task_gate_profiles import (
37
53
  GATE_PROFILE_STRICT,
38
54
  acceptance_row_blockers,
@@ -330,6 +346,9 @@ class TaskContextService:
330
346
  task_dir = Path(task_dir)
331
347
  blockers: list[str] = []
332
348
  task_data = self._task_data(task_dir)
349
+ strict = resolve_gate_profile(task_data) == GATE_PROFILE_STRICT
350
+ light = self._is_tiny_task(task_data)
351
+ language = get_language(self.repo_root)
333
352
  if not (task_dir / FILE_TASK_JSON).is_file():
334
353
  blockers.append("task.json is missing")
335
354
  anchor_text = read_text_utf8(task_dir / "decision-anchor.md")
@@ -337,14 +356,27 @@ class TaskContextService:
337
356
  blockers.append("decision-anchor.md is missing or empty")
338
357
  else:
339
358
  blockers.extend(self._decision_anchor_section_blockers(anchor_text))
340
- if resolve_gate_profile(task_data) == GATE_PROFILE_STRICT:
359
+ if strict:
341
360
  blockers.extend(acceptance_row_blockers(anchor_text))
361
+ if not light:
362
+ blockers.extend(
363
+ self._anchor_spec_check_blockers(
364
+ anchor_text,
365
+ language,
366
+ task_data.get("dev_type"),
367
+ )
368
+ )
342
369
  for context_file in ("implement.jsonl",):
343
370
  if not read_text_utf8(task_dir / context_file):
344
371
  blockers.append(f"{context_file} is missing or empty")
345
- if (task_dir / FILE_TASK_JSON).is_file() and not self._is_tiny_task(task_data):
346
- blockers.extend(self._plan_file_blockers(task_data))
347
- if resolve_gate_profile(task_data) == GATE_PROFILE_STRICT:
372
+ if (task_dir / FILE_TASK_JSON).is_file() and not light:
373
+ plan_blockers = self._plan_file_blockers(task_data)
374
+ blockers.extend(plan_blockers)
375
+ if strict and not plan_blockers:
376
+ blockers.extend(
377
+ plan_template_blockers(self.repo_root, task_data, language)
378
+ )
379
+ if strict:
348
380
  blockers.extend(dev_type_blocker(task_data))
349
381
  return tuple(blockers)
350
382
 
@@ -360,13 +392,53 @@ class TaskContextService:
360
392
 
361
393
  @staticmethod
362
394
  def _decision_anchor_section_blockers(anchor_text: str) -> list[str]:
363
- blockers: list[str] = []
364
- for section in ("## 目标", "## 验收标准"):
365
- if section not in anchor_text:
366
- blockers.append(
367
- f"decision-anchor.md missing required section: {section}"
368
- )
369
- return blockers
395
+ present = present_sections(anchor_text)
396
+ return [
397
+ "decision-anchor.md missing required section: "
398
+ + " (or ".join(f"## {heading}" for heading in section_headings(section))
399
+ + ")"
400
+ for section in REQUIRED_SECTIONS
401
+ if section not in present
402
+ ]
403
+
404
+ @staticmethod
405
+ def _anchor_spec_check_blockers(
406
+ anchor_text: str,
407
+ language: str,
408
+ dev_type: str | None,
409
+ ) -> list[str]:
410
+ expected = heading_for(VERIFICATION, language)
411
+ actual = headings(anchor_text)
412
+ normalized_dev_type = (
413
+ dev_type.strip()
414
+ if isinstance(dev_type, str) and dev_type.strip()
415
+ else None
416
+ )
417
+ example = spec_check_example(language, normalized_dev_type)
418
+ if expected not in actual:
419
+ other = heading_for(
420
+ VERIFICATION,
421
+ "en-US" if language == "zh-CN" else "zh-CN",
422
+ )
423
+ if other in actual:
424
+ return [
425
+ f"decision-anchor.md uses `## {other}`; a strict {language} "
426
+ f"task requires `## {expected}` with a filled spec-check "
427
+ f"result, e.g. {example}"
428
+ ]
429
+ return [
430
+ f"decision-anchor.md is missing `## {expected}` with a filled "
431
+ f"spec-check result, e.g. {example}"
432
+ ]
433
+ if not any(
434
+ is_spec_check_result_line(line, language)
435
+ for line in verification_section_lines(anchor_text)
436
+ ):
437
+ return [
438
+ f"decision-anchor.md `## {expected}` is missing a filled "
439
+ f"spec-check result, e.g. {example}"
440
+ ]
441
+ return []
370
442
 
371
443
  @staticmethod
372
444
  def _is_tiny_task(task_data: dict) -> bool:
@@ -6,7 +6,16 @@ from __future__ import annotations
6
6
  from dataclasses import dataclass
7
7
  from datetime import datetime
8
8
  from pathlib import Path
9
-
9
+ import re
10
+
11
+ from infra.config import get_language
12
+ from services.decision_anchor_schema import (
13
+ ACCEPTANCE,
14
+ GOAL,
15
+ VERIFICATION,
16
+ heading_for,
17
+ spec_check_placeholder,
18
+ )
10
19
  from services.task_tree import TaskTreeError, TaskTreeService
11
20
  from services import task_graph
12
21
  from infra.paths import (
@@ -19,6 +28,20 @@ from services.task_gate_profiles import GATE_PROFILE_STRICT
19
28
  from services.task_repository import TaskRepository, TaskRepositoryError
20
29
 
21
30
 
31
+ _GOAL_LINE_RE = re.compile(r"^\*\*(?:Goal|目标):\*\*\s*(.+?)\s*$")
32
+ _GOAL_BOLD_COLON_RE = re.compile(r"^\*\*(?:Goal|目标)\*\*:\s*(.+?)\s*$")
33
+ _GOAL_TABLE_RE = re.compile(
34
+ r"^\|\s*\*\*(?:Goal|目标)\*\*\s*\|\s*(.+?)\s*\|\s*$"
35
+ )
36
+
37
+ _ANCHOR_PLACEHOLDERS = {
38
+ ("zh-CN", GOAL): "<!-- 用一句话描述可观察的目标。 -->",
39
+ ("zh-CN", ACCEPTANCE): "<!-- 至少一条,例如:- [ ] AC-001: ... -->",
40
+ ("en-US", GOAL): "<!-- One sentence describing the observable outcome. -->",
41
+ ("en-US", ACCEPTANCE): "<!-- At least one, e.g. - [ ] AC-001: ... -->",
42
+ }
43
+
44
+
22
45
  class TaskCreationError(RuntimeError):
23
46
  """Raised when task creation cannot be completed."""
24
47
 
@@ -66,6 +89,25 @@ class TaskCreationResult:
66
89
  linked_parent: str | None
67
90
  missing_parent: str | None
68
91
  generated_anchor: bool
92
+ generated_anchor_from_plan: bool
93
+
94
+
95
+ def _extract_plan_goal(plan_text: str) -> str | None:
96
+ """First machine-readable goal line, then the legacy table row."""
97
+ for raw in plan_text.splitlines():
98
+ line = raw.strip()
99
+ for pattern in (_GOAL_LINE_RE, _GOAL_BOLD_COLON_RE, _GOAL_TABLE_RE):
100
+ match = pattern.match(line)
101
+ if match:
102
+ return match.group(1).strip()
103
+ return None
104
+
105
+
106
+ def _anchor_placeholder(section: str, language: str) -> str:
107
+ return _ANCHOR_PLACEHOLDERS.get(
108
+ (language, section),
109
+ _ANCHOR_PLACEHOLDERS[("zh-CN", section)],
110
+ )
69
111
 
70
112
 
71
113
  def ensure_tasks_dir(repo_root: Path) -> Path:
@@ -122,9 +164,10 @@ class TaskCreationService:
122
164
  created_files.append(task_dir / FILE_TASK_JSON)
123
165
 
124
166
  linked_parent, missing_parent = self._link_parent(request, task_dir)
125
- generated_anchor = self._generate_anchor(
167
+ generated_anchor, generated_anchor_from_plan = self._generate_anchor(
126
168
  task_dir,
127
169
  bound_plan_path,
170
+ request.dev_type,
128
171
  )
129
172
  if generated_anchor:
130
173
  created_files.append(task_dir / "decision-anchor.md")
@@ -136,6 +179,7 @@ class TaskCreationService:
136
179
  linked_parent=linked_parent,
137
180
  missing_parent=missing_parent,
138
181
  generated_anchor=generated_anchor,
182
+ generated_anchor_from_plan=generated_anchor_from_plan,
139
183
  )
140
184
 
141
185
  def _task_data(
@@ -316,41 +360,56 @@ class TaskCreationService:
316
360
  "plan file must live under .cowork-flow/plans",
317
361
  )
318
362
 
319
- @staticmethod
320
363
  def _generate_anchor(
364
+ self,
321
365
  task_dir: Path,
322
366
  plan_path: Path | None,
323
- ) -> bool:
324
- if plan_path is None:
325
- return False
326
- if not plan_path.is_file():
327
- return False
367
+ dev_type: str | None = None,
368
+ ) -> tuple[bool, bool]:
369
+ """Create a language-specific anchor scaffold.
328
370
 
329
- try:
330
- plan_text = plan_path.read_text(encoding="utf-8")
331
- except (OSError, UnicodeDecodeError) as error:
332
- raise TaskCreationError(
333
- "TASK-CREATE-PLAN-001",
334
- plan_path,
335
- "plan cannot be read as UTF-8",
336
- ) from error
371
+ A bound plan's goal line (or legacy table row) fills the goal section;
372
+ otherwise the scaffold carries a localized placeholder. An anchor that
373
+ already exists is never overwritten.
374
+ """
375
+ anchor_file = task_dir / "decision-anchor.md"
376
+ if anchor_file.exists():
377
+ return False, False
337
378
 
338
- goal = next(
339
- (
340
- line.split(":", 1)[1].strip()
341
- for line in plan_text.splitlines()
342
- if line.startswith("**Goal:**")
343
- or line.startswith("**目标:**")
344
- ),
345
- None,
379
+ goal: str | None = None
380
+ if plan_path is not None and plan_path.is_file():
381
+ try:
382
+ plan_text = plan_path.read_text(encoding="utf-8")
383
+ except (OSError, UnicodeDecodeError) as error:
384
+ raise TaskCreationError(
385
+ "TASK-CREATE-PLAN-001",
386
+ plan_path,
387
+ "plan cannot be read as UTF-8",
388
+ ) from error
389
+ goal = _extract_plan_goal(plan_text)
390
+
391
+ language = get_language(self.repo_root)
392
+ goal_heading = heading_for(GOAL, language)
393
+ acceptance_heading = heading_for(ACCEPTANCE, language)
394
+ verification_heading = heading_for(VERIFICATION, language)
395
+ goal_body = goal or _anchor_placeholder(GOAL, language)
396
+ acceptance_body = _anchor_placeholder(ACCEPTANCE, language)
397
+ verification_comment = (
398
+ "<!-- strict 任务启动前:把 <dev_type> 和 "
399
+ "<pass / not_applicable + 理由> 替换为实际核对结果;"
400
+ "保留模板占位符会被启动门禁阻断。 -->"
401
+ if language == "zh-CN"
402
+ else "<!-- Before a strict task starts: replace <dev_type> and "
403
+ "<pass / not_applicable + reason> with the actual result; "
404
+ "the start gate rejects the template placeholder. -->"
346
405
  )
347
- anchor_file = task_dir / "decision-anchor.md"
348
- if not goal or anchor_file.exists():
349
- return False
406
+ verification_body = spec_check_placeholder(language, dev_type)
350
407
  try:
351
408
  anchor_file.write_text(
352
- f"## 目标\n\n{goal}\n\n## 验收标准\n"
353
- "<!-- 至少一条,例如:- [ ] AC-001: ... -->\n",
409
+ f"## {goal_heading}\n\n{goal_body}\n\n"
410
+ f"## {acceptance_heading}\n\n{acceptance_body}\n\n"
411
+ f"## {verification_heading}\n\n{verification_comment}\n"
412
+ f"{verification_body}\n",
354
413
  encoding="utf-8",
355
414
  )
356
415
  except OSError as error:
@@ -363,7 +422,7 @@ class TaskCreationService:
363
422
  anchor_file,
364
423
  "decision anchor could not be written",
365
424
  ) from error
366
- return True
425
+ return True, goal is not None
367
426
 
368
427
 
369
428
  def _validate_dev_type(dev_type: str | None, path: Path) -> None:
@@ -4,7 +4,7 @@ A task created today carries `meta.gateProfile = "strict"`; a task without the
4
4
  marker keeps the historical gate (legacy), so nothing that already exists is
5
5
  retroactively re-gated.
6
6
 
7
- The strict gate exists because the old one only checked that the `## 验收标准`
7
+ The strict gate exists because the old one only checked that the acceptance
8
8
  heading was present. An anchor with an empty acceptance section passed, the
9
9
  acceptance/evidence gates then found no AC rows to check, and the whole chain
10
10
  silently did nothing.
@@ -12,20 +12,17 @@ silently did nothing.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
- import re
16
-
17
- from services.fact_view import ACCEPTANCE_RE
15
+ from services.decision_anchor_schema import (
16
+ ACCEPTANCE_RE,
17
+ LIST_ITEM_RE,
18
+ acceptance_section_lines,
19
+ )
18
20
 
19
21
  GATE_PROFILE_STRICT = "strict"
20
22
  GATE_PROFILE_LEGACY = "legacy"
21
23
 
22
24
  LIGHT_TASK_TYPE = "tiny"
23
25
 
24
- ACCEPTANCE_HEADING = "验收标准"
25
-
26
- _HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
27
- _LIST_ITEM_RE = re.compile(r"^\s*[-*]\s")
28
-
29
26
 
30
27
  def resolve_gate_profile(task_data: dict) -> str:
31
28
  """The gate a task is held to; an unmarked task is legacy."""
@@ -72,20 +69,6 @@ def is_light_task(task_data: dict) -> bool:
72
69
  return declares_light_profile(task_data)
73
70
 
74
71
 
75
- def acceptance_section_lines(anchor_text: str) -> list[str]:
76
- """Raw lines under `## 验收标准`, up to the next heading."""
77
- lines: list[str] = []
78
- inside = False
79
- for raw in anchor_text.splitlines():
80
- heading = _HEADING_RE.match(raw.strip())
81
- if heading:
82
- inside = heading.group(1) == ACCEPTANCE_HEADING
83
- continue
84
- if inside:
85
- lines.append(raw)
86
- return lines
87
-
88
-
89
72
  def acceptance_row_blockers(anchor_text: str) -> list[str]:
90
73
  """Why the acceptance section cannot start a strict task.
91
74
 
@@ -101,7 +84,7 @@ def acceptance_row_blockers(anchor_text: str) -> list[str]:
101
84
  if match:
102
85
  ids.append(match.group(2))
103
86
  continue
104
- if _LIST_ITEM_RE.match(raw):
87
+ if LIST_ITEM_RE.match(raw):
105
88
  malformed.append(raw.strip()[:80])
106
89
  blockers: list[str] = []
107
90
  if not ids: