cowork-flow 2.4.0 → 2.4.2

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 (27) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/docs/architecture.en.md +1 -1
  3. package/docs/architecture.md +1 -1
  4. package/package.json +1 -1
  5. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  6. package/presets/codex/.codex-plugin/plugin.json +1 -1
  7. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  8. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  9. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  10. package/template/.cowork-flow/.version +1 -1
  11. package/template/.cowork-flow/scripts/adapters/cli/plan.py +218 -0
  12. package/template/.cowork-flow/scripts/run.py +3 -1
  13. package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +134 -10
  14. package/template/.cowork-flow/scripts/services/fact_view.py +6 -0
  15. package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
  16. package/template/.cowork-flow/scripts/services/plan_readiness.py +583 -0
  17. package/template/.cowork-flow/scripts/services/plan_scaffold.py +133 -0
  18. package/template/.cowork-flow/scripts/services/plan_schema.py +222 -0
  19. package/template/.cowork-flow/scripts/services/task_context.py +83 -5
  20. package/template/.cowork-flow/scripts/services/task_creation.py +41 -25
  21. package/template/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
  22. package/template/.cowork-flow/spec/contracts/plan-binding.md +85 -5
  23. package/template/.cowork-flow/spec/runtime/contract-registry.json +3 -1
  24. package/template/.cowork-flow/templates/plan-template.md +1 -1
  25. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
  26. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +1 -1
  27. package/template/skills/task-planning/SKILL.md +27 -3
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Template-driven plan skeleton generation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from infra.paths import DIR_WORKFLOW
11
+
12
+
13
+ PLAN_TEMPLATE_PATH = f"{DIR_WORKFLOW}/templates/plan-template.md"
14
+ PLAN_DIRECTORY = f"{DIR_WORKFLOW}/plans"
15
+ SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
16
+ H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
17
+
18
+
19
+ class PlanScaffoldError(RuntimeError):
20
+ """Raised when a plan skeleton cannot be generated."""
21
+
22
+ def __init__(self, code: str, path: Path, detail: str) -> None:
23
+ self.code = code
24
+ self.path = path
25
+ self.detail = detail
26
+ super().__init__(f"{code}: {detail}: {path}")
27
+
28
+
29
+ class PlanScaffoldService:
30
+ """Create a plan file from the installed, localized plan template."""
31
+
32
+ def __init__(self, repo_root: Path) -> None:
33
+ self.repo_root = Path(repo_root)
34
+
35
+ def create(
36
+ self,
37
+ title: str,
38
+ slug: str,
39
+ *,
40
+ force: bool = False,
41
+ date_prefix: str | None = None,
42
+ ) -> Path:
43
+ normalized_title = title.strip()
44
+ normalized_slug = slug.strip()
45
+ if not normalized_title:
46
+ raise PlanScaffoldError(
47
+ "PLAN-NEW-TITLE-001",
48
+ self.repo_root,
49
+ "plan title is required",
50
+ )
51
+ if not SLUG_RE.match(normalized_slug):
52
+ raise PlanScaffoldError(
53
+ "PLAN-NEW-SLUG-001",
54
+ self.repo_root,
55
+ "plan slug must contain lowercase ASCII letters, digits, and hyphens",
56
+ )
57
+
58
+ template = self.repo_root / PLAN_TEMPLATE_PATH
59
+ if not template.is_file():
60
+ raise PlanScaffoldError(
61
+ "PLAN-NEW-TEMPLATE-001",
62
+ template,
63
+ "installed plan template is missing; run `cwf project sync .`",
64
+ )
65
+
66
+ plans_dir = self.repo_root / PLAN_DIRECTORY
67
+ day = date_prefix or datetime.now().strftime("%Y-%m-%d")
68
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day):
69
+ raise PlanScaffoldError(
70
+ "PLAN-NEW-DATE-001",
71
+ plans_dir,
72
+ "plan date must use YYYY-MM-DD",
73
+ )
74
+ plan_path = plans_dir / f"{day}-{normalized_slug}.md"
75
+ if plan_path.exists() and not force:
76
+ raise PlanScaffoldError(
77
+ "PLAN-NEW-EXISTS-001",
78
+ plan_path,
79
+ "plan already exists; pass --force to overwrite",
80
+ )
81
+
82
+ try:
83
+ text = template.read_text(encoding="utf-8")
84
+ except (OSError, UnicodeDecodeError) as error:
85
+ raise PlanScaffoldError(
86
+ "PLAN-NEW-TEMPLATE-002",
87
+ template,
88
+ "installed plan template cannot be read as UTF-8",
89
+ ) from error
90
+
91
+ rendered = self._render_title(text, normalized_title, template)
92
+ try:
93
+ plans_dir.mkdir(parents=True, exist_ok=True)
94
+ with plan_path.open("w", encoding="utf-8", newline="\n") as handle:
95
+ handle.write(rendered)
96
+ except OSError as error:
97
+ raise PlanScaffoldError(
98
+ "PLAN-NEW-WRITE-001",
99
+ plan_path,
100
+ "plan file could not be written",
101
+ ) from error
102
+ return plan_path
103
+
104
+ @staticmethod
105
+ def _render_title(text: str, title: str, template: Path) -> str:
106
+ lines = text.splitlines()
107
+ for index, line in enumerate(lines):
108
+ match = H1_RE.match(line.strip())
109
+ if match is None:
110
+ continue
111
+ heading = match.group(1)
112
+ if "<功能>" in heading:
113
+ if title.endswith("实施计划"):
114
+ lines[index] = f"# {title}"
115
+ else:
116
+ lines[index] = f"# {heading.replace('<功能>', title)}"
117
+ return "\n".join(lines) + "\n"
118
+ if "<Feature>" in heading:
119
+ if title.endswith("Implementation Plan"):
120
+ lines[index] = f"# {title}"
121
+ else:
122
+ lines[index] = f"# {heading.replace('<Feature>', title)}"
123
+ return "\n".join(lines) + "\n"
124
+ raise PlanScaffoldError(
125
+ "PLAN-NEW-TITLE-002",
126
+ template,
127
+ "plan template title is missing the <功能>/<Feature> placeholder",
128
+ )
129
+ raise PlanScaffoldError(
130
+ "PLAN-NEW-TITLE-003",
131
+ template,
132
+ "plan template has no top-level title",
133
+ )
@@ -0,0 +1,222 @@
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
+ GOAL_LINE_RE = re.compile(r"^\*\*(?:Goal|目标):\*\*\s*(.+?)\s*$")
56
+ GOAL_BOLD_COLON_RE = re.compile(r"^\*\*(?:Goal|目标)\*\*:\s*(.+?)\s*$")
57
+ GOAL_TABLE_RE = re.compile(
58
+ r"^\|\s*\*\*(?:Goal|目标)\*\*\s*\|\s*(.+?)\s*\|\s*$"
59
+ )
60
+ PLAN_AC_RE = re.compile(
61
+ r"^\s*-\s*(?:\[[ xX]\]\s*)?(AC-\d{3})\s*[::]\s*(.+?)\s*$"
62
+ )
63
+
64
+
65
+ def _language(language: str) -> str:
66
+ return language if language in PLAN_SECTION_HEADINGS else DEFAULT_LANGUAGE
67
+
68
+
69
+ def _headings(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
70
+ h1: list[str] = []
71
+ h2: list[str] = []
72
+ h3: list[str] = []
73
+ for raw in lines:
74
+ line = raw.strip()
75
+ match = H3_RE.match(line)
76
+ if match:
77
+ h3.append(match.group(1))
78
+ continue
79
+ match = H2_RE.match(line)
80
+ if match:
81
+ h2.append(match.group(1))
82
+ continue
83
+ match = H1_RE.match(line)
84
+ if match:
85
+ h1.append(match.group(1))
86
+ return h1, h2, h3
87
+
88
+
89
+ def _section_lines(lines: list[str], heading: str) -> list[str]:
90
+ body: list[str] = []
91
+ inside = False
92
+ for raw in lines:
93
+ line = raw.strip()
94
+ match = H2_RE.match(line)
95
+ if match:
96
+ inside = match.group(1) == heading
97
+ continue
98
+ if inside:
99
+ body.append(raw)
100
+ return body
101
+
102
+
103
+ def _task_prefix_blocker(heading: str, language: str, prefix: str) -> str | None:
104
+ lowered = heading.lower()
105
+ if not (heading.startswith("任务") or lowered.startswith("task")):
106
+ return None
107
+ if heading.startswith(prefix):
108
+ return None
109
+ return (
110
+ f"plan task heading '### {heading}' must use the {language} prefix "
111
+ f"'### {prefix} N:'"
112
+ if language == "zh-CN"
113
+ else f"plan task heading '### {heading}' must use the {language} prefix "
114
+ f"'### {prefix} N:'"
115
+ )
116
+
117
+
118
+ def plan_goal(text: str) -> str | None:
119
+ """First machine-readable plan goal line, then the legacy table row."""
120
+ for raw in structural_lines(text):
121
+ line = raw.strip()
122
+ for pattern in (GOAL_LINE_RE, GOAL_BOLD_COLON_RE, GOAL_TABLE_RE):
123
+ match = pattern.match(line)
124
+ if match:
125
+ return match.group(1).strip()
126
+ return None
127
+
128
+
129
+ def plan_acceptance_criteria(
130
+ text: str,
131
+ language: str,
132
+ ) -> list[dict[str, str]]:
133
+ """Acceptance rows declared by the plan, in document order."""
134
+ resolved = _language(language)
135
+ heading = PLAN_SECTION_HEADINGS[resolved][1]
136
+ rows: list[dict[str, str]] = []
137
+ for raw in _section_lines(structural_lines(text), heading):
138
+ match = PLAN_AC_RE.match(raw)
139
+ if match:
140
+ rows.append({"id": match.group(1), "text": match.group(2).strip()})
141
+ return rows
142
+
143
+
144
+ def section_body(text: str, heading: str) -> list[str]:
145
+ """Raw lines under one `##` heading, excluding fenced code."""
146
+ return _section_lines(structural_lines(text), heading)
147
+
148
+
149
+ def plan_template_blockers(
150
+ text: str,
151
+ language: str,
152
+ *,
153
+ dev_type: str | None = None,
154
+ ) -> list[str]:
155
+ """Why a plan does not match the configured-language template shape."""
156
+ if not text.strip():
157
+ return ["plan is empty"]
158
+ resolved = _language(language)
159
+ expected = PLAN_SECTION_HEADINGS[resolved]
160
+ other = "en-US" if resolved == "zh-CN" else "zh-CN"
161
+ other_headings = set(PLAN_SECTION_HEADINGS[other])
162
+ lines = structural_lines(text)
163
+ h1, h2, h3 = _headings(lines)
164
+ blockers: list[str] = []
165
+
166
+ marker = PLAN_TITLE_MARKERS[resolved]
167
+ if not h1:
168
+ blockers.append(
169
+ f"plan is missing a top-level title; expected: # <功能> {marker}"
170
+ )
171
+ elif marker not in h1[0]:
172
+ blockers.append(
173
+ f"plan title must contain '{marker}' for {resolved}; "
174
+ f"expected: # <功能> {marker}"
175
+ )
176
+
177
+ wrong_language = [heading for heading in h2 if heading in other_headings]
178
+ if wrong_language:
179
+ blockers.append(
180
+ f"plan uses {other} plan headings in a {resolved} project: "
181
+ + ", ".join(f"## {heading}" for heading in wrong_language)
182
+ + "; expected: "
183
+ + ", ".join(f"## {heading}" for heading in expected)
184
+ )
185
+ else:
186
+ missing = [heading for heading in expected if heading not in h2]
187
+ if missing:
188
+ blockers.append(
189
+ f"plan is missing required {resolved} template heading(s): "
190
+ + ", ".join(f"## {heading}" for heading in missing)
191
+ )
192
+ else:
193
+ positions = [h2.index(heading) for heading in expected]
194
+ if positions != sorted(positions):
195
+ blockers.append(
196
+ f"plan template headings are out of order; expected: "
197
+ + " -> ".join(f"## {heading}" for heading in expected)
198
+ )
199
+
200
+ prefix = PLAN_TASK_PREFIXES[resolved]
201
+ for heading in h3:
202
+ blocker = _task_prefix_blocker(heading, resolved, prefix)
203
+ if blocker is not None:
204
+ blockers.append(blocker)
205
+ break
206
+
207
+ final_heading = expected[-1]
208
+ if final_heading in h2:
209
+ body = _section_lines(lines, final_heading)
210
+ if not any(
211
+ spec_check_label(resolved) in line
212
+ and is_spec_check_result_line(line, resolved)
213
+ for line in body
214
+ ):
215
+ blockers.append(
216
+ f"plan `## {final_heading}` is missing a filled spec-check "
217
+ f"result; expected a line starting with '{spec_check_label(resolved)}' "
218
+ f"and containing 'pass' or 'not_applicable', e.g. "
219
+ f"{spec_check_example(resolved, dev_type)}"
220
+ )
221
+
222
+ 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 (
@@ -34,10 +35,21 @@ from services.context_paths import (
34
35
  )
35
36
  from services.decision_anchor_schema import (
36
37
  REQUIRED_SECTIONS,
38
+ VERIFICATION,
39
+ heading_for,
40
+ headings,
41
+ is_spec_check_result_line,
37
42
  present_sections,
38
43
  section_headings,
44
+ spec_check_example,
45
+ verification_section_lines,
39
46
  )
40
- from services.plan_binding import normalize_plan_file, plan_file_blockers
47
+ from services.plan_binding import (
48
+ normalize_plan_file,
49
+ plan_file_blockers,
50
+ plan_template_blockers,
51
+ )
52
+ from services.plan_readiness import plan_ready_snapshot_blockers
41
53
  from services.task_gate_profiles import (
42
54
  GATE_PROFILE_STRICT,
43
55
  acceptance_row_blockers,
@@ -335,6 +347,9 @@ class TaskContextService:
335
347
  task_dir = Path(task_dir)
336
348
  blockers: list[str] = []
337
349
  task_data = self._task_data(task_dir)
350
+ strict = resolve_gate_profile(task_data) == GATE_PROFILE_STRICT
351
+ light = self._is_tiny_task(task_data)
352
+ language = get_language(self.repo_root)
338
353
  if not (task_dir / FILE_TASK_JSON).is_file():
339
354
  blockers.append("task.json is missing")
340
355
  anchor_text = read_text_utf8(task_dir / "decision-anchor.md")
@@ -342,14 +357,38 @@ class TaskContextService:
342
357
  blockers.append("decision-anchor.md is missing or empty")
343
358
  else:
344
359
  blockers.extend(self._decision_anchor_section_blockers(anchor_text))
345
- if resolve_gate_profile(task_data) == GATE_PROFILE_STRICT:
360
+ if strict:
346
361
  blockers.extend(acceptance_row_blockers(anchor_text))
362
+ if not light:
363
+ blockers.extend(
364
+ self._anchor_spec_check_blockers(
365
+ anchor_text,
366
+ language,
367
+ task_data.get("dev_type"),
368
+ )
369
+ )
347
370
  for context_file in ("implement.jsonl",):
348
371
  if not read_text_utf8(task_dir / context_file):
349
372
  blockers.append(f"{context_file} is missing or empty")
350
- if (task_dir / FILE_TASK_JSON).is_file() and not self._is_tiny_task(task_data):
351
- blockers.extend(self._plan_file_blockers(task_data))
352
- if resolve_gate_profile(task_data) == GATE_PROFILE_STRICT:
373
+ if (task_dir / FILE_TASK_JSON).is_file() and not light:
374
+ plan_blockers = self._plan_file_blockers(task_data)
375
+ blockers.extend(plan_blockers)
376
+ if strict and not plan_blockers:
377
+ blockers.extend(
378
+ plan_template_blockers(self.repo_root, task_data, language)
379
+ )
380
+ if strict and not light:
381
+ meta = task_data.get("meta")
382
+ meta = meta if isinstance(meta, dict) else {}
383
+ blockers.extend(
384
+ plan_ready_snapshot_blockers(
385
+ self.repo_root,
386
+ task_dir,
387
+ task_data,
388
+ required=meta.get("planReadyRequired") is True,
389
+ )
390
+ )
391
+ if strict:
353
392
  blockers.extend(dev_type_blocker(task_data))
354
393
  return tuple(blockers)
355
394
 
@@ -374,6 +413,45 @@ class TaskContextService:
374
413
  if section not in present
375
414
  ]
376
415
 
416
+ @staticmethod
417
+ def _anchor_spec_check_blockers(
418
+ anchor_text: str,
419
+ language: str,
420
+ dev_type: str | None,
421
+ ) -> list[str]:
422
+ expected = heading_for(VERIFICATION, language)
423
+ actual = headings(anchor_text)
424
+ normalized_dev_type = (
425
+ dev_type.strip()
426
+ if isinstance(dev_type, str) and dev_type.strip()
427
+ else None
428
+ )
429
+ example = spec_check_example(language, normalized_dev_type)
430
+ if expected not in actual:
431
+ other = heading_for(
432
+ VERIFICATION,
433
+ "en-US" if language == "zh-CN" else "zh-CN",
434
+ )
435
+ if other in actual:
436
+ return [
437
+ f"decision-anchor.md uses `## {other}`; a strict {language} "
438
+ f"task requires `## {expected}` with a filled spec-check "
439
+ f"result, e.g. {example}"
440
+ ]
441
+ return [
442
+ f"decision-anchor.md is missing `## {expected}` with a filled "
443
+ f"spec-check result, e.g. {example}"
444
+ ]
445
+ if not any(
446
+ is_spec_check_result_line(line, language)
447
+ for line in verification_section_lines(anchor_text)
448
+ ):
449
+ return [
450
+ f"decision-anchor.md `## {expected}` is missing a filled "
451
+ f"spec-check result, e.g. {example}"
452
+ ]
453
+ return []
454
+
377
455
  @staticmethod
378
456
  def _is_tiny_task(task_data: dict) -> bool:
379
457
  """Field-level reading only: the plan-file waiver also covers
@@ -6,14 +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
- import re
10
9
 
11
10
  from infra.config import get_language
12
11
  from services.decision_anchor_schema import (
13
12
  ACCEPTANCE,
14
13
  GOAL,
14
+ VERIFICATION,
15
15
  heading_for,
16
+ spec_check_placeholder,
16
17
  )
18
+ from services.plan_schema import plan_acceptance_criteria, plan_goal
17
19
  from services.task_tree import TaskTreeError, TaskTreeService
18
20
  from services import task_graph
19
21
  from infra.paths import (
@@ -22,16 +24,10 @@ from infra.paths import (
22
24
  get_tasks_dir,
23
25
  )
24
26
  from services.plan_binding import PlanBindingError, bind_plan_file
25
- from services.task_gate_profiles import GATE_PROFILE_STRICT
27
+ from services.task_gate_profiles import GATE_PROFILE_STRICT, LIGHT_TASK_TYPE
26
28
  from services.task_repository import TaskRepository, TaskRepositoryError
27
29
 
28
30
 
29
- _GOAL_LINE_RE = re.compile(r"^\*\*(?:Goal|目标):\*\*\s*(.+?)\s*$")
30
- _GOAL_BOLD_COLON_RE = re.compile(r"^\*\*(?:Goal|目标)\*\*:\s*(.+?)\s*$")
31
- _GOAL_TABLE_RE = re.compile(
32
- r"^\|\s*\*\*(?:Goal|目标)\*\*\s*\|\s*(.+?)\s*\|\s*$"
33
- )
34
-
35
31
  _ANCHOR_PLACEHOLDERS = {
36
32
  ("zh-CN", GOAL): "<!-- 用一句话描述可观察的目标。 -->",
37
33
  ("zh-CN", ACCEPTANCE): "<!-- 至少一条,例如:- [ ] AC-001: ... -->",
@@ -90,17 +86,6 @@ class TaskCreationResult:
90
86
  generated_anchor_from_plan: bool
91
87
 
92
88
 
93
- def _extract_plan_goal(plan_text: str) -> str | None:
94
- """First machine-readable goal line, then the legacy table row."""
95
- for raw in plan_text.splitlines():
96
- line = raw.strip()
97
- for pattern in (_GOAL_LINE_RE, _GOAL_BOLD_COLON_RE, _GOAL_TABLE_RE):
98
- match = pattern.match(line)
99
- if match:
100
- return match.group(1).strip()
101
- return None
102
-
103
-
104
89
  def _anchor_placeholder(section: str, language: str) -> str:
105
90
  return _ANCHOR_PLACEHOLDERS.get(
106
91
  (language, section),
@@ -108,6 +93,12 @@ def _anchor_placeholder(section: str, language: str) -> str:
108
93
  )
109
94
 
110
95
 
96
+ def _render_acceptance(rows: list[dict[str, str]]) -> str:
97
+ return "\n".join(
98
+ f"- [ ] {row['id']}: {row['text']}" for row in rows
99
+ )
100
+
101
+
111
102
  def ensure_tasks_dir(repo_root: Path) -> Path:
112
103
  """Ensure active and archived task directories exist."""
113
104
  tasks_dir = get_tasks_dir(repo_root)
@@ -165,6 +156,7 @@ class TaskCreationService:
165
156
  generated_anchor, generated_anchor_from_plan = self._generate_anchor(
166
157
  task_dir,
167
158
  bound_plan_path,
159
+ request.dev_type,
168
160
  )
169
161
  if generated_anchor:
170
162
  created_files.append(task_dir / "decision-anchor.md")
@@ -189,6 +181,10 @@ class TaskCreationService:
189
181
  created_at = request.created_at or datetime.now().strftime("%Y-%m-%d")
190
182
  meta = dict(plan_metadata) if isinstance(plan_metadata, dict) else {}
191
183
  meta.setdefault("gateProfile", GATE_PROFILE_STRICT)
184
+ if meta.get("gateProfile") == GATE_PROFILE_STRICT and (
185
+ request.task_type != LIGHT_TASK_TYPE
186
+ ):
187
+ meta["planReadyRequired"] = True
192
188
  # The light profile is declarable at creation only: this is the sole
193
189
  # write of the field, and no lifecycle command changes it later.
194
190
  if request.task_type:
@@ -361,18 +357,21 @@ class TaskCreationService:
361
357
  self,
362
358
  task_dir: Path,
363
359
  plan_path: Path | None,
360
+ dev_type: str | None = None,
364
361
  ) -> tuple[bool, bool]:
365
362
  """Create a language-specific anchor scaffold.
366
363
 
367
- A bound plan's goal line (or legacy table row) fills the goal section;
368
- otherwise the scaffold carries a localized placeholder. An anchor that
364
+ A bound plan's goal and AC rows fill the corresponding sections;
365
+ otherwise each section carries a localized placeholder. An anchor that
369
366
  already exists is never overwritten.
370
367
  """
371
368
  anchor_file = task_dir / "decision-anchor.md"
372
369
  if anchor_file.exists():
373
370
  return False, False
374
371
 
372
+ language = get_language(self.repo_root)
375
373
  goal: str | None = None
374
+ acceptance_rows: list[dict[str, str]] = []
376
375
  if plan_path is not None and plan_path.is_file():
377
376
  try:
378
377
  plan_text = plan_path.read_text(encoding="utf-8")
@@ -382,17 +381,34 @@ class TaskCreationService:
382
381
  plan_path,
383
382
  "plan cannot be read as UTF-8",
384
383
  ) from error
385
- goal = _extract_plan_goal(plan_text)
384
+ goal = plan_goal(plan_text)
385
+ acceptance_rows = plan_acceptance_criteria(plan_text, language)
386
386
 
387
- language = get_language(self.repo_root)
388
387
  goal_heading = heading_for(GOAL, language)
389
388
  acceptance_heading = heading_for(ACCEPTANCE, language)
389
+ verification_heading = heading_for(VERIFICATION, language)
390
390
  goal_body = goal or _anchor_placeholder(GOAL, language)
391
- acceptance_body = _anchor_placeholder(ACCEPTANCE, language)
391
+ acceptance_body = (
392
+ _render_acceptance(acceptance_rows)
393
+ if acceptance_rows
394
+ else _anchor_placeholder(ACCEPTANCE, language)
395
+ )
396
+ verification_comment = (
397
+ "<!-- strict 任务启动前:把 <dev_type> 和 "
398
+ "<pass / not_applicable + 理由> 替换为实际核对结果;"
399
+ "保留模板占位符会被启动门禁阻断。 -->"
400
+ if language == "zh-CN"
401
+ else "<!-- Before a strict task starts: replace <dev_type> and "
402
+ "<pass / not_applicable + reason> with the actual result; "
403
+ "the start gate rejects the template placeholder. -->"
404
+ )
405
+ verification_body = spec_check_placeholder(language, dev_type)
392
406
  try:
393
407
  anchor_file.write_text(
394
408
  f"## {goal_heading}\n\n{goal_body}\n\n"
395
- f"## {acceptance_heading}\n\n{acceptance_body}\n",
409
+ f"## {acceptance_heading}\n\n{acceptance_body}\n\n"
410
+ f"## {verification_heading}\n\n{verification_comment}\n"
411
+ f"{verification_body}\n",
396
412
  encoding="utf-8",
397
413
  )
398
414
  except OSError as error:
@@ -49,7 +49,7 @@ and the right column the `en-US` output.
49
49
  | `## 被拒方案` / `## Rejected Options` | optional | required for brainstorming tasks, so a rejected option is not reconsidered during execution |
50
50
  | `## 关键假设` / `## Key Assumptions` | optional | filled in for complex tasks to surface implicit constraints; not read by the runtime parser |
51
51
  | `## 范围边界` / `## Scope Boundary` | optional | prevents scope creep |
52
- | `## 验证命令` / `## Verification Commands` | optional | filled in for complex tasks, as the basis for automated verification; when the changed files are covered by `.cowork-flow/spec/backend/` or `frontend/` rules, it must also carry the spec/gate check for the task's `dev_type` (deciding command, or clause-by-clause `pass` / `not_applicable` with reason) |
52
+ | `## 验证命令` / `## Verification Commands` | required for strict non-Tiny; optional for legacy/Tiny | filled in for complex tasks, as the basis for automated verification; a strict non-Tiny task also needs the configured-language section and a filled spec/gate check for the task's `dev_type` (deciding command, or clause-by-clause `pass` / `not_applicable` with reason) |
53
53
 
54
54
  ### Machine-readable line formats (for injection)
55
55
 
@@ -59,11 +59,13 @@ fact view (`services/fact_view.parse_decision_anchor`). Line-format conventions:
59
59
  - `## 验证命令`: one command per line (an optional `- ` prefix), truncated to 120
60
60
  characters, at most 10 lines; the implementing agent runs them itself
61
61
  (self-declared, self-checked) and the reviewer verifies the execution evidence.
62
- When the changed files are covered by `.cowork-flow/spec/<dev_type>/` rules, the
63
- section must also carry the spec/gate check for the task's `dev_type`: a deciding
64
- command, or a `规范核对:` line reviewed clause by clause (`pass` /
65
- `not_applicable` + reason). This is a strong review reminder, not a `spec-check`
66
- hard gate.
62
+ A strict non-Tiny task must also carry a `规范核对:` line in the configured
63
+ language's section, with the actual `pass` / `not_applicable` result and a
64
+ reason. The template placeholder (`<pass / not_applicable + 理由>`) does not
65
+ satisfy the start gate. This line is a hard start gate for strict non-Tiny
66
+ tasks, while its content remains self-declared and is reviewed by the check
67
+ agent. The fact view recognizes the line and does not return it as a shell
68
+ command.
67
69
  - `## 范围边界`: only the first non-empty line is taken, truncated to 160
68
70
  characters.
69
71
 
@@ -98,6 +100,13 @@ After merging (5 files): `decision-anchor.md` + `implement.jsonl` + `check.jsonl
98
100
  `## Acceptance Criteria`, no duplicate AC ids, no non-AC list rows in that
99
101
  section, and a declared `dev_type` (otherwise `task specs` dev-type dispatch
100
102
  silently comes back shorter).
103
+ - A strict non-Tiny task also requires the configured-language
104
+ `## 验证命令` / `## Verification Commands` section with a filled
105
+ `规范核对:` / `Spec check:` line. A missing section, the other-language
106
+ heading, or the template placeholder blocks start. Tiny tasks and legacy tasks
107
+ are exempt. The bound plan's `## 最终验证` / `## Final Verification` section
108
+ carries the same filled check; plan structure and language are covered by
109
+ `plan-binding.md`.
101
110
  - An acceptance row's checkbox state (`- [ ]` / `- [x]`) and the evidence records
102
111
  (the task directory's `evidence.jsonl`) are the fact source of the completion
103
112
  gate: completion is blocked when an unchecked AC exists (`LIFECYCLE-AC-001`), a