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.
Files changed (27) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/docs/architecture.en.md +1 -1
  3. package/docs/architecture.md +1 -1
  4. package/package.json +1 -1
  5. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  6. package/presets/codex/.codex-plugin/plugin.json +1 -1
  7. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  8. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  9. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  10. package/template/.cowork-flow/.version +1 -1
  11. package/template/.cowork-flow/scripts/adapters/cli/plan.py +218 -0
  12. package/template/.cowork-flow/scripts/run.py +3 -1
  13. package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +134 -10
  14. package/template/.cowork-flow/scripts/services/fact_view.py +6 -0
  15. package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
  16. package/template/.cowork-flow/scripts/services/plan_readiness.py +583 -0
  17. package/template/.cowork-flow/scripts/services/plan_scaffold.py +133 -0
  18. package/template/.cowork-flow/scripts/services/plan_schema.py +222 -0
  19. package/template/.cowork-flow/scripts/services/task_context.py +83 -5
  20. package/template/.cowork-flow/scripts/services/task_creation.py +41 -25
  21. package/template/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
  22. package/template/.cowork-flow/spec/contracts/plan-binding.md +85 -5
  23. package/template/.cowork-flow/spec/runtime/contract-registry.json +3 -1
  24. package/template/.cowork-flow/templates/plan-template.md +1 -1
  25. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +15 -6
  26. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +1 -1
  27. package/template/skills/task-planning/SKILL.md +27 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@
4
4
 
5
5
  ### Added
6
6
 
7
+ ### Changed
8
+
9
+ ## [2.4.2] - 2026-10-23
10
+
11
+ ### Added
12
+
13
+ - **计划骨架生成与定稿门禁**:新增 `./.cowork-flow/run plan new --title ... --slug ...`,从已安装的本地化 `.cowork-flow/templates/plan-template.md` 生成计划骨架并替换标题占位符,默认拒绝覆盖已有计划;新增 `plan finalize [task-dir]`,校验计划模板形状、目标、AC、章节内容、任务简报字段、验证命令、anchor 的 AC/目标/规范核对和 `implement.jsonl`,通过后写入 `task.json.meta.planReady` 的 plan/anchor 摘要。模板中的角括号占位内容以及示例路径/符号(如 `path/a.py`、`function_name`)不算已填充,必须替换后才能定稿。任务创建会把计划 AC 自动填入 anchor 的验收标准。新 strict 非 Tiny 任务默认带 `planReadyRequired`,start 要求快照存在且摘要未变;人验收后修改计划或 anchor 会阻断 start,需重新 finalize 并重新验收。Tiny、legacy 与未标记旧任务保持原行为。
14
+
15
+ ### Changed
16
+
17
+ - **计划流程改为“生成骨架 → 填充 → finalize → 人验收 → start”**:`task-planning` 技能、`plan-binding.md` 契约与 `plan finalize` 检查共同约束计划完整性;start 保留结构 backstop,并把新任务的完整性主检查前移到 finalize。契约摘要同步更新,未新增第二套计划模板。
18
+
19
+ ## [2.4.1] - 2026-10-23
20
+
21
+ ### Added
22
+
23
+ - **新任务 anchor 预置规范核对脚手架**:`task create` 生成的 `decision-anchor.md` 在目标/验收标准后追加配置语言的 `## 验证命令` / `## Verification Commands`,并写入带 `<dev_type>` 与 `<pass / not_applicable + 理由>` 的 `规范核对:` / `Spec check:` 占位行和启动前替换说明。创建与 `--from-plan` 绑定仍保持宽松,占位行在 start 时才被门禁拒绝。
24
+
25
+ ### Changed
26
+
27
+ - **strict 非 Tiny 任务的计划模板与规范核对硬门禁**:start 前校验绑定计划使用配置语言的标题标记、六个模板章节顺序、`### 任务` / `### Task` 前缀,以及 `## 最终验证` / `## Final Verification` 中已填写的 `规范核对:` / `Spec check:` 结果;同时要求 anchor 具有配置语言的验证节和已填写结果。模板占位符不算通过;事实视图不再把规范核对行当作 shell 命令。Tiny 与 legacy 任务保持原有行为,创建/绑定阶段不新增拒绝路径。
28
+
7
29
  ## [2.4.0] - 2026-10-23
8
30
 
9
31
  ### Added
@@ -423,7 +445,9 @@
423
445
 
424
446
  此版本未保留本地条目正文,详情见 [GitHub Release](https://github.com/fishlikewater/cowork-flow/releases/tag/v0.0.47)。
425
447
 
426
- [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v2.4.0...HEAD
448
+ [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v2.4.2...HEAD
449
+ [2.4.2]: https://github.com/fishlikewater/cowork-flow/compare/v2.4.1...v2.4.2
450
+ [2.4.1]: https://github.com/fishlikewater/cowork-flow/compare/v2.4.0...v2.4.1
427
451
  [2.4.0]: https://github.com/fishlikewater/cowork-flow/compare/v2.3.0...v2.4.0
428
452
  [2.3.0]: https://github.com/fishlikewater/cowork-flow/compare/v2.2.0...v2.3.0
429
453
  [2.2.0]: https://github.com/fishlikewater/cowork-flow/compare/v2.1.0...v2.2.0
@@ -73,7 +73,7 @@ Installers and the synchronizer do not read and write incrementally; they execut
73
73
 
74
74
  `config.yaml`, `.developer`, specs, tasks, and plans are protected by the sync policy. `--force` widens the overwrite range, but `.cowork-flow/config.yaml` is in `forceProtectedFiles`: once created, neither `init --force` nor `sync --force` overwrites it. For an existing project, run with `--dry-run` first to see the changes.
75
75
 
76
- The installation language is written to `language` in `config.yaml` (`zh-CN` by default, or `en-US`). The `localization` field of the Host Asset Manifest declares which assets have language variants; their source files live under `template/locales/<language>/<canonical-path>`. `init`, `sync`, and `dev refresh` choose the source file through `localizedSource()`, while the target path is always the canonical path, and `locales/` is excluded by `excludedPrefixes` so it never enters the project. Currently only `spec/contracts/decision-anchor.md` and `.cowork-flow/templates/plan-template.md` are localized: the former sets the heading language of a new anchor, the latter sets the language of plan artifacts, and parsers always accept both Chinese and English headings.
76
+ The installation language is written to `language` in `config.yaml` (`zh-CN` by default, or `en-US`). The `localization` field of the Host Asset Manifest declares which assets have language variants; their source files live under `template/locales/<language>/<canonical-path>`. `init`, `sync`, and `dev refresh` choose the source file through `localizedSource()`, while the target path is always the canonical path, and `locales/` is excluded by `excludedPrefixes` so it never enters the project. Currently only `spec/contracts/decision-anchor.md` and `.cowork-flow/templates/plan-template.md` are localized: the former sets the heading language of a new anchor, the latter sets the language of plan artifacts, and parsers always accept both Chinese and English headings. The start gate for strict non-Tiny tasks additionally requires the plan and anchor to use the configured-language template shape and a filled `Spec check:` / `规范核对:` result; the template placeholder blocks start, while Tiny and legacy tasks are unaffected.
77
77
 
78
78
  `.cowork-flow/spec/` splits its sync policy by ownership. Framework-maintained `spec/contracts/`, `spec/runtime/`, `spec/schemas/`, and `spec/index.md` follow `sync`: contract errata, `contract-registry.json`, `host-assets.json`, `scope-rules.json`, and schemas therefore reach installed projects in the same generation as the scripts, and obsolete framework spec files registered in `obsoleteFiles` are deleted as well. Project-maintained `spec/backend/`, `spec/frontend/`, `spec/guides/`, and `spec/references/` stay protected, and a normal `sync` never overwrites or deletes files there. The cost is that a project which has locally modified a framework contract or `scope-rules.json` loses those changes on the next `sync`; customizations worth keeping should go into the four project directories, `AGENTS.md`, `config.yaml`, or a project-owned Skill.
79
79
 
@@ -73,7 +73,7 @@
73
73
 
74
74
  `config.yaml`、`.developer`、规范、任务和计划按同步策略保护。`--force` 会扩大覆盖范围,但 `.cowork-flow/config.yaml` 是 `forceProtectedFiles`:首次创建后 `init --force` 和 `sync --force` 都不再覆盖它。已有项目应先用 `--dry-run` 查看变更。
75
75
 
76
- 安装语言写在 `config.yaml` 的 `language`(`zh-CN` 默认,或 `en-US`)。Host Asset Manifest 的 `localization` 声明哪些资产存在语言变体,源文件位于 `template/locales/<language>/<canonical-path>`;`init`、`sync` 与 `dev refresh` 通过 `localizedSource()` 选择源文件,目标路径始终是 canonical path,`locales/` 被 `excludedPrefixes` 排除,不会进入项目。当前只有 `spec/contracts/decision-anchor.md` 与 `.cowork-flow/templates/plan-template.md` 本地化;前者决定新 anchor 的标题语言,后者决定计划产物语言,解析器始终同时接受中英两套标题。
76
+ 安装语言写在 `config.yaml` 的 `language`(`zh-CN` 默认,或 `en-US`)。Host Asset Manifest 的 `localization` 声明哪些资产存在语言变体,源文件位于 `template/locales/<language>/<canonical-path>`;`init`、`sync` 与 `dev refresh` 通过 `localizedSource()` 选择源文件,目标路径始终是 canonical path,`locales/` 被 `excludedPrefixes` 排除,不会进入项目。当前只有 `spec/contracts/decision-anchor.md` 与 `.cowork-flow/templates/plan-template.md` 本地化;前者决定新 anchor 的标题语言,后者决定计划产物语言,解析器始终同时接受中英两套标题。strict 非 Tiny 任务的 start 门禁另外要求计划与 anchor 使用配置语言的模板形状,并要求 `规范核对:` 行填写实际 `pass` / `not_applicable` 结果;模板占位符会被阻断,Tiny 与 legacy 任务不受影响。
77
77
 
78
78
  `.cowork-flow/spec/` 按归属拆分同步策略。框架维护的 `spec/contracts/`、`spec/runtime/`、`spec/schemas/` 与 `spec/index.md` 随 `sync` 更新:契约勘误、`contract-registry.json`、`host-assets.json`、`scope-rules.json` 和 schema 因此能与脚本同代际到达已装项目,`obsoleteFiles` 中登记的过期框架 spec 文件也会被删除。项目维护的 `spec/backend/`、`spec/frontend/`、`spec/guides/`、`spec/references/` 保持保护,普通 `sync` 不会覆盖或删除其中的文件。代价是本地修改过框架契约或 `scope-rules.json` 的项目会在下一次 `sync` 丢掉这些改动;需要保留的定制应写入四个项目目录、`AGENTS.md`、`config.yaml` 或项目自有 Skill。
79
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Claude Code。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Codex。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Kimi Code。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Qoder。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1 +1 @@
1
- 2.4.0
1
+ 2.4.2
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """Plan skeleton generation and readiness finalization CLI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from adapters.cli.execution_context_args import (
12
+ build_internal_execution_context_parser,
13
+ execution_context_from_namespace,
14
+ )
15
+ from adapters.cli.task_support import (
16
+ Colors,
17
+ colored,
18
+ resolve_task_dir,
19
+ slugify,
20
+ )
21
+ from infra.paths import FILE_TASK_JSON, get_repo_root
22
+ from runtime.session_state import (
23
+ PROVENANCE_PROCESS_FALLBACK,
24
+ get_active_task,
25
+ )
26
+ from services.plan_readiness import PlanReadinessError, PlanReadinessService
27
+ from services.plan_scaffold import PlanScaffoldError, PlanScaffoldService
28
+
29
+
30
+ def _build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(
32
+ description="Generate a plan skeleton or finalize planning artifacts",
33
+ parents=[build_internal_execution_context_parser()],
34
+ )
35
+ subparsers = parser.add_subparsers(dest="command", required=True)
36
+
37
+ new_parser = subparsers.add_parser(
38
+ "new",
39
+ help="Create a plan skeleton from the installed localized template",
40
+ )
41
+ new_parser.add_argument("--title", required=True, help="Plan title")
42
+ new_parser.add_argument(
43
+ "--slug",
44
+ help="ASCII plan slug; defaults to the title when it slugifies",
45
+ )
46
+ new_parser.add_argument(
47
+ "--force",
48
+ action="store_true",
49
+ help="Overwrite an existing plan file",
50
+ )
51
+ new_parser.add_argument(
52
+ "--date",
53
+ help="Plan date prefix in YYYY-MM-DD form (default: today)",
54
+ )
55
+
56
+ finalize_parser = subparsers.add_parser(
57
+ "finalize",
58
+ help="Validate planning artifacts and record the plan-ready snapshot",
59
+ )
60
+ finalize_parser.add_argument(
61
+ "dir",
62
+ nargs="?",
63
+ help="Task directory or name; defaults to the active task",
64
+ )
65
+ finalize_parser.add_argument(
66
+ "--json",
67
+ action="store_true",
68
+ help="Print the plan-ready snapshot as JSON",
69
+ )
70
+ return parser
71
+
72
+
73
+ def _resolve_task(args: argparse.Namespace, repo_root: Path) -> Path | None:
74
+ target = getattr(args, "dir", None)
75
+ if target:
76
+ task_dir = resolve_task_dir(target, repo_root)
77
+ else:
78
+ active = get_active_task(repo_root)
79
+ if not active.context_key:
80
+ print(
81
+ colored(
82
+ "Error: Missing session context. Set "
83
+ "COWORK_FLOW_CONTEXT_ID or pass a task dir.",
84
+ Colors.RED,
85
+ ),
86
+ file=sys.stderr,
87
+ )
88
+ return None
89
+ if active.task_path and active.provenance == PROVENANCE_PROCESS_FALLBACK:
90
+ print(
91
+ colored(
92
+ "Error: Session binding is process-fallback (shared); "
93
+ "set COWORK_FLOW_CONTEXT_ID to the session value shown "
94
+ "in the workflow-state header, or pass a task dir "
95
+ "explicitly.",
96
+ Colors.RED,
97
+ ),
98
+ file=sys.stderr,
99
+ )
100
+ return None
101
+ if not active.task_path:
102
+ print(
103
+ colored(
104
+ "Error: No active task set for this session",
105
+ Colors.RED,
106
+ ),
107
+ file=sys.stderr,
108
+ )
109
+ return None
110
+ task_dir = repo_root / active.task_path
111
+
112
+ if not task_dir.is_dir() or not (task_dir / FILE_TASK_JSON).is_file():
113
+ print(
114
+ colored(
115
+ f"Error: Task not found or task.json missing: {task_dir}",
116
+ Colors.RED,
117
+ ),
118
+ file=sys.stderr,
119
+ )
120
+ return None
121
+ return task_dir
122
+
123
+
124
+ def _cmd_new(args: argparse.Namespace, repo_root: Path) -> int:
125
+ slug = args.slug or slugify(args.title)
126
+ if not slug:
127
+ print(
128
+ colored(
129
+ "Error: could not generate an ASCII slug from the title; "
130
+ "pass --slug <ascii-slug>",
131
+ Colors.RED,
132
+ ),
133
+ file=sys.stderr,
134
+ )
135
+ return 1
136
+ try:
137
+ plan_path = PlanScaffoldService(repo_root).create(
138
+ args.title,
139
+ slug,
140
+ force=bool(args.force),
141
+ date_prefix=args.date,
142
+ )
143
+ except PlanScaffoldError as error:
144
+ print(
145
+ colored(
146
+ f"Error: Failed to create plan skeleton [{error.code}]: "
147
+ f"{error.detail}",
148
+ Colors.RED,
149
+ ),
150
+ file=sys.stderr,
151
+ )
152
+ return 1
153
+ try:
154
+ display = plan_path.resolve().relative_to(repo_root.resolve()).as_posix()
155
+ except ValueError:
156
+ display = str(plan_path)
157
+ print(display)
158
+ return 0
159
+
160
+
161
+ def _cmd_finalize(args: argparse.Namespace, repo_root: Path) -> int:
162
+ task_dir = _resolve_task(args, repo_root)
163
+ if task_dir is None:
164
+ return 1
165
+ try:
166
+ snapshot = PlanReadinessService(repo_root).finalize(task_dir)
167
+ except PlanReadinessError as error:
168
+ print(
169
+ colored("Error: Plan is not ready for review", Colors.RED),
170
+ file=sys.stderr,
171
+ )
172
+ for blocker in error.blockers:
173
+ print(f" - {blocker}", file=sys.stderr)
174
+ print(
175
+ "Hint: fill the planning artifacts, then rerun "
176
+ "`./.cowork-flow/run plan finalize <task-dir>`.",
177
+ file=sys.stderr,
178
+ )
179
+ return 1
180
+
181
+ if getattr(args, "json", False):
182
+ print(json.dumps(snapshot, ensure_ascii=False, indent=2))
183
+ else:
184
+ print(colored("Plan is ready for human review.", Colors.GREEN))
185
+ print(f" planDigest: {snapshot['planDigest']}")
186
+ print(f" anchorDigest: {snapshot['anchorDigest']}")
187
+ print(f" readyAt: {snapshot['readyAt']}")
188
+ print(
189
+ " Next: review the plan and anchor, then start the task. "
190
+ "Start will reject any later edit to either file."
191
+ )
192
+ return 0
193
+
194
+
195
+ def main(argv: list[str] | None = None) -> int:
196
+ args = _build_parser().parse_args(argv)
197
+ execution_context = execution_context_from_namespace(args)
198
+ if not execution_context.is_default:
199
+ print(
200
+ colored(
201
+ "Error: plan commands must run in the main session; "
202
+ "delegated contexts cannot finalize a task.",
203
+ Colors.RED,
204
+ ),
205
+ file=sys.stderr,
206
+ )
207
+ return 2
208
+
209
+ repo_root = get_repo_root()
210
+ if args.command == "new":
211
+ return _cmd_new(args, repo_root)
212
+ if args.command == "finalize":
213
+ return _cmd_finalize(args, repo_root)
214
+ return 1
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
@@ -23,6 +23,7 @@ configure_cli_encoding()
23
23
  COMMAND_SCRIPTS = {
24
24
  "resume": "adapters/cli/resume.py",
25
25
  "task": "adapters/cli/task.py",
26
+ "plan": "adapters/cli/plan.py",
26
27
  "get-context": "adapters/cli/get_context.py",
27
28
  "get_context": "adapters/cli/get_context.py",
28
29
  "get-developer": "adapters/cli/get_developer.py",
@@ -35,7 +36,7 @@ COMMAND_SCRIPTS = {
35
36
  }
36
37
  RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
37
38
 
38
- CONTEXT_AWARE_COMMANDS = {"resume", "task", "subagent"}
39
+ CONTEXT_AWARE_COMMANDS = {"resume", "task", "plan", "subagent"}
39
40
 
40
41
 
41
42
  def print_usage() -> None:
@@ -49,6 +50,7 @@ def print_usage() -> None:
49
50
  Common commands:
50
51
  resume
51
52
  task
53
+ plan
52
54
  state
53
55
  get-context
54
56
  get-developer
@@ -23,6 +23,11 @@ REQUIRED_SECTIONS = (GOAL, ACCEPTANCE)
23
23
 
24
24
  DEFAULT_LANGUAGE = "zh-CN"
25
25
 
26
+ SPEC_CHECK_LABELS: dict[str, str] = {
27
+ "zh-CN": "规范核对:",
28
+ "en-US": "Spec check:",
29
+ }
30
+
26
31
  LANGUAGE_HEADINGS: dict[str, dict[str, str]] = {
27
32
  "zh-CN": {
28
33
  GOAL: "目标",
@@ -53,6 +58,10 @@ ACCEPTANCE_RE = re.compile(
53
58
  r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
54
59
  )
55
60
  LIST_ITEM_RE = re.compile(r"^\s*[-*]\s")
61
+ LIST_PREFIX_RE = re.compile(r"^\s*[-*+]\s*")
62
+ FENCE_MARKER_RE = re.compile(r"^(```|~~~)")
63
+ SPEC_CHECK_RESULT_RE = re.compile(r"(?i)\b(?:pass|not_applicable)\b")
64
+ ANGLE_SPAN_RE = re.compile(r"[<<][^>>]*[>>]")
56
65
 
57
66
 
58
67
  def canonical_section(heading: str) -> str | None:
@@ -77,6 +86,130 @@ def section_headings(section: str) -> tuple[str, ...]:
77
86
  )
78
87
 
79
88
 
89
+ def spec_check_label(language: str) -> str:
90
+ """The spec-check label for a language, falling back to the default."""
91
+ return SPEC_CHECK_LABELS.get(language, SPEC_CHECK_LABELS[DEFAULT_LANGUAGE])
92
+
93
+
94
+ def _spec_check_remainder(line: str, language: str | None) -> str | None:
95
+ text = LIST_PREFIX_RE.sub("", line.strip(), count=1)
96
+ labels = (
97
+ (spec_check_label(language),)
98
+ if language is not None
99
+ else tuple(SPEC_CHECK_LABELS.values())
100
+ )
101
+ for label in labels:
102
+ if text.startswith(label):
103
+ return text[len(label):]
104
+ return None
105
+
106
+
107
+ def is_spec_check_line(line: str, language: str | None = None) -> bool:
108
+ """Whether a line starts with the configured or any spec-check label."""
109
+ return _spec_check_remainder(line, language) is not None
110
+
111
+
112
+ def is_spec_check_result_line(line: str, language: str | None = None) -> bool:
113
+ """Whether a spec-check line carries an actual result.
114
+
115
+ Angle-bracket spans are the template's placeholder syntax, so a result
116
+ mentioned only inside `<...>`/`<...>` does not count.
117
+ """
118
+ remainder = _spec_check_remainder(line, language)
119
+ if remainder is None:
120
+ return False
121
+ return SPEC_CHECK_RESULT_RE.search(ANGLE_SPAN_RE.sub("", remainder)) is not None
122
+
123
+
124
+ def _spec_scope(dev_type: str | None) -> str:
125
+ """The spec source a task's dev_type dispatches to.
126
+
127
+ `spec` is the whole-spec dev type and its reading list starts at the spec
128
+ index, not at a `.cowork-flow/spec/spec/` directory.
129
+ """
130
+ if dev_type == "spec":
131
+ return ".cowork-flow/spec/"
132
+ return f".cowork-flow/spec/{dev_type or '<dev_type>'}/"
133
+
134
+
135
+ def spec_check_placeholder(language: str, dev_type: str | None = None) -> str:
136
+ """The localized scaffold line that the start gate treats as unfilled."""
137
+ spec_dir = _spec_scope(dev_type)
138
+ if language == "en-US":
139
+ return (
140
+ f"- Spec check: clause-by-clause check against applicable "
141
+ f"`{spec_dir}` rules -> <pass / not_applicable + reason>."
142
+ )
143
+ return (
144
+ f"- 规范核对:逐条核对 `{spec_dir}` 适用规范 "
145
+ "→ <pass / not_applicable + 理由>。"
146
+ )
147
+
148
+
149
+ def spec_check_example(language: str, dev_type: str | None = None) -> str:
150
+ """A filled spec-check example for start-gate error messages."""
151
+ spec_dir = _spec_scope(dev_type)
152
+ if language == "en-US":
153
+ return (
154
+ f"- Spec check: clause-by-clause check against applicable "
155
+ f"`{spec_dir}` rules -> pass (reason: ...)"
156
+ )
157
+ return (
158
+ f"- 规范核对:逐条核对 `{spec_dir}` 适用规范 → pass(理由:...)"
159
+ )
160
+
161
+
162
+ def structural_lines(text: str) -> list[str]:
163
+ """Lines outside fenced code blocks, for structural Markdown checks."""
164
+ lines: list[str] = []
165
+ fence: str | None = None
166
+ for raw in text.splitlines():
167
+ marker = FENCE_MARKER_RE.match(raw.strip())
168
+ if marker is not None:
169
+ if fence is None:
170
+ fence = marker.group(1)
171
+ elif marker.group(1) == fence:
172
+ fence = None
173
+ continue
174
+ if fence is None:
175
+ lines.append(raw)
176
+ return lines
177
+
178
+
179
+ def section_lines(
180
+ text: str,
181
+ section: str,
182
+ *,
183
+ skip_fenced: bool = False,
184
+ ) -> list[str]:
185
+ """Raw lines under a recognized heading, up to the next `##` heading."""
186
+ lines: list[str] = []
187
+ inside = False
188
+ source = structural_lines(text) if skip_fenced else text.splitlines()
189
+ for raw in source:
190
+ heading = HEADING_RE.match(raw.strip())
191
+ if heading:
192
+ inside = canonical_section(heading.group(1)) == section
193
+ continue
194
+ if inside:
195
+ lines.append(raw)
196
+ return lines
197
+
198
+
199
+ def verification_section_lines(text: str) -> list[str]:
200
+ """Raw verification lines, ignoring fenced command examples."""
201
+ return section_lines(text, VERIFICATION, skip_fenced=True)
202
+
203
+
204
+ def headings(text: str) -> tuple[str, ...]:
205
+ """Every `##` heading text in the document order."""
206
+ return tuple(
207
+ heading.group(1)
208
+ for raw in text.splitlines()
209
+ if (heading := HEADING_RE.match(raw.strip()))
210
+ )
211
+
212
+
80
213
  def present_sections(text: str) -> set[str]:
81
214
  """Canonical keys of the recognized `##` sections present in the text."""
82
215
  present: set[str] = set()
@@ -92,13 +225,4 @@ def present_sections(text: str) -> set[str]:
92
225
 
93
226
  def acceptance_section_lines(text: str) -> list[str]:
94
227
  """Raw lines under the acceptance heading, up to the next heading."""
95
- lines: list[str] = []
96
- inside = False
97
- for raw in text.splitlines():
98
- heading = HEADING_RE.match(raw.strip())
99
- if heading:
100
- inside = canonical_section(heading.group(1)) == ACCEPTANCE
101
- continue
102
- if inside:
103
- lines.append(raw)
104
- return lines
228
+ return section_lines(text, ACCEPTANCE)
@@ -26,6 +26,7 @@ from services.decision_anchor_schema import (
26
26
  SCOPE,
27
27
  VERIFICATION,
28
28
  canonical_section,
29
+ is_spec_check_line,
29
30
  )
30
31
 
31
32
  GOAL_TRUNCATE = 300
@@ -73,6 +74,11 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
73
74
  command = raw.strip()
74
75
  if command.startswith("- "):
75
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
76
82
  if command and len(verify_commands) < VERIFY_COMMAND_MAX:
77
83
  verify_commands.append(command[:VERIFY_COMMAND_TRUNCATE])
78
84
  elif section == SCOPE:
@@ -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
+ ]