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,583 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Plan readiness: completeness checks and the approved-plan snapshot."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from infra.config import get_language
|
|
12
|
+
from infra.files import read_text_utf8
|
|
13
|
+
from infra.paths import DIR_WORKFLOW
|
|
14
|
+
from services.decision_anchor_schema import (
|
|
15
|
+
ANGLE_SPAN_RE,
|
|
16
|
+
REQUIRED_SECTIONS,
|
|
17
|
+
VERIFICATION,
|
|
18
|
+
heading_for,
|
|
19
|
+
headings,
|
|
20
|
+
is_spec_check_result_line,
|
|
21
|
+
present_sections,
|
|
22
|
+
section_headings,
|
|
23
|
+
spec_check_example,
|
|
24
|
+
verification_section_lines,
|
|
25
|
+
)
|
|
26
|
+
from services.plan_binding import plan_file_blockers, plan_template_blockers
|
|
27
|
+
from services.plan_schema import (
|
|
28
|
+
plan_acceptance_criteria,
|
|
29
|
+
plan_goal,
|
|
30
|
+
)
|
|
31
|
+
from services.task_gate_profiles import (
|
|
32
|
+
acceptance_row_blockers,
|
|
33
|
+
declares_light_profile,
|
|
34
|
+
dev_type_blocker,
|
|
35
|
+
)
|
|
36
|
+
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PLAN_READY_KEY = "planReady"
|
|
40
|
+
TASK_HEADING_RE = re.compile(r"^###\s+(?:任务|Task)\s+\d+\s*[::]\s*(.+?)\s*$")
|
|
41
|
+
H2_RE = re.compile(r"^##(?!#)\s+(.+?)\s*$")
|
|
42
|
+
BOLD_LABEL_RE = re.compile(r"^\*\*(.+?)\*\*\s*$")
|
|
43
|
+
FENCE_RE = re.compile(r"^(```|~~~)")
|
|
44
|
+
PLAN_TEMPLATE_PATH = f"{DIR_WORKFLOW}/templates/plan-template.md"
|
|
45
|
+
|
|
46
|
+
TASK_FIELDS = {
|
|
47
|
+
"zh-CN": (
|
|
48
|
+
"目的",
|
|
49
|
+
"代码事实来源",
|
|
50
|
+
"文件边界",
|
|
51
|
+
"关键符号",
|
|
52
|
+
"实现说明",
|
|
53
|
+
"测试证明",
|
|
54
|
+
"验证命令",
|
|
55
|
+
"完成条件",
|
|
56
|
+
"禁止漂移",
|
|
57
|
+
"偏差条件",
|
|
58
|
+
),
|
|
59
|
+
"en-US": (
|
|
60
|
+
"Purpose",
|
|
61
|
+
"Code Fact Sources",
|
|
62
|
+
"File Boundaries",
|
|
63
|
+
"Key Symbols",
|
|
64
|
+
"Implementation Notes",
|
|
65
|
+
"Test Proof",
|
|
66
|
+
"Verification Command",
|
|
67
|
+
"Completion Conditions",
|
|
68
|
+
"Prohibited Drift",
|
|
69
|
+
"Deviation Conditions",
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
TINY_TASK_FIELDS = {
|
|
73
|
+
"zh-CN": ("文件边界", "验证命令", "完成条件"),
|
|
74
|
+
"en-US": ("File Boundaries", "Verification Command", "Completion Conditions"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PlanReadinessError(RuntimeError):
|
|
79
|
+
"""Raised when a plan is not ready for human review."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, blockers: list[str]) -> None:
|
|
82
|
+
self.blockers = tuple(blockers)
|
|
83
|
+
super().__init__("plan is not ready")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PlanReadinessService:
|
|
87
|
+
"""Validate planning artifacts and persist the reviewed snapshot."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, repo_root: Path) -> None:
|
|
90
|
+
self.repo_root = Path(repo_root)
|
|
91
|
+
self.repository = TaskRepository(self.repo_root)
|
|
92
|
+
|
|
93
|
+
def finalize(self, task_dir: Path) -> dict:
|
|
94
|
+
task_dir = Path(task_dir)
|
|
95
|
+
task_data = self._load_task(task_dir)
|
|
96
|
+
language = get_language(self.repo_root)
|
|
97
|
+
blockers = plan_readiness_blockers(
|
|
98
|
+
self.repo_root,
|
|
99
|
+
task_dir,
|
|
100
|
+
task_data,
|
|
101
|
+
language=language,
|
|
102
|
+
)
|
|
103
|
+
if blockers:
|
|
104
|
+
raise PlanReadinessError(blockers)
|
|
105
|
+
|
|
106
|
+
plan_path = _plan_path(self.repo_root, task_data)
|
|
107
|
+
anchor_path = task_dir / "decision-anchor.md"
|
|
108
|
+
snapshot = {
|
|
109
|
+
"planDigest": _file_digest(plan_path),
|
|
110
|
+
"anchorDigest": _file_digest(anchor_path),
|
|
111
|
+
"readyAt": datetime.now(timezone.utc).strftime(
|
|
112
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
113
|
+
),
|
|
114
|
+
"devType": _dev_type(task_data),
|
|
115
|
+
}
|
|
116
|
+
meta = dict(task_data.get("meta") or {})
|
|
117
|
+
meta[PLAN_READY_KEY] = snapshot
|
|
118
|
+
try:
|
|
119
|
+
self.repository.save(task_dir, {"meta": meta})
|
|
120
|
+
except TaskRepositoryError as error:
|
|
121
|
+
raise PlanReadinessError(
|
|
122
|
+
[f"task.json could not be updated with the plan-ready snapshot: {error}"]
|
|
123
|
+
) from error
|
|
124
|
+
return snapshot
|
|
125
|
+
|
|
126
|
+
def _load_task(self, task_dir: Path) -> dict:
|
|
127
|
+
try:
|
|
128
|
+
return self.repository.load(task_dir)
|
|
129
|
+
except TaskRepositoryError as error:
|
|
130
|
+
raise PlanReadinessError(
|
|
131
|
+
[f"task.json could not be read: {error}"]
|
|
132
|
+
) from error
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def plan_readiness_blockers(
|
|
136
|
+
repo_root: Path,
|
|
137
|
+
task_dir: Path,
|
|
138
|
+
task_data: dict,
|
|
139
|
+
*,
|
|
140
|
+
language: str | None = None,
|
|
141
|
+
) -> list[str]:
|
|
142
|
+
"""Why the planning artifacts are not ready for human review."""
|
|
143
|
+
repo_root = Path(repo_root)
|
|
144
|
+
task_dir = Path(task_dir)
|
|
145
|
+
resolved_language = language or get_language(repo_root)
|
|
146
|
+
status = task_data.get("status")
|
|
147
|
+
blockers: list[str] = []
|
|
148
|
+
if status != "planning":
|
|
149
|
+
blockers.append(
|
|
150
|
+
"plan finalize only runs on a planning task; "
|
|
151
|
+
f"current status is {status or 'missing'}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
blocking_plan_facts = plan_file_blockers(repo_root, task_data)
|
|
155
|
+
blockers.extend(blocking_plan_facts)
|
|
156
|
+
if blocking_plan_facts:
|
|
157
|
+
return blockers
|
|
158
|
+
blockers.extend(
|
|
159
|
+
plan_template_blockers(repo_root, task_data, resolved_language)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
plan_path = _plan_path(repo_root, task_data)
|
|
163
|
+
plan_text = read_text_utf8(plan_path)
|
|
164
|
+
if not plan_text.strip():
|
|
165
|
+
blockers.append(f"plan is empty: {_display(repo_root, plan_path)}")
|
|
166
|
+
return blockers
|
|
167
|
+
|
|
168
|
+
goal = plan_goal(plan_text)
|
|
169
|
+
if not goal:
|
|
170
|
+
blockers.append(
|
|
171
|
+
"plan is missing its machine-readable goal line "
|
|
172
|
+
"(`**目标:**` / `**Goal:**`)"
|
|
173
|
+
)
|
|
174
|
+
elif not _has_concrete_text(goal):
|
|
175
|
+
blockers.append(
|
|
176
|
+
"plan goal line still uses a template placeholder; "
|
|
177
|
+
"replace it with the observable behavior"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
acceptance = plan_acceptance_criteria(plan_text, resolved_language)
|
|
181
|
+
if not acceptance:
|
|
182
|
+
blockers.append(
|
|
183
|
+
"plan `## 验收标准` / `## Acceptance Criteria` has no AC-NNN rows"
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
placeholder_rows = [
|
|
187
|
+
row["id"]
|
|
188
|
+
for row in acceptance
|
|
189
|
+
if not _has_concrete_text(row["text"])
|
|
190
|
+
]
|
|
191
|
+
if placeholder_rows:
|
|
192
|
+
blockers.append(
|
|
193
|
+
"plan acceptance criterion(s) still use template placeholders: "
|
|
194
|
+
+ ", ".join(placeholder_rows)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expected_sections = {
|
|
198
|
+
"zh-CN": ("目标", "全局约束", "文件边界", "任务"),
|
|
199
|
+
"en-US": ("Goal", "Global Constraints", "File Boundaries", "Tasks"),
|
|
200
|
+
}.get(
|
|
201
|
+
resolved_language,
|
|
202
|
+
("目标", "全局约束", "文件边界", "任务"),
|
|
203
|
+
)
|
|
204
|
+
template_path = repo_root / PLAN_TEMPLATE_PATH
|
|
205
|
+
template_text = (
|
|
206
|
+
read_text_utf8(template_path) if template_path.is_file() else ""
|
|
207
|
+
)
|
|
208
|
+
if not template_text.strip():
|
|
209
|
+
blockers.append(
|
|
210
|
+
"installed plan template is missing or empty; run "
|
|
211
|
+
"`cwf project sync .` before finalizing"
|
|
212
|
+
)
|
|
213
|
+
return blockers
|
|
214
|
+
template_sections = {
|
|
215
|
+
heading: _raw_section_lines(template_text, heading)
|
|
216
|
+
for heading in expected_sections
|
|
217
|
+
}
|
|
218
|
+
template_fields = _task_fields(
|
|
219
|
+
template_sections.get(expected_sections[-1], [])
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for heading in expected_sections[:-1]:
|
|
223
|
+
if not _has_meaningful_lines(
|
|
224
|
+
_raw_section_lines(plan_text, heading),
|
|
225
|
+
template_sections.get(heading, []),
|
|
226
|
+
):
|
|
227
|
+
blockers.append(
|
|
228
|
+
f"plan `## {heading}` has no concrete content; "
|
|
229
|
+
"fill the template placeholder before finalizing"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
task_blocks = _task_blocks(plan_text)
|
|
233
|
+
if not task_blocks:
|
|
234
|
+
blockers.append(
|
|
235
|
+
"plan `## 任务` / `## Tasks` has no `### 任务 N` / `### Task N` block"
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
tiny = declares_light_profile(task_data)
|
|
239
|
+
required_fields = TINY_TASK_FIELDS.get(
|
|
240
|
+
resolved_language, TINY_TASK_FIELDS["zh-CN"]
|
|
241
|
+
) if tiny else TASK_FIELDS.get(
|
|
242
|
+
resolved_language, TASK_FIELDS["zh-CN"]
|
|
243
|
+
)
|
|
244
|
+
missing_template_fields = [
|
|
245
|
+
field for field in required_fields if field not in template_fields
|
|
246
|
+
]
|
|
247
|
+
if missing_template_fields:
|
|
248
|
+
blockers.append(
|
|
249
|
+
"installed plan template is missing task brief field(s): "
|
|
250
|
+
+ ", ".join(missing_template_fields)
|
|
251
|
+
)
|
|
252
|
+
verification_field = (
|
|
253
|
+
"Verification Command"
|
|
254
|
+
if resolved_language == "en-US"
|
|
255
|
+
else "验证命令"
|
|
256
|
+
)
|
|
257
|
+
for block in task_blocks:
|
|
258
|
+
fields = _task_fields(block["lines"])
|
|
259
|
+
missing = [
|
|
260
|
+
field for field in required_fields if field not in fields
|
|
261
|
+
]
|
|
262
|
+
if missing:
|
|
263
|
+
blockers.append(
|
|
264
|
+
f"plan task `### {block['heading']}` is missing brief "
|
|
265
|
+
f"field(s): {', '.join(missing)}"
|
|
266
|
+
)
|
|
267
|
+
continue
|
|
268
|
+
empty = [
|
|
269
|
+
field
|
|
270
|
+
for field in required_fields
|
|
271
|
+
if (
|
|
272
|
+
not _has_verification_command(fields[field])
|
|
273
|
+
if field == verification_field
|
|
274
|
+
else not _has_meaningful_lines(
|
|
275
|
+
fields[field], template_fields.get(field, [])
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
]
|
|
279
|
+
if empty:
|
|
280
|
+
blockers.append(
|
|
281
|
+
f"plan task `### {block['heading']}` has empty brief "
|
|
282
|
+
f"field(s): {', '.join(empty)}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
anchor_path = task_dir / "decision-anchor.md"
|
|
286
|
+
anchor_text = read_text_utf8(anchor_path)
|
|
287
|
+
if not anchor_text.strip():
|
|
288
|
+
blockers.append("decision-anchor.md is missing or empty")
|
|
289
|
+
else:
|
|
290
|
+
blockers.extend(_anchor_blockers(anchor_text, resolved_language))
|
|
291
|
+
blockers.extend(acceptance_row_blockers(anchor_text))
|
|
292
|
+
blockers.extend(
|
|
293
|
+
_anchor_spec_check_blockers(
|
|
294
|
+
anchor_text,
|
|
295
|
+
resolved_language,
|
|
296
|
+
_dev_type(task_data),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
blockers.extend(_acceptance_sync_blockers(acceptance, anchor_text))
|
|
300
|
+
blockers.extend(_goal_sync_blockers(goal, anchor_text))
|
|
301
|
+
|
|
302
|
+
implement_index = task_dir / "implement.jsonl"
|
|
303
|
+
if not read_text_utf8(implement_index).strip():
|
|
304
|
+
blockers.append("implement.jsonl is missing or empty")
|
|
305
|
+
|
|
306
|
+
blockers.extend(dev_type_blocker(task_data))
|
|
307
|
+
return blockers
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def plan_ready_snapshot_blockers(
|
|
311
|
+
repo_root: Path,
|
|
312
|
+
task_dir: Path,
|
|
313
|
+
task_data: dict,
|
|
314
|
+
*,
|
|
315
|
+
required: bool,
|
|
316
|
+
) -> list[str]:
|
|
317
|
+
"""Why the stored plan-ready snapshot is absent or no longer current."""
|
|
318
|
+
meta = task_data.get("meta")
|
|
319
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
320
|
+
snapshot = meta.get(PLAN_READY_KEY)
|
|
321
|
+
if not isinstance(snapshot, dict):
|
|
322
|
+
if not required:
|
|
323
|
+
return []
|
|
324
|
+
return [
|
|
325
|
+
"strict task has no plan-ready snapshot; run plan finalize "
|
|
326
|
+
f"`{_display(Path(repo_root), Path(task_dir))}` "
|
|
327
|
+
"before human review/start"
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
plan_path = _plan_path(Path(repo_root), task_data)
|
|
331
|
+
anchor_path = Path(task_dir) / "decision-anchor.md"
|
|
332
|
+
if not plan_path.is_file():
|
|
333
|
+
return [
|
|
334
|
+
"plan-ready snapshot is stale: the bound plan file is missing; "
|
|
335
|
+
"restore it and rerun `plan finalize`"
|
|
336
|
+
]
|
|
337
|
+
if not anchor_path.is_file():
|
|
338
|
+
return [
|
|
339
|
+
"plan-ready snapshot is stale: decision-anchor.md is missing; "
|
|
340
|
+
"restore it and rerun `plan finalize`"
|
|
341
|
+
]
|
|
342
|
+
if snapshot.get("planDigest") != _file_digest(plan_path) or snapshot.get(
|
|
343
|
+
"anchorDigest"
|
|
344
|
+
) != _file_digest(anchor_path) or snapshot.get("devType") != _dev_type(
|
|
345
|
+
task_data
|
|
346
|
+
):
|
|
347
|
+
return [
|
|
348
|
+
"plan-ready snapshot is stale: the plan, decision-anchor, or "
|
|
349
|
+
"dev_type changed after finalize; rerun `plan finalize` and "
|
|
350
|
+
"review the new version"
|
|
351
|
+
]
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _file_digest(path: Path) -> str:
|
|
356
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _dev_type(task_data: dict) -> str | None:
|
|
360
|
+
value = task_data.get("dev_type")
|
|
361
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _display(repo_root: Path, path: Path) -> str:
|
|
365
|
+
try:
|
|
366
|
+
return Path(path).resolve().relative_to(repo_root.resolve()).as_posix()
|
|
367
|
+
except (OSError, ValueError):
|
|
368
|
+
return str(path)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _plan_path(repo_root: Path, task_data: dict) -> Path:
|
|
372
|
+
meta = task_data.get("meta")
|
|
373
|
+
plan_file = meta.get("planFile") if isinstance(meta, dict) else None
|
|
374
|
+
if not isinstance(plan_file, str) or not plan_file.strip():
|
|
375
|
+
return repo_root / DIR_WORKFLOW / "plans" / "__missing__"
|
|
376
|
+
return repo_root / plan_file.strip()
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _has_meaningful_lines(
|
|
380
|
+
lines: list[str],
|
|
381
|
+
template_lines: list[str] | tuple[str, ...] = (),
|
|
382
|
+
) -> bool:
|
|
383
|
+
"""Whether a section or field has content beyond its template body."""
|
|
384
|
+
template = {
|
|
385
|
+
normalized
|
|
386
|
+
for raw in template_lines
|
|
387
|
+
if (normalized := _normalize_placeholder_line(raw.strip()))
|
|
388
|
+
and not normalized.startswith(("```", "~~~"))
|
|
389
|
+
}
|
|
390
|
+
for raw in lines:
|
|
391
|
+
line = raw.strip()
|
|
392
|
+
if not line or line.startswith("<!--"):
|
|
393
|
+
continue
|
|
394
|
+
if line in ("```", "~~~") or line.startswith(("```", "~~~")):
|
|
395
|
+
continue
|
|
396
|
+
body = _normalize_placeholder_line(line)
|
|
397
|
+
if not body or body in template:
|
|
398
|
+
continue
|
|
399
|
+
if _has_concrete_text(body):
|
|
400
|
+
return True
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _has_verification_command(lines: list[str]) -> bool:
|
|
405
|
+
"""A verification field needs a concrete command inside a code fence."""
|
|
406
|
+
fence: str | None = None
|
|
407
|
+
for raw in lines:
|
|
408
|
+
marker = FENCE_RE.match(raw.strip())
|
|
409
|
+
if marker:
|
|
410
|
+
if fence is None:
|
|
411
|
+
fence = marker.group(1)
|
|
412
|
+
elif marker.group(1) == fence:
|
|
413
|
+
fence = None
|
|
414
|
+
continue
|
|
415
|
+
if fence is None:
|
|
416
|
+
continue
|
|
417
|
+
command = raw.strip()
|
|
418
|
+
if command.startswith(("#", "//")):
|
|
419
|
+
continue
|
|
420
|
+
if _has_concrete_text(command):
|
|
421
|
+
return True
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _raw_section_lines(text: str, heading: str) -> list[str]:
|
|
426
|
+
"""Raw lines under one `##` heading, including fenced code."""
|
|
427
|
+
body: list[str] = []
|
|
428
|
+
inside = False
|
|
429
|
+
for raw in text.splitlines():
|
|
430
|
+
match = H2_RE.match(raw.strip())
|
|
431
|
+
if match:
|
|
432
|
+
inside = match.group(1) == heading
|
|
433
|
+
continue
|
|
434
|
+
if inside:
|
|
435
|
+
body.append(raw)
|
|
436
|
+
return body
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _normalize_placeholder_line(line: str) -> str:
|
|
440
|
+
"""Strip list and code-comment markers before template-line comparison."""
|
|
441
|
+
body = line[2:].strip() if line.startswith("- ") else line
|
|
442
|
+
body = body.lstrip("#> ").strip()
|
|
443
|
+
return re.sub(r"\s+", " ", body)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _has_concrete_text(text: str) -> bool:
|
|
447
|
+
"""Whether a line carries content beyond angle-bracket placeholders."""
|
|
448
|
+
cleaned = ANGLE_SPAN_RE.sub("", text)
|
|
449
|
+
return any(char.isalnum() for char in cleaned)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _task_blocks(text: str) -> list[dict]:
|
|
453
|
+
blocks: list[dict] = []
|
|
454
|
+
current: dict | None = None
|
|
455
|
+
fence: str | None = None
|
|
456
|
+
for raw in text.splitlines():
|
|
457
|
+
stripped = raw.strip()
|
|
458
|
+
marker = FENCE_RE.match(stripped)
|
|
459
|
+
if marker:
|
|
460
|
+
if fence is None:
|
|
461
|
+
fence = marker.group(1)
|
|
462
|
+
elif marker.group(1) == fence:
|
|
463
|
+
fence = None
|
|
464
|
+
if current is not None:
|
|
465
|
+
current["lines"].append(raw)
|
|
466
|
+
continue
|
|
467
|
+
if fence is None:
|
|
468
|
+
task = TASK_HEADING_RE.match(stripped)
|
|
469
|
+
if task:
|
|
470
|
+
if current is not None:
|
|
471
|
+
blocks.append(current)
|
|
472
|
+
current = {"heading": task.group(1), "lines": []}
|
|
473
|
+
continue
|
|
474
|
+
if H2_RE.match(stripped):
|
|
475
|
+
if current is not None:
|
|
476
|
+
blocks.append(current)
|
|
477
|
+
current = None
|
|
478
|
+
continue
|
|
479
|
+
if current is not None:
|
|
480
|
+
current["lines"].append(raw)
|
|
481
|
+
if current is not None:
|
|
482
|
+
blocks.append(current)
|
|
483
|
+
return blocks
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _task_fields(lines: list[str]) -> dict[str, list[str]]:
|
|
487
|
+
fields: dict[str, list[str]] = {}
|
|
488
|
+
current: str | None = None
|
|
489
|
+
for raw in lines:
|
|
490
|
+
match = BOLD_LABEL_RE.match(raw.strip())
|
|
491
|
+
if match:
|
|
492
|
+
current = match.group(1).strip()
|
|
493
|
+
fields.setdefault(current, [])
|
|
494
|
+
continue
|
|
495
|
+
if current is not None:
|
|
496
|
+
fields[current].append(raw)
|
|
497
|
+
return fields
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _anchor_blockers(anchor_text: str, language: str) -> list[str]:
|
|
501
|
+
present = present_sections(anchor_text)
|
|
502
|
+
blockers = [
|
|
503
|
+
"decision-anchor.md missing required section: "
|
|
504
|
+
+ " (or ".join(f"## {heading}" for heading in section_headings(section))
|
|
505
|
+
for section in REQUIRED_SECTIONS
|
|
506
|
+
if section not in present
|
|
507
|
+
]
|
|
508
|
+
expected = heading_for(VERIFICATION, language)
|
|
509
|
+
if expected not in headings(anchor_text):
|
|
510
|
+
other = heading_for(
|
|
511
|
+
VERIFICATION,
|
|
512
|
+
"en-US" if language == "zh-CN" else "zh-CN",
|
|
513
|
+
)
|
|
514
|
+
if other in headings(anchor_text):
|
|
515
|
+
blockers.append(
|
|
516
|
+
f"decision-anchor.md uses `## {other}`; a {language} task "
|
|
517
|
+
f"requires `## {expected}`"
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
blockers.append(
|
|
521
|
+
f"decision-anchor.md is missing `## {expected}`"
|
|
522
|
+
)
|
|
523
|
+
return blockers
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _anchor_spec_check_blockers(
|
|
527
|
+
anchor_text: str,
|
|
528
|
+
language: str,
|
|
529
|
+
dev_type: str | None,
|
|
530
|
+
) -> list[str]:
|
|
531
|
+
expected = heading_for(VERIFICATION, language)
|
|
532
|
+
if expected not in headings(anchor_text):
|
|
533
|
+
return []
|
|
534
|
+
if not any(
|
|
535
|
+
is_spec_check_result_line(line, language)
|
|
536
|
+
for line in verification_section_lines(anchor_text)
|
|
537
|
+
):
|
|
538
|
+
return [
|
|
539
|
+
f"decision-anchor.md `## {expected}` is missing a filled "
|
|
540
|
+
f"spec-check result, e.g. "
|
|
541
|
+
f"{spec_check_example(language, dev_type)}"
|
|
542
|
+
]
|
|
543
|
+
return []
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _acceptance_sync_blockers(plan_rows: list[dict[str, str]], anchor_text: str) -> list[str]:
|
|
547
|
+
from services.fact_view import parse_decision_anchor
|
|
548
|
+
|
|
549
|
+
anchor_rows = parse_decision_anchor(anchor_text).get("acceptanceCriteria") or []
|
|
550
|
+
plan_ids = [row["id"] for row in plan_rows]
|
|
551
|
+
anchor_ids = [row.get("id") for row in anchor_rows]
|
|
552
|
+
if plan_ids != anchor_ids:
|
|
553
|
+
return [
|
|
554
|
+
"decision-anchor.md acceptance IDs do not match the plan: "
|
|
555
|
+
f"plan={plan_ids or 'none'} anchor={anchor_ids or 'none'}"
|
|
556
|
+
]
|
|
557
|
+
for plan_row, anchor_row in zip(plan_rows, anchor_rows):
|
|
558
|
+
if plan_row["text"].strip() != str(anchor_row.get("text") or "").strip():
|
|
559
|
+
return [
|
|
560
|
+
f"decision-anchor.md {plan_row['id']} text differs from the plan; "
|
|
561
|
+
"update the anchor before finalizing"
|
|
562
|
+
]
|
|
563
|
+
return []
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _goal_sync_blockers(plan_goal_text: str | None, anchor_text: str) -> list[str]:
|
|
567
|
+
if not plan_goal_text:
|
|
568
|
+
return []
|
|
569
|
+
from services.fact_view import parse_decision_anchor
|
|
570
|
+
|
|
571
|
+
anchor_goal = str(parse_decision_anchor(anchor_text).get("goal") or "").strip()
|
|
572
|
+
if not anchor_goal:
|
|
573
|
+
return ["decision-anchor.md has no goal text"]
|
|
574
|
+
normalized_plan = " ".join(plan_goal_text.split())
|
|
575
|
+
normalized_anchor = " ".join(anchor_goal.split())
|
|
576
|
+
if normalized_plan != normalized_anchor and not normalized_plan.startswith(
|
|
577
|
+
normalized_anchor
|
|
578
|
+
):
|
|
579
|
+
return [
|
|
580
|
+
"decision-anchor.md goal differs from the plan goal; "
|
|
581
|
+
"align the anchor before finalizing"
|
|
582
|
+
]
|
|
583
|
+
return []
|