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.
- package/CHANGELOG.md +25 -1
- package/docs/architecture.en.md +1 -1
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/plan.py +218 -0
- package/template/.cowork-flow/scripts/run.py +3 -1
- package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +134 -10
- package/template/.cowork-flow/scripts/services/fact_view.py +6 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
- package/template/.cowork-flow/scripts/services/plan_readiness.py +583 -0
- package/template/.cowork-flow/scripts/services/plan_scaffold.py +133 -0
- package/template/.cowork-flow/scripts/services/plan_schema.py +222 -0
- package/template/.cowork-flow/scripts/services/task_context.py +83 -5
- package/template/.cowork-flow/scripts/services/task_creation.py +41 -25
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
- package/template/.cowork-flow/spec/contracts/plan-binding.md +85 -5
- package/template/.cowork-flow/spec/runtime/contract-registry.json +3 -1
- package/template/.cowork-flow/templates/plan-template.md +1 -1
- package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
- package/template/locales/en-US/.cowork-flow/templates/plan-template.md +1 -1
- 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
|
|
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
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
368
|
-
otherwise
|
|
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 =
|
|
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 =
|
|
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;
|
|
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
|
-
|
|
63
|
-
section
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|