cowork-flow 2.3.0 → 2.4.1
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 +31 -1
- package/README.en.md +34 -18
- package/README.md +8 -5
- package/docs/architecture.en.md +95 -0
- package/docs/architecture.md +7 -3
- package/docs/direction.en.md +71 -0
- package/docs/direction.md +6 -1
- package/docs/hosts.en.md +299 -0
- package/docs/hosts.md +3 -1
- package/docs/index.en.md +30 -0
- package/docs/index.md +2 -0
- package/docs/release.en.md +108 -0
- package/docs/release.md +2 -0
- package/package.json +2 -2
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/claude-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +4 -0
- package/src/commands/init.js +129 -2
- package/src/commands/registry.js +3 -1
- package/src/commands/source-refresh.js +5 -1
- package/src/commands/sync.js +3 -0
- package/src/lib/copy-template.js +23 -5
- package/src/lib/host-assets.js +117 -2
- package/src/lib/language.js +162 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +8 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
- package/template/.cowork-flow/scripts/infra/config.py +85 -0
- package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +228 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +27 -15
- package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
- package/template/.cowork-flow/scripts/services/plan_schema.py +182 -0
- package/template/.cowork-flow/scripts/services/task_context.py +84 -12
- package/template/.cowork-flow/scripts/services/task_creation.py +89 -30
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
- package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +35 -19
- package/template/.cowork-flow/spec/contracts/plan-binding.md +27 -4
- package/template/.cowork-flow/spec/index.md +26 -26
- package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
- package/template/.cowork-flow/spec/references/index.md +16 -16
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
- package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
- package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
- package/template/.cowork-flow/spec/runtime/contract-registry.json +1 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
- package/template/.cowork-flow/spec/runtime/index.md +10 -12
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
- package/template/.cowork-flow/spec/schemas/index.md +5 -5
- package/template/.cowork-flow/templates/plan-template.md +91 -0
- package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +169 -0
- package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
- package/template/skills/runtime-health/scripts/doctor.py +110 -0
- package/template/skills/task-planning/SKILL.md +21 -94
|
@@ -17,23 +17,31 @@ from datetime import datetime, timezone
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
+
from services.decision_anchor_schema import (
|
|
21
|
+
ACCEPTANCE,
|
|
22
|
+
ACCEPTANCE_RE,
|
|
23
|
+
GOAL,
|
|
24
|
+
HEADING_RE,
|
|
25
|
+
REJECTED,
|
|
26
|
+
SCOPE,
|
|
27
|
+
VERIFICATION,
|
|
28
|
+
canonical_section,
|
|
29
|
+
is_spec_check_line,
|
|
30
|
+
)
|
|
31
|
+
|
|
20
32
|
GOAL_TRUNCATE = 300
|
|
21
33
|
VERIFY_COMMAND_TRUNCATE = 120
|
|
22
34
|
VERIFY_COMMAND_MAX = 10
|
|
23
35
|
SCOPE_BOUNDARY_TRUNCATE = 160
|
|
24
|
-
ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
|
|
25
|
-
ACCEPTANCE_RE = re.compile(
|
|
26
|
-
r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
|
|
27
|
-
)
|
|
28
36
|
REJECTED_RE = re.compile(r"^\s*-\s*\*\*(.+?)\*\*")
|
|
29
|
-
_HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
|
|
30
37
|
|
|
31
38
|
|
|
32
39
|
def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
33
40
|
"""Extract goal / acceptance criteria / rejected option names / declared
|
|
34
41
|
verification commands / scope boundary from a decision-anchor.md body.
|
|
35
|
-
Section
|
|
36
|
-
line-level and degrades to
|
|
42
|
+
Section headings may be the Chinese set or the English aliases (see
|
|
43
|
+
spec/contracts/decision-anchor.md); parsing is line-level and degrades to
|
|
44
|
+
empty values when sections are missing."""
|
|
37
45
|
goal_lines: list[str] = []
|
|
38
46
|
acceptance: list[dict[str, str]] = []
|
|
39
47
|
rejected: list[str] = []
|
|
@@ -41,15 +49,14 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
|
41
49
|
scope_boundary = ""
|
|
42
50
|
section: str | None = None
|
|
43
51
|
for raw in text.splitlines():
|
|
44
|
-
heading =
|
|
52
|
+
heading = HEADING_RE.match(raw.strip())
|
|
45
53
|
if heading:
|
|
46
|
-
|
|
47
|
-
section = name if name in ANCHOR_SECTIONS else None
|
|
54
|
+
section = canonical_section(heading.group(1))
|
|
48
55
|
continue
|
|
49
|
-
if section ==
|
|
56
|
+
if section == GOAL:
|
|
50
57
|
if raw.strip():
|
|
51
58
|
goal_lines.append(raw.strip())
|
|
52
|
-
elif section ==
|
|
59
|
+
elif section == ACCEPTANCE:
|
|
53
60
|
match = ACCEPTANCE_RE.match(raw)
|
|
54
61
|
if match:
|
|
55
62
|
acceptance.append(
|
|
@@ -59,17 +66,22 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
|
|
|
59
66
|
"checked": match.group(1) in ("x", "X"),
|
|
60
67
|
}
|
|
61
68
|
)
|
|
62
|
-
elif section ==
|
|
69
|
+
elif section == REJECTED:
|
|
63
70
|
match = REJECTED_RE.match(raw)
|
|
64
71
|
if match:
|
|
65
72
|
rejected.append(match.group(1).strip())
|
|
66
|
-
elif section ==
|
|
73
|
+
elif section == VERIFICATION:
|
|
67
74
|
command = raw.strip()
|
|
68
75
|
if command.startswith("- "):
|
|
69
76
|
command = command[2:].strip()
|
|
77
|
+
if command.startswith("<!--"):
|
|
78
|
+
# Scaffold instructions and author notes are not commands.
|
|
79
|
+
continue
|
|
80
|
+
if is_spec_check_line(command):
|
|
81
|
+
continue
|
|
70
82
|
if command and len(verify_commands) < VERIFY_COMMAND_MAX:
|
|
71
83
|
verify_commands.append(command[:VERIFY_COMMAND_TRUNCATE])
|
|
72
|
-
elif section ==
|
|
84
|
+
elif section == SCOPE:
|
|
73
85
|
if raw.strip() and not scope_boundary:
|
|
74
86
|
scope_boundary = raw.strip()[:SCOPE_BOUNDARY_TRUNCATE]
|
|
75
87
|
return {
|
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from infra.files import read_text_utf8
|
|
11
11
|
from infra.paths import DIR_WORKFLOW
|
|
12
|
+
from services.plan_schema import plan_template_blockers as plan_text_blockers
|
|
12
13
|
from services.task_repository import TaskRepository
|
|
13
14
|
|
|
14
15
|
|
|
@@ -165,3 +166,36 @@ def plan_file_blockers(repo_root: Path, task_data: dict) -> list[str]:
|
|
|
165
166
|
return [f"planFile is empty: {error.normalized}"]
|
|
166
167
|
return [error.detail]
|
|
167
168
|
return []
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def plan_template_blockers(
|
|
172
|
+
repo_root: Path,
|
|
173
|
+
task_data: dict,
|
|
174
|
+
language: str,
|
|
175
|
+
) -> list[str]:
|
|
176
|
+
"""Structure blockers for an already-bound, non-empty plan."""
|
|
177
|
+
meta = task_data.get("meta")
|
|
178
|
+
plan_file = meta.get("planFile") if isinstance(meta, dict) else None
|
|
179
|
+
if not isinstance(plan_file, str) or not plan_file.strip():
|
|
180
|
+
return []
|
|
181
|
+
try:
|
|
182
|
+
bound = bind_plan_file(
|
|
183
|
+
repo_root,
|
|
184
|
+
plan_file,
|
|
185
|
+
allow_absolute=False,
|
|
186
|
+
require_exists=True,
|
|
187
|
+
require_non_empty=True,
|
|
188
|
+
)
|
|
189
|
+
except PlanBindingError:
|
|
190
|
+
# plan_file_blockers already reported the binding fact.
|
|
191
|
+
return []
|
|
192
|
+
text = read_text_utf8(bound.path)
|
|
193
|
+
if not text.strip():
|
|
194
|
+
return []
|
|
195
|
+
dev_type = task_data.get("dev_type")
|
|
196
|
+
if not isinstance(dev_type, str) or not dev_type.strip():
|
|
197
|
+
dev_type = None
|
|
198
|
+
return [
|
|
199
|
+
f"plan {bound.normalized}: {message}"
|
|
200
|
+
for message in plan_text_blockers(text, language, dev_type=dev_type)
|
|
201
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
|
|
56
|
+
def _language(language: str) -> str:
|
|
57
|
+
return language if language in PLAN_SECTION_HEADINGS else DEFAULT_LANGUAGE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _headings(lines: list[str]) -> tuple[list[str], list[str], list[str]]:
|
|
61
|
+
h1: list[str] = []
|
|
62
|
+
h2: list[str] = []
|
|
63
|
+
h3: list[str] = []
|
|
64
|
+
for raw in lines:
|
|
65
|
+
line = raw.strip()
|
|
66
|
+
match = H3_RE.match(line)
|
|
67
|
+
if match:
|
|
68
|
+
h3.append(match.group(1))
|
|
69
|
+
continue
|
|
70
|
+
match = H2_RE.match(line)
|
|
71
|
+
if match:
|
|
72
|
+
h2.append(match.group(1))
|
|
73
|
+
continue
|
|
74
|
+
match = H1_RE.match(line)
|
|
75
|
+
if match:
|
|
76
|
+
h1.append(match.group(1))
|
|
77
|
+
return h1, h2, h3
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _section_lines(lines: list[str], heading: str) -> list[str]:
|
|
81
|
+
body: list[str] = []
|
|
82
|
+
inside = False
|
|
83
|
+
for raw in lines:
|
|
84
|
+
line = raw.strip()
|
|
85
|
+
match = H2_RE.match(line)
|
|
86
|
+
if match:
|
|
87
|
+
inside = match.group(1) == heading
|
|
88
|
+
continue
|
|
89
|
+
if inside:
|
|
90
|
+
body.append(raw)
|
|
91
|
+
return body
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _task_prefix_blocker(heading: str, language: str, prefix: str) -> str | None:
|
|
95
|
+
lowered = heading.lower()
|
|
96
|
+
if not (heading.startswith("任务") or lowered.startswith("task")):
|
|
97
|
+
return None
|
|
98
|
+
if heading.startswith(prefix):
|
|
99
|
+
return None
|
|
100
|
+
return (
|
|
101
|
+
f"plan task heading '### {heading}' must use the {language} prefix "
|
|
102
|
+
f"'### {prefix} N:'"
|
|
103
|
+
if language == "zh-CN"
|
|
104
|
+
else f"plan task heading '### {heading}' must use the {language} prefix "
|
|
105
|
+
f"'### {prefix} N:'"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def plan_template_blockers(
|
|
110
|
+
text: str,
|
|
111
|
+
language: str,
|
|
112
|
+
*,
|
|
113
|
+
dev_type: str | None = None,
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
"""Why a plan does not match the configured-language template shape."""
|
|
116
|
+
if not text.strip():
|
|
117
|
+
return ["plan is empty"]
|
|
118
|
+
resolved = _language(language)
|
|
119
|
+
expected = PLAN_SECTION_HEADINGS[resolved]
|
|
120
|
+
other = "en-US" if resolved == "zh-CN" else "zh-CN"
|
|
121
|
+
other_headings = set(PLAN_SECTION_HEADINGS[other])
|
|
122
|
+
lines = structural_lines(text)
|
|
123
|
+
h1, h2, h3 = _headings(lines)
|
|
124
|
+
blockers: list[str] = []
|
|
125
|
+
|
|
126
|
+
marker = PLAN_TITLE_MARKERS[resolved]
|
|
127
|
+
if not h1:
|
|
128
|
+
blockers.append(
|
|
129
|
+
f"plan is missing a top-level title; expected: # <功能> {marker}"
|
|
130
|
+
)
|
|
131
|
+
elif marker not in h1[0]:
|
|
132
|
+
blockers.append(
|
|
133
|
+
f"plan title must contain '{marker}' for {resolved}; "
|
|
134
|
+
f"expected: # <功能> {marker}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
wrong_language = [heading for heading in h2 if heading in other_headings]
|
|
138
|
+
if wrong_language:
|
|
139
|
+
blockers.append(
|
|
140
|
+
f"plan uses {other} plan headings in a {resolved} project: "
|
|
141
|
+
+ ", ".join(f"## {heading}" for heading in wrong_language)
|
|
142
|
+
+ "; expected: "
|
|
143
|
+
+ ", ".join(f"## {heading}" for heading in expected)
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
missing = [heading for heading in expected if heading not in h2]
|
|
147
|
+
if missing:
|
|
148
|
+
blockers.append(
|
|
149
|
+
f"plan is missing required {resolved} template heading(s): "
|
|
150
|
+
+ ", ".join(f"## {heading}" for heading in missing)
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
positions = [h2.index(heading) for heading in expected]
|
|
154
|
+
if positions != sorted(positions):
|
|
155
|
+
blockers.append(
|
|
156
|
+
f"plan template headings are out of order; expected: "
|
|
157
|
+
+ " -> ".join(f"## {heading}" for heading in expected)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
prefix = PLAN_TASK_PREFIXES[resolved]
|
|
161
|
+
for heading in h3:
|
|
162
|
+
blocker = _task_prefix_blocker(heading, resolved, prefix)
|
|
163
|
+
if blocker is not None:
|
|
164
|
+
blockers.append(blocker)
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
final_heading = expected[-1]
|
|
168
|
+
if final_heading in h2:
|
|
169
|
+
body = _section_lines(lines, final_heading)
|
|
170
|
+
if not any(
|
|
171
|
+
spec_check_label(resolved) in line
|
|
172
|
+
and is_spec_check_result_line(line, resolved)
|
|
173
|
+
for line in body
|
|
174
|
+
):
|
|
175
|
+
blockers.append(
|
|
176
|
+
f"plan `## {final_heading}` is missing a filled spec-check "
|
|
177
|
+
f"result; expected a line starting with '{spec_check_label(resolved)}' "
|
|
178
|
+
f"and containing 'pass' or 'not_applicable', e.g. "
|
|
179
|
+
f"{spec_check_example(resolved, dev_type)}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
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 (
|
|
@@ -32,7 +33,22 @@ from services.context_paths import (
|
|
|
32
33
|
normalize_context_path,
|
|
33
34
|
prepare_context_entry,
|
|
34
35
|
)
|
|
35
|
-
from services.
|
|
36
|
+
from services.decision_anchor_schema import (
|
|
37
|
+
REQUIRED_SECTIONS,
|
|
38
|
+
VERIFICATION,
|
|
39
|
+
heading_for,
|
|
40
|
+
headings,
|
|
41
|
+
is_spec_check_result_line,
|
|
42
|
+
present_sections,
|
|
43
|
+
section_headings,
|
|
44
|
+
spec_check_example,
|
|
45
|
+
verification_section_lines,
|
|
46
|
+
)
|
|
47
|
+
from services.plan_binding import (
|
|
48
|
+
normalize_plan_file,
|
|
49
|
+
plan_file_blockers,
|
|
50
|
+
plan_template_blockers,
|
|
51
|
+
)
|
|
36
52
|
from services.task_gate_profiles import (
|
|
37
53
|
GATE_PROFILE_STRICT,
|
|
38
54
|
acceptance_row_blockers,
|
|
@@ -330,6 +346,9 @@ class TaskContextService:
|
|
|
330
346
|
task_dir = Path(task_dir)
|
|
331
347
|
blockers: list[str] = []
|
|
332
348
|
task_data = self._task_data(task_dir)
|
|
349
|
+
strict = resolve_gate_profile(task_data) == GATE_PROFILE_STRICT
|
|
350
|
+
light = self._is_tiny_task(task_data)
|
|
351
|
+
language = get_language(self.repo_root)
|
|
333
352
|
if not (task_dir / FILE_TASK_JSON).is_file():
|
|
334
353
|
blockers.append("task.json is missing")
|
|
335
354
|
anchor_text = read_text_utf8(task_dir / "decision-anchor.md")
|
|
@@ -337,14 +356,27 @@ class TaskContextService:
|
|
|
337
356
|
blockers.append("decision-anchor.md is missing or empty")
|
|
338
357
|
else:
|
|
339
358
|
blockers.extend(self._decision_anchor_section_blockers(anchor_text))
|
|
340
|
-
if
|
|
359
|
+
if strict:
|
|
341
360
|
blockers.extend(acceptance_row_blockers(anchor_text))
|
|
361
|
+
if not light:
|
|
362
|
+
blockers.extend(
|
|
363
|
+
self._anchor_spec_check_blockers(
|
|
364
|
+
anchor_text,
|
|
365
|
+
language,
|
|
366
|
+
task_data.get("dev_type"),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
342
369
|
for context_file in ("implement.jsonl",):
|
|
343
370
|
if not read_text_utf8(task_dir / context_file):
|
|
344
371
|
blockers.append(f"{context_file} is missing or empty")
|
|
345
|
-
if (task_dir / FILE_TASK_JSON).is_file() and not
|
|
346
|
-
|
|
347
|
-
|
|
372
|
+
if (task_dir / FILE_TASK_JSON).is_file() and not light:
|
|
373
|
+
plan_blockers = self._plan_file_blockers(task_data)
|
|
374
|
+
blockers.extend(plan_blockers)
|
|
375
|
+
if strict and not plan_blockers:
|
|
376
|
+
blockers.extend(
|
|
377
|
+
plan_template_blockers(self.repo_root, task_data, language)
|
|
378
|
+
)
|
|
379
|
+
if strict:
|
|
348
380
|
blockers.extend(dev_type_blocker(task_data))
|
|
349
381
|
return tuple(blockers)
|
|
350
382
|
|
|
@@ -360,13 +392,53 @@ class TaskContextService:
|
|
|
360
392
|
|
|
361
393
|
@staticmethod
|
|
362
394
|
def _decision_anchor_section_blockers(anchor_text: str) -> list[str]:
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
395
|
+
present = present_sections(anchor_text)
|
|
396
|
+
return [
|
|
397
|
+
"decision-anchor.md missing required section: "
|
|
398
|
+
+ " (or ".join(f"## {heading}" for heading in section_headings(section))
|
|
399
|
+
+ ")"
|
|
400
|
+
for section in REQUIRED_SECTIONS
|
|
401
|
+
if section not in present
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
@staticmethod
|
|
405
|
+
def _anchor_spec_check_blockers(
|
|
406
|
+
anchor_text: str,
|
|
407
|
+
language: str,
|
|
408
|
+
dev_type: str | None,
|
|
409
|
+
) -> list[str]:
|
|
410
|
+
expected = heading_for(VERIFICATION, language)
|
|
411
|
+
actual = headings(anchor_text)
|
|
412
|
+
normalized_dev_type = (
|
|
413
|
+
dev_type.strip()
|
|
414
|
+
if isinstance(dev_type, str) and dev_type.strip()
|
|
415
|
+
else None
|
|
416
|
+
)
|
|
417
|
+
example = spec_check_example(language, normalized_dev_type)
|
|
418
|
+
if expected not in actual:
|
|
419
|
+
other = heading_for(
|
|
420
|
+
VERIFICATION,
|
|
421
|
+
"en-US" if language == "zh-CN" else "zh-CN",
|
|
422
|
+
)
|
|
423
|
+
if other in actual:
|
|
424
|
+
return [
|
|
425
|
+
f"decision-anchor.md uses `## {other}`; a strict {language} "
|
|
426
|
+
f"task requires `## {expected}` with a filled spec-check "
|
|
427
|
+
f"result, e.g. {example}"
|
|
428
|
+
]
|
|
429
|
+
return [
|
|
430
|
+
f"decision-anchor.md is missing `## {expected}` with a filled "
|
|
431
|
+
f"spec-check result, e.g. {example}"
|
|
432
|
+
]
|
|
433
|
+
if not any(
|
|
434
|
+
is_spec_check_result_line(line, language)
|
|
435
|
+
for line in verification_section_lines(anchor_text)
|
|
436
|
+
):
|
|
437
|
+
return [
|
|
438
|
+
f"decision-anchor.md `## {expected}` is missing a filled "
|
|
439
|
+
f"spec-check result, e.g. {example}"
|
|
440
|
+
]
|
|
441
|
+
return []
|
|
370
442
|
|
|
371
443
|
@staticmethod
|
|
372
444
|
def _is_tiny_task(task_data: dict) -> bool:
|
|
@@ -6,7 +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
|
-
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from infra.config import get_language
|
|
12
|
+
from services.decision_anchor_schema import (
|
|
13
|
+
ACCEPTANCE,
|
|
14
|
+
GOAL,
|
|
15
|
+
VERIFICATION,
|
|
16
|
+
heading_for,
|
|
17
|
+
spec_check_placeholder,
|
|
18
|
+
)
|
|
10
19
|
from services.task_tree import TaskTreeError, TaskTreeService
|
|
11
20
|
from services import task_graph
|
|
12
21
|
from infra.paths import (
|
|
@@ -19,6 +28,20 @@ from services.task_gate_profiles import GATE_PROFILE_STRICT
|
|
|
19
28
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
20
29
|
|
|
21
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
|
+
_ANCHOR_PLACEHOLDERS = {
|
|
38
|
+
("zh-CN", GOAL): "<!-- 用一句话描述可观察的目标。 -->",
|
|
39
|
+
("zh-CN", ACCEPTANCE): "<!-- 至少一条,例如:- [ ] AC-001: ... -->",
|
|
40
|
+
("en-US", GOAL): "<!-- One sentence describing the observable outcome. -->",
|
|
41
|
+
("en-US", ACCEPTANCE): "<!-- At least one, e.g. - [ ] AC-001: ... -->",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
22
45
|
class TaskCreationError(RuntimeError):
|
|
23
46
|
"""Raised when task creation cannot be completed."""
|
|
24
47
|
|
|
@@ -66,6 +89,25 @@ class TaskCreationResult:
|
|
|
66
89
|
linked_parent: str | None
|
|
67
90
|
missing_parent: str | None
|
|
68
91
|
generated_anchor: bool
|
|
92
|
+
generated_anchor_from_plan: bool
|
|
93
|
+
|
|
94
|
+
|
|
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
|
+
def _anchor_placeholder(section: str, language: str) -> str:
|
|
107
|
+
return _ANCHOR_PLACEHOLDERS.get(
|
|
108
|
+
(language, section),
|
|
109
|
+
_ANCHOR_PLACEHOLDERS[("zh-CN", section)],
|
|
110
|
+
)
|
|
69
111
|
|
|
70
112
|
|
|
71
113
|
def ensure_tasks_dir(repo_root: Path) -> Path:
|
|
@@ -122,9 +164,10 @@ class TaskCreationService:
|
|
|
122
164
|
created_files.append(task_dir / FILE_TASK_JSON)
|
|
123
165
|
|
|
124
166
|
linked_parent, missing_parent = self._link_parent(request, task_dir)
|
|
125
|
-
generated_anchor = self._generate_anchor(
|
|
167
|
+
generated_anchor, generated_anchor_from_plan = self._generate_anchor(
|
|
126
168
|
task_dir,
|
|
127
169
|
bound_plan_path,
|
|
170
|
+
request.dev_type,
|
|
128
171
|
)
|
|
129
172
|
if generated_anchor:
|
|
130
173
|
created_files.append(task_dir / "decision-anchor.md")
|
|
@@ -136,6 +179,7 @@ class TaskCreationService:
|
|
|
136
179
|
linked_parent=linked_parent,
|
|
137
180
|
missing_parent=missing_parent,
|
|
138
181
|
generated_anchor=generated_anchor,
|
|
182
|
+
generated_anchor_from_plan=generated_anchor_from_plan,
|
|
139
183
|
)
|
|
140
184
|
|
|
141
185
|
def _task_data(
|
|
@@ -316,41 +360,56 @@ class TaskCreationService:
|
|
|
316
360
|
"plan file must live under .cowork-flow/plans",
|
|
317
361
|
)
|
|
318
362
|
|
|
319
|
-
@staticmethod
|
|
320
363
|
def _generate_anchor(
|
|
364
|
+
self,
|
|
321
365
|
task_dir: Path,
|
|
322
366
|
plan_path: Path | None,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if not plan_path.is_file():
|
|
327
|
-
return False
|
|
367
|
+
dev_type: str | None = None,
|
|
368
|
+
) -> tuple[bool, bool]:
|
|
369
|
+
"""Create a language-specific anchor scaffold.
|
|
328
370
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
) from error
|
|
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
|
|
373
|
+
already exists is never overwritten.
|
|
374
|
+
"""
|
|
375
|
+
anchor_file = task_dir / "decision-anchor.md"
|
|
376
|
+
if anchor_file.exists():
|
|
377
|
+
return False, False
|
|
337
378
|
|
|
338
|
-
goal =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
379
|
+
goal: str | None = None
|
|
380
|
+
if plan_path is not None and plan_path.is_file():
|
|
381
|
+
try:
|
|
382
|
+
plan_text = plan_path.read_text(encoding="utf-8")
|
|
383
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
384
|
+
raise TaskCreationError(
|
|
385
|
+
"TASK-CREATE-PLAN-001",
|
|
386
|
+
plan_path,
|
|
387
|
+
"plan cannot be read as UTF-8",
|
|
388
|
+
) from error
|
|
389
|
+
goal = _extract_plan_goal(plan_text)
|
|
390
|
+
|
|
391
|
+
language = get_language(self.repo_root)
|
|
392
|
+
goal_heading = heading_for(GOAL, language)
|
|
393
|
+
acceptance_heading = heading_for(ACCEPTANCE, language)
|
|
394
|
+
verification_heading = heading_for(VERIFICATION, language)
|
|
395
|
+
goal_body = goal or _anchor_placeholder(GOAL, language)
|
|
396
|
+
acceptance_body = _anchor_placeholder(ACCEPTANCE, language)
|
|
397
|
+
verification_comment = (
|
|
398
|
+
"<!-- strict 任务启动前:把 <dev_type> 和 "
|
|
399
|
+
"<pass / not_applicable + 理由> 替换为实际核对结果;"
|
|
400
|
+
"保留模板占位符会被启动门禁阻断。 -->"
|
|
401
|
+
if language == "zh-CN"
|
|
402
|
+
else "<!-- Before a strict task starts: replace <dev_type> and "
|
|
403
|
+
"<pass / not_applicable + reason> with the actual result; "
|
|
404
|
+
"the start gate rejects the template placeholder. -->"
|
|
346
405
|
)
|
|
347
|
-
|
|
348
|
-
if not goal or anchor_file.exists():
|
|
349
|
-
return False
|
|
406
|
+
verification_body = spec_check_placeholder(language, dev_type)
|
|
350
407
|
try:
|
|
351
408
|
anchor_file.write_text(
|
|
352
|
-
f"##
|
|
353
|
-
"
|
|
409
|
+
f"## {goal_heading}\n\n{goal_body}\n\n"
|
|
410
|
+
f"## {acceptance_heading}\n\n{acceptance_body}\n\n"
|
|
411
|
+
f"## {verification_heading}\n\n{verification_comment}\n"
|
|
412
|
+
f"{verification_body}\n",
|
|
354
413
|
encoding="utf-8",
|
|
355
414
|
)
|
|
356
415
|
except OSError as error:
|
|
@@ -363,7 +422,7 @@ class TaskCreationService:
|
|
|
363
422
|
anchor_file,
|
|
364
423
|
"decision anchor could not be written",
|
|
365
424
|
) from error
|
|
366
|
-
return True
|
|
425
|
+
return True, goal is not None
|
|
367
426
|
|
|
368
427
|
|
|
369
428
|
def _validate_dev_type(dev_type: str | None, path: Path) -> None:
|
|
@@ -4,7 +4,7 @@ A task created today carries `meta.gateProfile = "strict"`; a task without the
|
|
|
4
4
|
marker keeps the historical gate (legacy), so nothing that already exists is
|
|
5
5
|
retroactively re-gated.
|
|
6
6
|
|
|
7
|
-
The strict gate exists because the old one only checked that the
|
|
7
|
+
The strict gate exists because the old one only checked that the acceptance
|
|
8
8
|
heading was present. An anchor with an empty acceptance section passed, the
|
|
9
9
|
acceptance/evidence gates then found no AC rows to check, and the whole chain
|
|
10
10
|
silently did nothing.
|
|
@@ -12,20 +12,17 @@ silently did nothing.
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
from services.decision_anchor_schema import (
|
|
16
|
+
ACCEPTANCE_RE,
|
|
17
|
+
LIST_ITEM_RE,
|
|
18
|
+
acceptance_section_lines,
|
|
19
|
+
)
|
|
18
20
|
|
|
19
21
|
GATE_PROFILE_STRICT = "strict"
|
|
20
22
|
GATE_PROFILE_LEGACY = "legacy"
|
|
21
23
|
|
|
22
24
|
LIGHT_TASK_TYPE = "tiny"
|
|
23
25
|
|
|
24
|
-
ACCEPTANCE_HEADING = "验收标准"
|
|
25
|
-
|
|
26
|
-
_HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
|
|
27
|
-
_LIST_ITEM_RE = re.compile(r"^\s*[-*]\s")
|
|
28
|
-
|
|
29
26
|
|
|
30
27
|
def resolve_gate_profile(task_data: dict) -> str:
|
|
31
28
|
"""The gate a task is held to; an unmarked task is legacy."""
|
|
@@ -72,20 +69,6 @@ def is_light_task(task_data: dict) -> bool:
|
|
|
72
69
|
return declares_light_profile(task_data)
|
|
73
70
|
|
|
74
71
|
|
|
75
|
-
def acceptance_section_lines(anchor_text: str) -> list[str]:
|
|
76
|
-
"""Raw lines under `## 验收标准`, up to the next heading."""
|
|
77
|
-
lines: list[str] = []
|
|
78
|
-
inside = False
|
|
79
|
-
for raw in anchor_text.splitlines():
|
|
80
|
-
heading = _HEADING_RE.match(raw.strip())
|
|
81
|
-
if heading:
|
|
82
|
-
inside = heading.group(1) == ACCEPTANCE_HEADING
|
|
83
|
-
continue
|
|
84
|
-
if inside:
|
|
85
|
-
lines.append(raw)
|
|
86
|
-
return lines
|
|
87
|
-
|
|
88
|
-
|
|
89
72
|
def acceptance_row_blockers(anchor_text: str) -> list[str]:
|
|
90
73
|
"""Why the acceptance section cannot start a strict task.
|
|
91
74
|
|
|
@@ -101,7 +84,7 @@ def acceptance_row_blockers(anchor_text: str) -> list[str]:
|
|
|
101
84
|
if match:
|
|
102
85
|
ids.append(match.group(2))
|
|
103
86
|
continue
|
|
104
|
-
if
|
|
87
|
+
if LIST_ITEM_RE.match(raw):
|
|
105
88
|
malformed.append(raw.strip()[:80])
|
|
106
89
|
blockers: list[str] = []
|
|
107
90
|
if not ids:
|