cowork-flow 2.4.1 → 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.
@@ -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 []