cowork-flow 2.3.0 → 2.4.0

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 (60) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.en.md +34 -18
  3. package/README.md +8 -5
  4. package/docs/architecture.en.md +95 -0
  5. package/docs/architecture.md +7 -3
  6. package/docs/direction.en.md +71 -0
  7. package/docs/direction.md +6 -1
  8. package/docs/hosts.en.md +299 -0
  9. package/docs/hosts.md +3 -1
  10. package/docs/index.en.md +30 -0
  11. package/docs/index.md +2 -0
  12. package/docs/release.en.md +108 -0
  13. package/docs/release.md +2 -0
  14. package/package.json +2 -2
  15. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/presets/claude-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  17. package/presets/codex/.codex-plugin/plugin.json +1 -1
  18. package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  19. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  20. package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  21. package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  22. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  23. package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  24. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  25. package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +4 -0
  26. package/src/commands/init.js +129 -2
  27. package/src/commands/registry.js +3 -1
  28. package/src/commands/source-refresh.js +5 -1
  29. package/src/commands/sync.js +3 -0
  30. package/src/lib/copy-template.js +23 -5
  31. package/src/lib/host-assets.js +117 -2
  32. package/src/lib/language.js +162 -0
  33. package/template/.cowork-flow/.version +1 -1
  34. package/template/.cowork-flow/config.yaml +8 -0
  35. package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
  36. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
  37. package/template/.cowork-flow/scripts/infra/config.py +85 -0
  38. package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +104 -0
  39. package/template/.cowork-flow/scripts/services/fact_view.py +21 -15
  40. package/template/.cowork-flow/scripts/services/task_context.py +13 -7
  41. package/template/.cowork-flow/scripts/services/task_creation.py +72 -30
  42. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
  43. package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
  44. package/template/.cowork-flow/spec/contracts/decision-anchor.md +21 -14
  45. package/template/.cowork-flow/spec/contracts/plan-binding.md +2 -1
  46. package/template/.cowork-flow/spec/index.md +26 -26
  47. package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
  48. package/template/.cowork-flow/spec/references/index.md +16 -16
  49. package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
  50. package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
  51. package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
  52. package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
  53. package/template/.cowork-flow/spec/runtime/index.md +10 -12
  54. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
  55. package/template/.cowork-flow/spec/schemas/index.md +5 -5
  56. package/template/.cowork-flow/templates/plan-template.md +91 -0
  57. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +160 -0
  58. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
  59. package/template/skills/runtime-health/scripts/doctor.py +110 -0
  60. package/template/skills/task-planning/SKILL.md +19 -92
@@ -0,0 +1,162 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export const DEFAULT_LANGUAGE = 'zh-CN';
5
+ export const SUPPORTED_LANGUAGES = Object.freeze(['zh-CN', 'en-US']);
6
+
7
+ const LANGUAGE_ALIASES = new Map([
8
+ ['zh', 'zh-CN'],
9
+ ['zh-cn', 'zh-CN'],
10
+ ['zh_cn', 'zh-CN'],
11
+ ['en', 'en-US'],
12
+ ['en-us', 'en-US'],
13
+ ['en_us', 'en-US']
14
+ ]);
15
+
16
+ // The config is a flat YAML document; a top-level `language:` scalar is the
17
+ // only place the install-time choice is recorded. A `#` starts a comment only
18
+ // outside quotes and only after whitespace, matching the Python reader.
19
+ const LANGUAGE_KEY_RE = /^language\s*:\s*(.*)$/;
20
+
21
+ function stripInlineComment(value) {
22
+ let quote = '';
23
+ let escaped = false;
24
+ for (let index = 0; index < value.length; index += 1) {
25
+ const char = value[index];
26
+ if (escaped) {
27
+ escaped = false;
28
+ } else if (quote === '"' && char === '\\') {
29
+ escaped = true;
30
+ } else if (quote) {
31
+ if (char === quote) {
32
+ quote = '';
33
+ }
34
+ } else if (index === 0 && (char === '"' || char === "'")) {
35
+ quote = char;
36
+ } else if (char === '#' && (index === 0 || value[index - 1] === ' ' || value[index - 1] === '\t')) {
37
+ return value.slice(0, index).trimEnd();
38
+ }
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function unquote(value) {
44
+ if (
45
+ value.length >= 2
46
+ && (value[0] === '"' || value[0] === "'")
47
+ && value[value.length - 1] === value[0]
48
+ ) {
49
+ return value.slice(1, -1);
50
+ }
51
+ return value;
52
+ }
53
+
54
+ export function normalizeLanguage(value) {
55
+ if (value === undefined || value === null) {
56
+ return null;
57
+ }
58
+ return LANGUAGE_ALIASES.get(String(value).trim().toLowerCase()) ?? null;
59
+ }
60
+
61
+ // Top-level `language:` lines only. Two keys, an empty value, a glued comment,
62
+ // or trailing junk after a quoted value all mean the explicit setting cannot be
63
+ // trusted; callers decide whether that is fatal (sync) or falls back (generation).
64
+ export function readLanguageSetting(content) {
65
+ const entries = [];
66
+ for (const rawLine of String(content ?? '').split(/\r?\n/)) {
67
+ if (/^\s/.test(rawLine)) {
68
+ continue;
69
+ }
70
+ const match = LANGUAGE_KEY_RE.exec(rawLine);
71
+ if (match) {
72
+ entries.push(match[1]);
73
+ }
74
+ }
75
+ if (entries.length === 0) {
76
+ return { present: false, raw: null, duplicate: false };
77
+ }
78
+ if (entries.length > 1) {
79
+ return { present: true, raw: null, duplicate: true };
80
+ }
81
+ return {
82
+ present: true,
83
+ raw: unquote(stripInlineComment(entries[0].trim())).trim(),
84
+ duplicate: false
85
+ };
86
+ }
87
+
88
+ export function readLanguageValue(content) {
89
+ const setting = readLanguageSetting(content);
90
+ return setting.present ? setting.raw : null;
91
+ }
92
+
93
+ export function parseLanguage(content) {
94
+ return normalizeLanguage(readLanguageValue(content));
95
+ }
96
+
97
+ export async function readProjectLanguage(targetDir) {
98
+ const file = join(targetDir, '.cowork-flow', 'config.yaml');
99
+ let content;
100
+ try {
101
+ content = await readFile(file, 'utf8');
102
+ } catch (error) {
103
+ if (error && error.code === 'ENOENT') {
104
+ return {
105
+ file,
106
+ present: false,
107
+ configured: false,
108
+ valid: true,
109
+ duplicate: false,
110
+ raw: null,
111
+ language: DEFAULT_LANGUAGE
112
+ };
113
+ }
114
+ throw error;
115
+ }
116
+
117
+ const setting = readLanguageSetting(content);
118
+ const language = setting.duplicate ? null : normalizeLanguage(setting.raw);
119
+ return {
120
+ file,
121
+ present: true,
122
+ configured: setting.present,
123
+ valid: !setting.present || language !== null,
124
+ duplicate: setting.duplicate,
125
+ raw: setting.raw,
126
+ language: language ?? DEFAULT_LANGUAGE
127
+ };
128
+ }
129
+
130
+ // Planning commands must not silently fall back on a typo: the caller is about
131
+ // to distribute assets for a language the project explicitly asked for.
132
+ export async function readProjectLanguageStrict(targetDir) {
133
+ const info = await readProjectLanguage(targetDir);
134
+ if (info.configured && !info.valid) {
135
+ const detail = info.duplicate
136
+ ? 'duplicate language keys'
137
+ : `unsupported language ${JSON.stringify(info.raw)}`;
138
+ throw new Error(
139
+ `${detail} in ${info.file}; expected ${SUPPORTED_LANGUAGES.join(' or ')}`
140
+ );
141
+ }
142
+ return info;
143
+ }
144
+
145
+ // Write the chosen language into the config content that is about to be
146
+ // installed. The template already declares the key; the fallback keeps a
147
+ // hand-edited template from losing the choice.
148
+ export function withLanguage(content, language) {
149
+ const canonical = normalizeLanguage(language);
150
+ if (!canonical) {
151
+ throw new Error(`Unsupported language: ${language}`);
152
+ }
153
+ const setting = readLanguageSetting(content);
154
+ if (setting.duplicate) {
155
+ throw new Error('Cannot rewrite a config with duplicate language keys');
156
+ }
157
+ const line = `language: "${canonical}"`;
158
+ if (setting.present) {
159
+ return content.replace(/^language\s*:.*$/m, line);
160
+ }
161
+ return `${content.replace(/\s*$/, '')}\n\n${line}\n`;
162
+ }
@@ -1 +1 @@
1
- 2.3.0
1
+ 2.4.0
@@ -3,6 +3,14 @@
3
3
  #
4
4
  # All values have sensible defaults. Only override what you need.
5
5
 
6
+ #-------------------------------------------------------------------------------
7
+ # Language
8
+ #-------------------------------------------------------------------------------
9
+
10
+ # Output language for generated task and plan artifacts: zh-CN or en-US.
11
+ # This file is never overwritten by project sync, including sync --force.
12
+ language: "zh-CN"
13
+
6
14
  #-------------------------------------------------------------------------------
7
15
  # Codex Runtime
8
16
  #-------------------------------------------------------------------------------
@@ -133,10 +133,12 @@ def _print_create_result(result) -> None:
133
133
  file=sys.stderr,
134
134
  )
135
135
  if result.generated_anchor:
136
- print(
137
- f" {colored('[OK]', Colors.GREEN)} "
136
+ message = (
138
137
  "Generated decision-anchor.md from plan"
138
+ if result.generated_anchor_from_plan
139
+ else "Created decision-anchor.md scaffold"
139
140
  )
141
+ print(f" {colored('[OK]', Colors.GREEN)} {message}")
140
142
 
141
143
  print(
142
144
  colored(f"Created task: {dir_name}", Colors.GREEN),
@@ -150,7 +152,7 @@ def _print_next_steps() -> None:
150
152
  print("", file=sys.stderr)
151
153
  print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
152
154
  print(
153
- " 1. Create decision-anchor.md with requirements",
155
+ " 1. Fill in decision-anchor.md (a scaffold is created)",
154
156
  file=sys.stderr,
155
157
  )
156
158
  print(
@@ -31,11 +31,14 @@ CAPABILITY_STATUS_VALUES = (
31
31
  "experimental",
32
32
  "unsupported",
33
33
  )
34
- MANIFEST_KEYS = frozenset(("schemaVersion", "capabilityValues", "capabilityMatrix", "platforms", "excludedPrefixes", "syncPolicy"))
34
+ MANIFEST_KEYS = frozenset(("schemaVersion", "capabilityValues", "capabilityMatrix", "platforms", "excludedPrefixes", "localization", "syncPolicy"))
35
35
  PLATFORM_KEYS = frozenset(("id", "displayName", "aliases", "detectAny", "assetPrefixes", "assetFiles", "skillReadRoot", "skillDiscovery", "adapterPath", "capabilities", "commandTargets", "payload"))
36
36
  SKILL_DISCOVERY_KEYS = frozenset(("scope", "path", "gates", "evidence"))
37
37
  SKILL_DISCOVERY_SCOPES = frozenset(("project",))
38
- SYNC_POLICY_KEYS = frozenset(("protectedFiles", "protectedPrefixes", "safeFiles", "safePrefixes", "managedBlockFiles", "obsoleteFiles"))
38
+ SYNC_POLICY_KEYS = frozenset(("protectedFiles", "forceProtectedFiles", "protectedPrefixes", "safeFiles", "safePrefixes", "managedBlockFiles", "obsoleteFiles"))
39
+ LOCALIZATION_KEYS = frozenset(("default", "supported", "assets"))
40
+ DEFAULT_LANGUAGE = "zh-CN"
41
+ SUPPORTED_LANGUAGES = ("zh-CN", "en-US")
39
42
  COMMAND_TARGET_KEYS = frozenset(("config", "format", "target"))
40
43
  COMMAND_TARGET_FORMATS = frozenset(("json", "toml", "yaml"))
41
44
  CAPABILITY_DECLARATION_KEYS = frozenset(("status", "fallback"))
@@ -108,6 +111,7 @@ class HostPlatform:
108
111
  @dataclass(frozen=True)
109
112
  class SyncPolicy:
110
113
  protected_files: tuple[str, ...]
114
+ force_protected_files: tuple[str, ...]
111
115
  protected_prefixes: tuple[str, ...]
112
116
  safe_files: tuple[str, ...]
113
117
  safe_prefixes: tuple[str, ...]
@@ -115,6 +119,19 @@ class SyncPolicy:
115
119
  obsolete_files: tuple[str, ...]
116
120
 
117
121
 
122
+ @dataclass(frozen=True)
123
+ class Localization:
124
+ """Language-specific asset overrides.
125
+
126
+ The base template always carries the default language. A non-default
127
+ language may override a declared asset from `locales/<language>/`.
128
+ """
129
+
130
+ default: str
131
+ supported: tuple[str, ...]
132
+ assets: dict[str, tuple[str, ...]]
133
+
134
+
118
135
  @dataclass(frozen=True)
119
136
  class HostManifest:
120
137
  schema_version: int
@@ -123,6 +140,7 @@ class HostManifest:
123
140
  capability_matrix: dict[str, dict[str, CapabilityDeclaration]]
124
141
  platforms: tuple[HostPlatform, ...]
125
142
  excluded_prefixes: tuple[str, ...]
143
+ localization: Localization
126
144
  sync_policy: SyncPolicy
127
145
 
128
146
  @property
@@ -195,6 +213,19 @@ def validate_host_assets(
195
213
  if not (root / SCHEMA_PATH).is_file():
196
214
  errors.append(f"missing host asset schema: {root / SCHEMA_PATH}")
197
215
 
216
+ # Locale sources live in the package template. An installed project keeps
217
+ # only the resolved canonical path, so absence of `locales/` is normal and
218
+ # must not turn into a host-asset error.
219
+ if (root / "locales").is_dir():
220
+ for language, assets in manifest.localization.assets.items():
221
+ for asset in assets:
222
+ if not (root / asset).is_file():
223
+ errors.append(f"missing localized base asset: {asset}")
224
+ if not (root / "locales" / language / asset).is_file():
225
+ errors.append(
226
+ f"missing localized asset: locales/{language}/{asset}"
227
+ )
228
+
198
229
  selected_platforms: list[HostPlatform] = []
199
230
  selected_ids = (
200
231
  manifest.platform_ids
@@ -388,8 +419,13 @@ def _build_manifest(raw: dict[str, Any]) -> HostManifest:
388
419
  capability_matrix=capability_matrix,
389
420
  platforms=platforms,
390
421
  excluded_prefixes=_string_tuple(raw.get("excludedPrefixes"), "excludedPrefixes"),
422
+ localization=_build_localization(raw.get("localization")),
391
423
  sync_policy=SyncPolicy(
392
424
  protected_files=_string_tuple(sync_raw.get("protectedFiles"), "syncPolicy.protectedFiles"),
425
+ force_protected_files=_string_tuple(
426
+ sync_raw.get("forceProtectedFiles", []),
427
+ "syncPolicy.forceProtectedFiles",
428
+ ),
393
429
  protected_prefixes=_string_tuple(sync_raw.get("protectedPrefixes"), "syncPolicy.protectedPrefixes"),
394
430
  safe_files=_string_tuple(sync_raw.get("safeFiles"), "syncPolicy.safeFiles"),
395
431
  safe_prefixes=_string_tuple(sync_raw.get("safePrefixes"), "syncPolicy.safePrefixes"),
@@ -398,6 +434,39 @@ def _build_manifest(raw: dict[str, Any]) -> HostManifest:
398
434
  ),
399
435
  )
400
436
 
437
+
438
+ def _build_localization(raw: object) -> Localization:
439
+ if raw is None:
440
+ return Localization(
441
+ default=DEFAULT_LANGUAGE,
442
+ supported=SUPPORTED_LANGUAGES,
443
+ assets={},
444
+ )
445
+ if not isinstance(raw, dict):
446
+ raise HostManifestError("localization must be an object")
447
+ _reject_unknown_fields(raw, LOCALIZATION_KEYS, "localization")
448
+ default = raw.get("default")
449
+ if not isinstance(default, str) or not default.strip():
450
+ raise HostManifestError("localization.default must be a non-empty string")
451
+ supported = _string_tuple(raw.get("supported"), "localization.supported")
452
+ if default not in supported:
453
+ raise HostManifestError("localization.default must be listed in localization.supported")
454
+ assets_raw = raw.get("assets")
455
+ if not isinstance(assets_raw, dict):
456
+ raise HostManifestError("localization.assets must be an object")
457
+ assets: dict[str, tuple[str, ...]] = {}
458
+ for language, paths in assets_raw.items():
459
+ if language not in supported:
460
+ raise HostManifestError(
461
+ f"localization.assets language is not supported: {language}"
462
+ )
463
+ if language == default:
464
+ raise HostManifestError(
465
+ "localization.assets must not override the default language"
466
+ )
467
+ assets[language] = _string_tuple(paths, f"localization.assets.{language}")
468
+ return Localization(default=default, supported=supported, assets=assets)
469
+
401
470
  def _build_capability_matrix(
402
471
  raw: object,
403
472
  allowed: tuple[str, ...],
@@ -3,6 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import re
6
7
  from pathlib import Path
7
8
 
8
9
  from infra.paths import DIR_WORKFLOW, get_repo_root
@@ -18,9 +19,24 @@ DEFAULT_PARTY_MODE_V2_MAX_DRIFT_WARNINGS = 2
18
19
  DEFAULT_PARTY_MODE_V2_FRESH_CONTEXT_PER_ROUND = True
19
20
  DEFAULT_PARTY_MODE_V2_REQUIRE_CURRENT_ROUND_ONLY = True
20
21
 
22
+ DEFAULT_LANGUAGE = "zh-CN"
23
+ SUPPORTED_LANGUAGES = ("zh-CN", "en-US")
24
+ LANGUAGE_ALIASES = {
25
+ "zh": "zh-CN",
26
+ "zh-cn": "zh-CN",
27
+ "zh_cn": "zh-CN",
28
+ "en": "en-US",
29
+ "en-us": "en-US",
30
+ "en_us": "en-US",
31
+ }
32
+
21
33
  CONFIG_FILE = "config.yaml"
22
34
 
23
35
 
36
+ class UnsupportedLanguageError(ValueError):
37
+ """The config declares a language the runtime does not support."""
38
+
39
+
24
40
  def _unquote(value: str) -> str:
25
41
  if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
26
42
  return value[1:-1]
@@ -117,6 +133,75 @@ def _load_config(repo_root: Path | None = None) -> dict:
117
133
  return {}
118
134
 
119
135
 
136
+ def normalize_language(value: object) -> str | None:
137
+ """Canonical language for a supported value, otherwise None."""
138
+ if not isinstance(value, str):
139
+ return None
140
+ return LANGUAGE_ALIASES.get(value.strip().lower())
141
+
142
+
143
+ _LANGUAGE_KEY_RE = re.compile(r"^language\s*:\s*(.*)$")
144
+
145
+
146
+ def _read_language_setting(
147
+ repo_root: Path | None = None,
148
+ ) -> tuple[str | None, bool, bool]:
149
+ """The explicit top-level language scalar.
150
+
151
+ Returns `(raw, present, duplicate)`. Only top-level `language:` lines count.
152
+ Two keys, an empty value, a glued comment, or trailing junk are left for
153
+ `normalize_language` to reject, matching the Node reader.
154
+ """
155
+ config_file = _get_config_path(repo_root)
156
+ try:
157
+ content = config_file.read_text(encoding="utf-8")
158
+ except (OSError, IOError):
159
+ return None, False, False
160
+
161
+ entries: list[str] = []
162
+ for raw_line in content.splitlines():
163
+ if raw_line.startswith((" ", "\t")):
164
+ continue
165
+ match = _LANGUAGE_KEY_RE.match(raw_line)
166
+ if match:
167
+ entries.append(match.group(1))
168
+ if not entries:
169
+ return None, False, False
170
+ if len(entries) > 1:
171
+ return None, True, True
172
+ raw = _unquote(_strip_comment(entries[0].strip())).strip()
173
+ return raw, True, False
174
+
175
+
176
+ def get_language(
177
+ repo_root: Path | None = None,
178
+ *,
179
+ strict: bool = False,
180
+ ) -> str:
181
+ """Task/plan artifact language from the project config.
182
+
183
+ A missing file or key yields the default. An explicit unsupported value or
184
+ duplicate `language` keys raises when strict is set; runtime generation uses
185
+ the non-strict form so a config typo cannot block task creation.
186
+ """
187
+ raw, present, duplicate = _read_language_setting(repo_root)
188
+ if not present:
189
+ return DEFAULT_LANGUAGE
190
+ canonical = None if duplicate else normalize_language(raw)
191
+ if canonical is not None:
192
+ return canonical
193
+ if strict:
194
+ detail = (
195
+ "duplicate language keys"
196
+ if duplicate
197
+ else f"unsupported language {raw!r}"
198
+ )
199
+ raise UnsupportedLanguageError(
200
+ detail + "; expected one of: " + ", ".join(SUPPORTED_LANGUAGES)
201
+ )
202
+ return DEFAULT_LANGUAGE
203
+
204
+
120
205
 
121
206
  def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
122
207
  """Hook commands declared for one lifecycle event; empty when none."""
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """Shared decision-anchor heading schema.
3
+
4
+ The runtime recognizes two heading sets for the same sections: the historical
5
+ Chinese set and the English aliases introduced for English-language task
6
+ artifacts. Recognition is language-independent so existing tasks and mixed
7
+ language projects keep working; the configured project language only decides
8
+ which set a newly generated anchor uses.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ GOAL = "goal"
16
+ ACCEPTANCE = "acceptance"
17
+ REJECTED = "rejected"
18
+ ASSUMPTIONS = "assumptions"
19
+ SCOPE = "scope"
20
+ VERIFICATION = "verification"
21
+
22
+ REQUIRED_SECTIONS = (GOAL, ACCEPTANCE)
23
+
24
+ DEFAULT_LANGUAGE = "zh-CN"
25
+
26
+ LANGUAGE_HEADINGS: dict[str, dict[str, str]] = {
27
+ "zh-CN": {
28
+ GOAL: "目标",
29
+ ACCEPTANCE: "验收标准",
30
+ REJECTED: "被拒方案",
31
+ ASSUMPTIONS: "关键假设",
32
+ SCOPE: "范围边界",
33
+ VERIFICATION: "验证命令",
34
+ },
35
+ "en-US": {
36
+ GOAL: "Goal",
37
+ ACCEPTANCE: "Acceptance Criteria",
38
+ REJECTED: "Rejected Options",
39
+ ASSUMPTIONS: "Key Assumptions",
40
+ SCOPE: "Scope Boundary",
41
+ VERIFICATION: "Verification Commands",
42
+ },
43
+ }
44
+
45
+ SECTION_ALIASES: dict[str, str] = {
46
+ heading: section
47
+ for headings in LANGUAGE_HEADINGS.values()
48
+ for section, heading in headings.items()
49
+ }
50
+
51
+ HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
52
+ ACCEPTANCE_RE = re.compile(
53
+ r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
54
+ )
55
+ LIST_ITEM_RE = re.compile(r"^\s*[-*]\s")
56
+
57
+
58
+ def canonical_section(heading: str) -> str | None:
59
+ """Canonical section key for a recognized heading, or None."""
60
+ return SECTION_ALIASES.get(heading)
61
+
62
+
63
+ def headings_for(language: str) -> dict[str, str]:
64
+ """Heading set for a language, falling back to the default language."""
65
+ return LANGUAGE_HEADINGS.get(language, LANGUAGE_HEADINGS[DEFAULT_LANGUAGE])
66
+
67
+
68
+ def heading_for(section: str, language: str) -> str:
69
+ """One heading in the requested language, falling back to the default."""
70
+ return headings_for(language)[section]
71
+
72
+
73
+ def section_headings(section: str) -> tuple[str, ...]:
74
+ """Every recognized heading for one section, default language first."""
75
+ return tuple(
76
+ LANGUAGE_HEADINGS[language][section] for language in LANGUAGE_HEADINGS
77
+ )
78
+
79
+
80
+ def present_sections(text: str) -> set[str]:
81
+ """Canonical keys of the recognized `##` sections present in the text."""
82
+ present: set[str] = set()
83
+ for raw in text.splitlines():
84
+ heading = HEADING_RE.match(raw.strip())
85
+ if not heading:
86
+ continue
87
+ section = canonical_section(heading.group(1))
88
+ if section is not None:
89
+ present.add(section)
90
+ return present
91
+
92
+
93
+ def acceptance_section_lines(text: str) -> list[str]:
94
+ """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
@@ -17,23 +17,30 @@ 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
+ )
30
+
20
31
  GOAL_TRUNCATE = 300
21
32
  VERIFY_COMMAND_TRUNCATE = 120
22
33
  VERIFY_COMMAND_MAX = 10
23
34
  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
35
  REJECTED_RE = re.compile(r"^\s*-\s*\*\*(.+?)\*\*")
29
- _HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
30
36
 
31
37
 
32
38
  def parse_decision_anchor(text: str) -> dict[str, Any]:
33
39
  """Extract goal / acceptance criteria / rejected option names / declared
34
40
  verification commands / scope boundary from a decision-anchor.md body.
35
- Section format is frozen by spec/contracts/decision-anchor.md; parsing is
36
- line-level and degrades to empty values when sections are missing."""
41
+ Section headings may be the Chinese set or the English aliases (see
42
+ spec/contracts/decision-anchor.md); parsing is line-level and degrades to
43
+ empty values when sections are missing."""
37
44
  goal_lines: list[str] = []
38
45
  acceptance: list[dict[str, str]] = []
39
46
  rejected: list[str] = []
@@ -41,15 +48,14 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
41
48
  scope_boundary = ""
42
49
  section: str | None = None
43
50
  for raw in text.splitlines():
44
- heading = _HEADING_RE.match(raw.strip())
51
+ heading = HEADING_RE.match(raw.strip())
45
52
  if heading:
46
- name = heading.group(1)
47
- section = name if name in ANCHOR_SECTIONS else None
53
+ section = canonical_section(heading.group(1))
48
54
  continue
49
- if section == "目标":
55
+ if section == GOAL:
50
56
  if raw.strip():
51
57
  goal_lines.append(raw.strip())
52
- elif section == "验收标准":
58
+ elif section == ACCEPTANCE:
53
59
  match = ACCEPTANCE_RE.match(raw)
54
60
  if match:
55
61
  acceptance.append(
@@ -59,17 +65,17 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
59
65
  "checked": match.group(1) in ("x", "X"),
60
66
  }
61
67
  )
62
- elif section == "被拒方案":
68
+ elif section == REJECTED:
63
69
  match = REJECTED_RE.match(raw)
64
70
  if match:
65
71
  rejected.append(match.group(1).strip())
66
- elif section == "验证命令":
72
+ elif section == VERIFICATION:
67
73
  command = raw.strip()
68
74
  if command.startswith("- "):
69
75
  command = command[2:].strip()
70
76
  if command and len(verify_commands) < VERIFY_COMMAND_MAX:
71
77
  verify_commands.append(command[:VERIFY_COMMAND_TRUNCATE])
72
- elif section == "范围边界":
78
+ elif section == SCOPE:
73
79
  if raw.strip() and not scope_boundary:
74
80
  scope_boundary = raw.strip()[:SCOPE_BOUNDARY_TRUNCATE]
75
81
  return {
@@ -32,6 +32,11 @@ from services.context_paths import (
32
32
  normalize_context_path,
33
33
  prepare_context_entry,
34
34
  )
35
+ from services.decision_anchor_schema import (
36
+ REQUIRED_SECTIONS,
37
+ present_sections,
38
+ section_headings,
39
+ )
35
40
  from services.plan_binding import normalize_plan_file, plan_file_blockers
36
41
  from services.task_gate_profiles import (
37
42
  GATE_PROFILE_STRICT,
@@ -360,13 +365,14 @@ class TaskContextService:
360
365
 
361
366
  @staticmethod
362
367
  def _decision_anchor_section_blockers(anchor_text: str) -> list[str]:
363
- blockers: list[str] = []
364
- for section in ("## 目标", "## 验收标准"):
365
- if section not in anchor_text:
366
- blockers.append(
367
- f"decision-anchor.md missing required section: {section}"
368
- )
369
- return blockers
368
+ present = present_sections(anchor_text)
369
+ return [
370
+ "decision-anchor.md missing required section: "
371
+ + " (or ".join(f"## {heading}" for heading in section_headings(section))
372
+ + ")"
373
+ for section in REQUIRED_SECTIONS
374
+ if section not in present
375
+ ]
370
376
 
371
377
  @staticmethod
372
378
  def _is_tiny_task(task_data: dict) -> bool: