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.
Files changed (63) hide show
  1. package/CHANGELOG.md +31 -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 +228 -0
  39. package/template/.cowork-flow/scripts/services/fact_view.py +27 -15
  40. package/template/.cowork-flow/scripts/services/plan_binding.py +34 -0
  41. package/template/.cowork-flow/scripts/services/plan_schema.py +182 -0
  42. package/template/.cowork-flow/scripts/services/task_context.py +84 -12
  43. package/template/.cowork-flow/scripts/services/task_creation.py +89 -30
  44. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
  45. package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
  46. package/template/.cowork-flow/spec/contracts/decision-anchor.md +35 -19
  47. package/template/.cowork-flow/spec/contracts/plan-binding.md +27 -4
  48. package/template/.cowork-flow/spec/index.md +26 -26
  49. package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
  50. package/template/.cowork-flow/spec/references/index.md +16 -16
  51. package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
  52. package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
  53. package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
  54. package/template/.cowork-flow/spec/runtime/contract-registry.json +1 -0
  55. package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
  56. package/template/.cowork-flow/spec/runtime/index.md +10 -12
  57. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
  58. package/template/.cowork-flow/spec/schemas/index.md +5 -5
  59. package/template/.cowork-flow/templates/plan-template.md +91 -0
  60. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +169 -0
  61. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
  62. package/template/skills/runtime-health/scripts/doctor.py +110 -0
  63. package/template/skills/task-planning/SKILL.md +21 -94
@@ -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.1
@@ -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,228 @@
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
+ SPEC_CHECK_LABELS: dict[str, str] = {
27
+ "zh-CN": "规范核对:",
28
+ "en-US": "Spec check:",
29
+ }
30
+
31
+ LANGUAGE_HEADINGS: dict[str, dict[str, str]] = {
32
+ "zh-CN": {
33
+ GOAL: "目标",
34
+ ACCEPTANCE: "验收标准",
35
+ REJECTED: "被拒方案",
36
+ ASSUMPTIONS: "关键假设",
37
+ SCOPE: "范围边界",
38
+ VERIFICATION: "验证命令",
39
+ },
40
+ "en-US": {
41
+ GOAL: "Goal",
42
+ ACCEPTANCE: "Acceptance Criteria",
43
+ REJECTED: "Rejected Options",
44
+ ASSUMPTIONS: "Key Assumptions",
45
+ SCOPE: "Scope Boundary",
46
+ VERIFICATION: "Verification Commands",
47
+ },
48
+ }
49
+
50
+ SECTION_ALIASES: dict[str, str] = {
51
+ heading: section
52
+ for headings in LANGUAGE_HEADINGS.values()
53
+ for section, heading in headings.items()
54
+ }
55
+
56
+ HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
57
+ ACCEPTANCE_RE = re.compile(
58
+ r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
59
+ )
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"[<<][^>>]*[>>]")
65
+
66
+
67
+ def canonical_section(heading: str) -> str | None:
68
+ """Canonical section key for a recognized heading, or None."""
69
+ return SECTION_ALIASES.get(heading)
70
+
71
+
72
+ def headings_for(language: str) -> dict[str, str]:
73
+ """Heading set for a language, falling back to the default language."""
74
+ return LANGUAGE_HEADINGS.get(language, LANGUAGE_HEADINGS[DEFAULT_LANGUAGE])
75
+
76
+
77
+ def heading_for(section: str, language: str) -> str:
78
+ """One heading in the requested language, falling back to the default."""
79
+ return headings_for(language)[section]
80
+
81
+
82
+ def section_headings(section: str) -> tuple[str, ...]:
83
+ """Every recognized heading for one section, default language first."""
84
+ return tuple(
85
+ LANGUAGE_HEADINGS[language][section] for language in LANGUAGE_HEADINGS
86
+ )
87
+
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
+
213
+ def present_sections(text: str) -> set[str]:
214
+ """Canonical keys of the recognized `##` sections present in the text."""
215
+ present: set[str] = set()
216
+ for raw in text.splitlines():
217
+ heading = HEADING_RE.match(raw.strip())
218
+ if not heading:
219
+ continue
220
+ section = canonical_section(heading.group(1))
221
+ if section is not None:
222
+ present.add(section)
223
+ return present
224
+
225
+
226
+ def acceptance_section_lines(text: str) -> list[str]:
227
+ """Raw lines under the acceptance heading, up to the next heading."""
228
+ return section_lines(text, ACCEPTANCE)