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,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
+ )
@@ -52,6 +52,15 @@ H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
52
52
  H2_RE = re.compile(r"^##(?!#)\s+(.+?)\s*$")
53
53
  H3_RE = re.compile(r"^###\s+(.+?)\s*$")
54
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
+
55
64
 
56
65
  def _language(language: str) -> str:
57
66
  return language if language in PLAN_SECTION_HEADINGS else DEFAULT_LANGUAGE
@@ -106,6 +115,37 @@ def _task_prefix_blocker(heading: str, language: str, prefix: str) -> str | None
106
115
  )
107
116
 
108
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
+
109
149
  def plan_template_blockers(
110
150
  text: str,
111
151
  language: str,
@@ -49,6 +49,7 @@ from services.plan_binding import (
49
49
  plan_file_blockers,
50
50
  plan_template_blockers,
51
51
  )
52
+ from services.plan_readiness import plan_ready_snapshot_blockers
52
53
  from services.task_gate_profiles import (
53
54
  GATE_PROFILE_STRICT,
54
55
  acceptance_row_blockers,
@@ -376,6 +377,17 @@ class TaskContextService:
376
377
  blockers.extend(
377
378
  plan_template_blockers(self.repo_root, task_data, language)
378
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
+ )
379
391
  if strict:
380
392
  blockers.extend(dev_type_blocker(task_data))
381
393
  return tuple(blockers)
@@ -6,7 +6,6 @@ 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 (
@@ -16,6 +15,7 @@ from services.decision_anchor_schema import (
16
15
  heading_for,
17
16
  spec_check_placeholder,
18
17
  )
18
+ from services.plan_schema import plan_acceptance_criteria, plan_goal
19
19
  from services.task_tree import TaskTreeError, TaskTreeService
20
20
  from services import task_graph
21
21
  from infra.paths import (
@@ -24,16 +24,10 @@ from infra.paths import (
24
24
  get_tasks_dir,
25
25
  )
26
26
  from services.plan_binding import PlanBindingError, bind_plan_file
27
- from services.task_gate_profiles import GATE_PROFILE_STRICT
27
+ from services.task_gate_profiles import GATE_PROFILE_STRICT, LIGHT_TASK_TYPE
28
28
  from services.task_repository import TaskRepository, TaskRepositoryError
29
29
 
30
30
 
31
- _GOAL_LINE_RE = re.compile(r"^\*\*(?:Goal|目标):\*\*\s*(.+?)\s*$")
32
- _GOAL_BOLD_COLON_RE = re.compile(r"^\*\*(?:Goal|目标)\*\*:\s*(.+?)\s*$")
33
- _GOAL_TABLE_RE = re.compile(
34
- r"^\|\s*\*\*(?:Goal|目标)\*\*\s*\|\s*(.+?)\s*\|\s*$"
35
- )
36
-
37
31
  _ANCHOR_PLACEHOLDERS = {
38
32
  ("zh-CN", GOAL): "<!-- 用一句话描述可观察的目标。 -->",
39
33
  ("zh-CN", ACCEPTANCE): "<!-- 至少一条,例如:- [ ] AC-001: ... -->",
@@ -92,17 +86,6 @@ class TaskCreationResult:
92
86
  generated_anchor_from_plan: bool
93
87
 
94
88
 
95
- def _extract_plan_goal(plan_text: str) -> str | None:
96
- """First machine-readable goal line, then the legacy table row."""
97
- for raw in plan_text.splitlines():
98
- line = raw.strip()
99
- for pattern in (_GOAL_LINE_RE, _GOAL_BOLD_COLON_RE, _GOAL_TABLE_RE):
100
- match = pattern.match(line)
101
- if match:
102
- return match.group(1).strip()
103
- return None
104
-
105
-
106
89
  def _anchor_placeholder(section: str, language: str) -> str:
107
90
  return _ANCHOR_PLACEHOLDERS.get(
108
91
  (language, section),
@@ -110,6 +93,12 @@ def _anchor_placeholder(section: str, language: str) -> str:
110
93
  )
111
94
 
112
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
+
113
102
  def ensure_tasks_dir(repo_root: Path) -> Path:
114
103
  """Ensure active and archived task directories exist."""
115
104
  tasks_dir = get_tasks_dir(repo_root)
@@ -192,6 +181,10 @@ class TaskCreationService:
192
181
  created_at = request.created_at or datetime.now().strftime("%Y-%m-%d")
193
182
  meta = dict(plan_metadata) if isinstance(plan_metadata, dict) else {}
194
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
195
188
  # The light profile is declarable at creation only: this is the sole
196
189
  # write of the field, and no lifecycle command changes it later.
197
190
  if request.task_type:
@@ -368,15 +361,17 @@ class TaskCreationService:
368
361
  ) -> tuple[bool, bool]:
369
362
  """Create a language-specific anchor scaffold.
370
363
 
371
- A bound plan's goal line (or legacy table row) fills the goal section;
372
- otherwise the scaffold carries a localized placeholder. An anchor that
364
+ A bound plan's goal and AC rows fill the corresponding sections;
365
+ otherwise each section carries a localized placeholder. An anchor that
373
366
  already exists is never overwritten.
374
367
  """
375
368
  anchor_file = task_dir / "decision-anchor.md"
376
369
  if anchor_file.exists():
377
370
  return False, False
378
371
 
372
+ language = get_language(self.repo_root)
379
373
  goal: str | None = None
374
+ acceptance_rows: list[dict[str, str]] = []
380
375
  if plan_path is not None and plan_path.is_file():
381
376
  try:
382
377
  plan_text = plan_path.read_text(encoding="utf-8")
@@ -386,14 +381,18 @@ class TaskCreationService:
386
381
  plan_path,
387
382
  "plan cannot be read as UTF-8",
388
383
  ) from error
389
- goal = _extract_plan_goal(plan_text)
384
+ goal = plan_goal(plan_text)
385
+ acceptance_rows = plan_acceptance_criteria(plan_text, language)
390
386
 
391
- language = get_language(self.repo_root)
392
387
  goal_heading = heading_for(GOAL, language)
393
388
  acceptance_heading = heading_for(ACCEPTANCE, language)
394
389
  verification_heading = heading_for(VERIFICATION, language)
395
390
  goal_body = goal or _anchor_placeholder(GOAL, language)
396
- 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
+ )
397
396
  verification_comment = (
398
397
  "<!-- strict 任务启动前:把 <dev_type> 和 "
399
398
  "<pass / not_applicable + 理由> 替换为实际核对结果;"
@@ -28,6 +28,60 @@ the plan into a second workflow kernel.
28
28
  }
29
29
  ```
30
30
 
31
+ - A newly created strict non-Tiny task also records
32
+ `meta.planReadyRequired: true`. Existing tasks without that marker are not
33
+ retroactively required to have a snapshot; if they are finalized, the
34
+ snapshot is recorded and validated, but a missing snapshot is not a new
35
+ blocker for them.
36
+
37
+ ## Skeleton generation
38
+
39
+ - `./.cowork-flow/run plan new --title <title> --slug <slug>` reads the
40
+ installed localized `.cowork-flow/templates/plan-template.md` and writes a
41
+ plan skeleton to `.cowork-flow/plans/YYYY-MM-DD-<slug>.md`.
42
+ - The title placeholder (`<功能>` / `<Feature>`) is replaced; the rest of the
43
+ template shape is preserved. The file is written as UTF-8 with LF endings.
44
+ - An existing plan is never overwritten unless `--force` is explicit.
45
+ - Generation is optional; a planning agent may still fill the installed
46
+ template directly. `plan finalize` remains the completeness gate.
47
+
48
+ ## Plan finalize
49
+
50
+ - `./.cowork-flow/run plan finalize [task-dir]` is allowed only on a planning
51
+ task. It does not edit the plan or anchor.
52
+ - It requires the bound plan, the configured-language template shape, a
53
+ machine-readable concrete goal line, at least one concrete `AC-NNN` row,
54
+ content in `## 目标` / `## 全局约束` / `## 文件边界` beyond the installed
55
+ template body, complete task briefs (full fields for Normal/High-risk; file
56
+ boundaries, verification command, and completion conditions for Tiny), a
57
+ concrete command inside the task's fenced verification block, a non-empty
58
+ `implement.jsonl`, anchor acceptance rows matching the plan IDs/text, an
59
+ anchor goal matching the plan goal, and the filled anchor spec-check result.
60
+ Template bodies are read from the installed localized template at finalize
61
+ time, so placeholder matching follows template changes instead of a copied
62
+ sentinel list.
63
+ - On success it writes `task.json.meta.planReady`:
64
+
65
+ ```json
66
+ {
67
+ "meta": {
68
+ "planReady": {
69
+ "planDigest": "<sha256>",
70
+ "anchorDigest": "<sha256>",
71
+ "readyAt": "2026-10-23T00:00:00Z",
72
+ "devType": "backend"
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ - A failed finalize writes nothing. A successful finalize is the handoff point
79
+ for human review: the reviewer sees the complete plan and anchor.
80
+ - A new strict non-Tiny task requires the snapshot at start. Start rejects a
81
+ missing snapshot and rejects any snapshot whose digests no longer match the
82
+ current plan or anchor; editing either file after review requires another
83
+ finalize and another review. Unmarked old tasks remain exempt.
84
+
31
85
  ## Binding during planning
32
86
 
33
87
  - A planning task not bound at creation time can bind a plan later with
@@ -66,10 +120,13 @@ into `in_progress`:
66
120
  - The strict non-Tiny anchor carries the configured-language `## 验证命令` /
67
121
  `## Verification Commands` section with the same filled spec-check result;
68
122
  see `decision-anchor.md`.
123
+ - The plan passes `plan finalize`; the anchor acceptance IDs/text match the
124
+ plan, and the stored plan-ready digests still match both files.
69
125
 
70
126
  Creation and `--from-plan` binding stay permissive: a planning task may create
71
- and bind a plan before its structure is final. These checks run only at the
72
- planning → `in_progress` start gate.
127
+ and bind a plan before its structure is final. The completeness checks run at
128
+ `plan finalize`; the start gate repeats the structural backstop and verifies the
129
+ reviewed snapshot.
73
130
 
74
131
  A Tiny task may have no planFile and is exempt from the plan-shape and anchor
75
132
  spec-check gates. A legacy task (no `meta.gateProfile: "strict"`) keeps the
@@ -97,11 +154,12 @@ The recovery output lists paths only and never expands the plan prose.
97
154
 
98
155
  - No parsing of plan step status.
99
156
  - No Markdown AST or DSL.
100
- - No comparing AC text against plan task prose.
157
+ - No semantic validation of plan prose beyond the completeness checks above.
101
158
  - No matching verification commands against evidence records.
102
159
  - No semantic validation of the spec-check result or reason; the structural
103
160
  check only requires an actual `pass` / `not_applicable` token.
104
- - No plan-approval data model.
161
+ - No approval record beyond the plan-ready digest snapshot; human approval is
162
+ still a process step, not a separate workflow state.
105
163
 
106
164
  These capabilities are considered only after warning noise and real payoff have been
107
165
  validated.
@@ -109,7 +109,8 @@
109
109
  "Plan binding records repo-relative task.json.meta.planFile.",
110
110
  "Task start blocks clear missing plan facts for non-Tiny tasks.",
111
111
  "Strict non-Tiny start requires the configured-language plan template shape and a filled spec-check line.",
112
- "V1 does not parse plan steps, AC text, or verification evidence."
112
+ "New strict non-Tiny tasks require a plan-ready snapshot from `plan finalize`; edits after finalize invalidate it.",
113
+ "V1 does not parse plan step status or verification evidence; AC text is only compared between plan and anchor."
113
114
  ],
114
115
  "readWhen": [
115
116
  "when changing task creation plan binding",
@@ -30,6 +30,28 @@ If the installed template is missing, stop and report it (run `cwf project sync
30
30
 
31
31
  The template is distributed at install time for the project language configured in `.cowork-flow/config.yaml` (`zh-CN` by default, or `en-US`). Write the plan in that language. Keep the template's machine-readable goal line and `AC-NNN` identifiers unchanged so task creation and workflow gates can parse them. Do not translate paths, commands, code identifiers, or spec file names.
32
32
 
33
+ ### Skeleton Generation
34
+
35
+ Generate the first version from the installed template instead of retyping its structure:
36
+
37
+ ```bash
38
+ ./.cowork-flow/run plan new --title "<plan title>" --slug <ascii-slug>
39
+ ```
40
+
41
+ `plan new` replaces the title placeholder, writes `.cowork-flow/plans/YYYY-MM-DD-<slug>.md`, and refuses to overwrite an existing plan unless `--force` is explicit. Fill the generated skeleton; do not invent a second plan shape. If the installed template is missing, the command stops with a `cwf project sync .` hint.
42
+
43
+ ### Plan Readiness Gate
44
+
45
+ After filling the plan and anchor, finalize them before human review:
46
+
47
+ ```bash
48
+ ./.cowork-flow/run plan finalize <task-dir>
49
+ ```
50
+
51
+ `plan finalize` checks the plan and anchor for template shape, concrete section content, AC rows, complete task briefs, verification commands, matching goal/AC text, the spec-check result, and a non-empty `implement.jsonl`. On success it writes `task.json.meta.planReady` with plan/anchor digests.
52
+
53
+ New strict non-Tiny tasks require this snapshot at start. Present the finalized plan and anchor for human review; start only verifies that the reviewed files have not changed. If either file changes after finalize, run `plan finalize` again and re-review before starting. Tiny and legacy tasks keep their existing exemptions.
54
+
33
55
  ### Plan Style: Task Briefs, Not Design Docs
34
56
 
35
57
  Plans exist to prevent implementation drift. They must guide implementation by pinning task order, file boundaries, key symbols, the core patch shape, test proof, completion conditions, and prohibited drift. They are not architecture essays and must not explain every possible option.
@@ -82,6 +104,7 @@ A plan can be marked approved only when all conditions hold:
82
104
  - Every task has implementation steps and verification commands.
83
105
  - Task dependencies are explicit, including whether work is serial or parallelizable.
84
106
  - The plan uses the installed `.cowork-flow/templates/plan-template.md` shape and the configured output language. For strict non-Tiny tasks, the runtime blocks start when the plan or anchor lacks the configured-language verification section or a filled spec-check result.
107
+ - For new strict non-Tiny tasks, the plan and anchor have passed `plan finalize` and the human has reviewed that snapshot. Any later edit requires another finalize and review.
85
108
 
86
109
  `implement.jsonl` is the current task's context index — the files in scope and
87
110
  the spec pointers it needs — not the task list. The plan owns the task list.
@@ -120,3 +143,4 @@ Before handoff:
120
143
  5. Confirm the plan says to stop and report back when code facts conflict with the plan, a plan-external file becomes necessary, or a test fails for an unplanned reason.
121
144
  6. Confirm Final Verification carries the applicable `.cowork-flow/spec/<dev_type>/` clause check with an actual `pass` / `not_applicable` result, or states why no spec file applies. For strict non-Tiny tasks the runtime blocks start when this line is missing or still the template placeholder.
122
145
  7. Confirm the plan is written in the configured language and keeps the machine-readable goal line and `AC-NNN` identifiers unchanged.
146
+ 8. Run `plan finalize <task-dir>` and fix every reported blocker before handing the plan to the user for review.