cowork-flow 2.2.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 (61) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/README.en.md +46 -22
  3. package/README.md +10 -7
  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 +5 -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 +8 -2
  17. package/presets/codex/.codex-plugin/plugin.json +1 -1
  18. package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +8 -2
  19. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  20. package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +8 -2
  21. package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +8 -2
  22. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  23. package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +8 -2
  24. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  25. package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +8 -2
  26. package/src/cli.js +1 -1
  27. package/src/commands/init.js +200 -16
  28. package/src/commands/registry.js +10 -7
  29. package/src/commands/source-refresh.js +5 -1
  30. package/src/commands/sync.js +4 -1
  31. package/src/lib/copy-template.js +23 -5
  32. package/src/lib/host-assets.js +119 -4
  33. package/src/lib/language.js +162 -0
  34. package/template/.cowork-flow/.version +1 -1
  35. package/template/.cowork-flow/config.yaml +8 -0
  36. package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
  37. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
  38. package/template/.cowork-flow/scripts/infra/config.py +85 -0
  39. package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +104 -0
  40. package/template/.cowork-flow/scripts/services/fact_view.py +21 -15
  41. package/template/.cowork-flow/scripts/services/task_context.py +13 -7
  42. package/template/.cowork-flow/scripts/services/task_creation.py +72 -30
  43. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
  44. package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
  45. package/template/.cowork-flow/spec/contracts/decision-anchor.md +21 -14
  46. package/template/.cowork-flow/spec/contracts/plan-binding.md +2 -1
  47. package/template/.cowork-flow/spec/index.md +26 -26
  48. package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
  49. package/template/.cowork-flow/spec/references/index.md +16 -16
  50. package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
  51. package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
  52. package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
  53. package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
  54. package/template/.cowork-flow/spec/runtime/index.md +10 -12
  55. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
  56. package/template/.cowork-flow/spec/schemas/index.md +5 -5
  57. package/template/.cowork-flow/templates/plan-template.md +91 -0
  58. package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +160 -0
  59. package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
  60. package/template/skills/runtime-health/scripts/doctor.py +110 -0
  61. package/template/skills/task-planning/SKILL.md +19 -92
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { packageRoot, templateRoot } from './paths.js';
@@ -37,6 +37,7 @@ const MANIFEST_KEYS = [
37
37
  'capabilityMatrix',
38
38
  'platforms',
39
39
  'excludedPrefixes',
40
+ 'localization',
40
41
  'syncPolicy'
41
42
  ];
42
43
  const PLATFORM_KEYS = [
@@ -57,6 +58,7 @@ const SKILL_DISCOVERY_KEYS = ['scope', 'evidence', 'path', 'gates'];
57
58
  const SKILL_DISCOVERY_SCOPES = ['project'];
58
59
  const SYNC_POLICY_KEYS = [
59
60
  'protectedFiles',
61
+ 'forceProtectedFiles',
60
62
  'protectedPrefixes',
61
63
  'safeFiles',
62
64
  'safePrefixes',
@@ -101,6 +103,7 @@ function aliasIndex(platforms) {
101
103
  function normalizedSyncPolicy(policy) {
102
104
  return {
103
105
  protectedFiles: policy.protectedFiles.map(normalizePath),
106
+ forceProtectedFiles: (policy.forceProtectedFiles ?? []).map(normalizePath),
104
107
  protectedPrefixes: policy.protectedPrefixes.map(normalizePath),
105
108
  safeFiles: policy.safeFiles.map(normalizePath),
106
109
  safePrefixes: policy.safePrefixes.map(normalizePath),
@@ -110,6 +113,27 @@ function normalizedSyncPolicy(policy) {
110
113
  }
111
114
 
112
115
 
116
+ // A manifest without localization keeps every asset in the base template, which
117
+ // is exactly the pre-localization behaviour.
118
+ function normalizedLocalization(localization) {
119
+ const raw = localization ?? {
120
+ default: 'zh-CN',
121
+ supported: ['zh-CN', 'en-US'],
122
+ assets: {}
123
+ };
124
+ return {
125
+ default: raw.default,
126
+ supported: [...raw.supported],
127
+ assets: Object.fromEntries(
128
+ Object.entries(raw.assets).map(([language, assets]) => [
129
+ language,
130
+ assets.map(normalizePath)
131
+ ])
132
+ )
133
+ };
134
+ }
135
+
136
+
113
137
  // Every lookup a registry closes over, built after the
114
138
  // manifest passes validation. The order here is the order
115
139
  // init/sync observe.
@@ -120,6 +144,7 @@ function buildHostIndex(manifest) {
120
144
  const aliases = aliasIndex(platforms);
121
145
  const platformIds = platforms.map((platform) => platform.id);
122
146
  const capabilityMatrix = normalizeCapabilityMatrix(manifest.capabilityMatrix);
147
+ const localization = normalizedLocalization(manifest.localization);
123
148
  const syncPolicy = normalizedSyncPolicy(manifest.syncPolicy);
124
149
  const assetPrefixes = unique(
125
150
  platforms.flatMap((platform) => platform.assetPrefixes)
@@ -140,10 +165,12 @@ function buildHostIndex(manifest) {
140
165
  aliases,
141
166
  platformIds,
142
167
  capabilityMatrix,
168
+ localization,
143
169
  syncPolicy,
144
170
  assetPrefixes,
145
171
  skillReadRoots,
146
172
  protectedFiles: new Set(syncPolicy.protectedFiles),
173
+ forceProtectedFiles: new Set(syncPolicy.forceProtectedFiles),
147
174
  safeFiles: new Set(syncPolicy.safeFiles),
148
175
  managedBlockFiles: new Set(syncPolicy.managedBlockFiles)
149
176
  };
@@ -172,7 +199,7 @@ function selectionRules(index) {
172
199
  const platformId = aliases.get(token);
173
200
  if (!platformId) {
174
201
  throw new Error(
175
- `Unsupported platform: ${token}. Supported platforms: ${platformIds.join(', ')}`
202
+ `Unsupported host: ${token}. Supported hosts: ${platformIds.join(', ')}`
176
203
  );
177
204
  }
178
205
  selected.add(platformId);
@@ -180,7 +207,7 @@ function selectionRules(index) {
180
207
  }
181
208
  if (selected.size === 0) {
182
209
  throw new Error(
183
- `Platform selection required. Run: cwf project init <target> --platform ${platformIds.join('|')}`
210
+ `Host selection required. Run: cwf project init [target] --host ${platformIds.join('|')}`
184
211
  );
185
212
  }
186
213
  return platformIds.filter((platformId) => selected.has(platformId));
@@ -226,6 +253,7 @@ function syncRules(index) {
226
253
  const {
227
254
  syncPolicy,
228
255
  protectedFiles,
256
+ forceProtectedFiles,
229
257
  safeFiles,
230
258
  managedBlockFiles,
231
259
  assetPrefixes,
@@ -250,6 +278,12 @@ function syncRules(index) {
250
278
  || syncPolicy.protectedPrefixes.some((prefix) => normalized.startsWith(prefix));
251
279
  }
252
280
 
281
+ // A force-protected file is created once and never replaced, even by
282
+ // `init --force` or `sync --force`.
283
+ function isForceProtectedSyncFile(relativePath) {
284
+ return forceProtectedFiles.has(normalizePath(relativePath));
285
+ }
286
+
253
287
  function isManagedBlockFile(relativePath) {
254
288
  return managedBlockFiles.has(normalizePath(relativePath));
255
289
  }
@@ -267,7 +301,13 @@ function syncRules(index) {
267
301
  });
268
302
  }
269
303
 
270
- return { isSafeSyncFile, isProtectedSyncFile, isManagedBlockFile, obsoleteSyncFiles };
304
+ return {
305
+ isSafeSyncFile,
306
+ isProtectedSyncFile,
307
+ isForceProtectedSyncFile,
308
+ isManagedBlockFile,
309
+ obsoleteSyncFiles
310
+ };
271
311
  }
272
312
 
273
313
 
@@ -298,6 +338,7 @@ export function createHostRegistry(manifest) {
298
338
  platforms: index.platforms,
299
339
  platformIds: index.platformIds,
300
340
  capabilityMatrix: index.capabilityMatrix,
341
+ localization: index.localization,
301
342
  syncPolicy: index.syncPolicy,
302
343
  assetPrefixes: index.assetPrefixes,
303
344
  skillReadRoots: index.skillReadRoots,
@@ -323,6 +364,27 @@ export function createHostRegistry(manifest) {
323
364
  skillDestination(platformId) {
324
365
  return index.byId.get(platformId)?.skillReadRoot ?? null;
325
366
  },
367
+ // Canonical project-relative path for the asset's source. Non-default
368
+ // languages resolve only declared assets; everything else stays on the
369
+ // base template.
370
+ localizedSource(relativePath, language) {
371
+ const normalized = normalizePath(relativePath);
372
+ const selected = language ?? index.localization.default;
373
+ if (!index.localization.supported.includes(selected)) {
374
+ throw new Error(
375
+ `Unsupported language: ${selected}. Supported: ${index.localization.supported.join(', ')}`
376
+ );
377
+ }
378
+ const assets = index.localization.assets[selected] ?? [];
379
+ if (!assets.includes(normalized)) {
380
+ return normalized;
381
+ }
382
+ const localized = `locales/${selected}/${normalized}`;
383
+ if (!existsSync(join(templateRoot, ...localized.split('/')))) {
384
+ throw new Error(`Missing localized asset: ${localized}`);
385
+ }
386
+ return localized;
387
+ },
326
388
  // A declaration names a directory inside the package;
327
389
  // the absolute source dir is resolved once, here.
328
390
  platformPayload(platformId) {
@@ -337,6 +399,7 @@ export function createHostRegistry(manifest) {
337
399
  },
338
400
  isSafeSyncFile: sync.isSafeSyncFile,
339
401
  isProtectedSyncFile: sync.isProtectedSyncFile,
402
+ isForceProtectedSyncFile: sync.isForceProtectedSyncFile,
340
403
  isManagedBlockFile: sync.isManagedBlockFile,
341
404
  obsoleteSyncFiles: sync.obsoleteSyncFiles
342
405
  };
@@ -378,10 +441,59 @@ function validateManifest(manifest) {
378
441
  manifest.excludedPrefixes,
379
442
  'Host Asset Manifest excludedPrefixes'
380
443
  );
444
+ validateLocalization(manifest.localization);
381
445
  validateCapabilityMatrix(manifest.capabilityMatrix, allowed, platformIds);
382
446
  validateSyncPolicy(manifest.syncPolicy);
383
447
  }
384
448
 
449
+ function validateLocalization(localization) {
450
+ if (localization === undefined) {
451
+ return;
452
+ }
453
+ if (!localization || typeof localization !== 'object' || Array.isArray(localization)) {
454
+ throw new Error('Host Asset Manifest localization must be an object');
455
+ }
456
+ assertKnownKeys(
457
+ localization,
458
+ ['default', 'supported', 'assets'],
459
+ 'Host Asset Manifest localization'
460
+ );
461
+ validateRequiredString(
462
+ localization.default,
463
+ 'Host Asset Manifest localization.default'
464
+ );
465
+ validateStringArray(
466
+ localization.supported,
467
+ 'Host Asset Manifest localization.supported',
468
+ { unique: true }
469
+ );
470
+ if (!localization.supported.includes(localization.default)) {
471
+ throw new Error(
472
+ 'Host Asset Manifest localization.default must be listed in localization.supported'
473
+ );
474
+ }
475
+ if (!localization.assets || typeof localization.assets !== 'object' || Array.isArray(localization.assets)) {
476
+ throw new Error('Host Asset Manifest localization.assets must be an object');
477
+ }
478
+ for (const [language, assets] of Object.entries(localization.assets)) {
479
+ if (!localization.supported.includes(language)) {
480
+ throw new Error(
481
+ `Host Asset Manifest localization.assets language is not supported: ${language}`
482
+ );
483
+ }
484
+ if (language === localization.default) {
485
+ throw new Error(
486
+ 'Host Asset Manifest localization.assets must not override the default language'
487
+ );
488
+ }
489
+ validateStringArray(
490
+ assets,
491
+ `Host Asset Manifest localization.assets.${language}`,
492
+ { unique: true }
493
+ );
494
+ }
495
+ }
496
+
385
497
  function validateCapabilityValues(values) {
386
498
  validateStringArray(
387
499
  values,
@@ -515,6 +627,9 @@ function validateSyncPolicy(syncPolicy) {
515
627
  }
516
628
  assertKnownKeys(syncPolicy, SYNC_POLICY_KEYS, 'Host Asset Manifest syncPolicy');
517
629
  for (const key of SYNC_POLICY_KEYS) {
630
+ if (key === 'forceProtectedFiles' && syncPolicy[key] === undefined) {
631
+ continue;
632
+ }
518
633
  validateStringArray(
519
634
  syncPolicy[key],
520
635
  `Host Asset Manifest syncPolicy.${key}`
@@ -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.2.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."""