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
@@ -17,6 +17,14 @@ import {
17
17
  platformLabel
18
18
  } from '../lib/platforms.js';
19
19
  import { parseFlags, UsageError } from '../lib/cli-flags.js';
20
+ import {
21
+ DEFAULT_LANGUAGE,
22
+ SUPPORTED_LANGUAGES,
23
+ normalizeLanguage,
24
+ readProjectLanguage,
25
+ withLanguage
26
+ } from '../lib/language.js';
27
+ import { templateRoot } from '../lib/paths.js';
20
28
 
21
29
  const execFileAsync = promisify(execFile);
22
30
 
@@ -34,7 +42,7 @@ function parsePlatformSelectionOrUsage(values) {
34
42
  function parseInitArgs(args) {
35
43
  const { flags, positionals } = parseFlags(args, {
36
44
  boolean: ['--dry-run', '--force'],
37
- value: ['--host', '--platform', '--platforms', '--developer'],
45
+ value: ['--host', '--platform', '--platforms', '--developer', '--language'],
38
46
  repeatable: ['--host', '--platform', '--platforms'],
39
47
  positional: { min: 0, max: 1, name: '[target]' }
40
48
  });
@@ -42,6 +50,7 @@ function parseInitArgs(args) {
42
50
  developer: flags['--developer'] ?? null,
43
51
  dryRun: Boolean(flags['--dry-run']),
44
52
  force: Boolean(flags['--force']),
53
+ language: flags['--language'] ?? null,
45
54
  platforms: [
46
55
  ...(flags['--host'] ?? []),
47
56
  ...(flags['--platform'] ?? []),
@@ -165,6 +174,104 @@ async function resolvePlatforms(options, selectPlatforms) {
165
174
  return parsePlatformSelectionOrUsage(selected);
166
175
  }
167
176
 
177
+ function normalizeLanguageOrUsage(value) {
178
+ const language = normalizeLanguage(value);
179
+ if (!language) {
180
+ throw new UsageError(
181
+ `Unsupported language: ${value}. Supported: ${SUPPORTED_LANGUAGES.join(', ')}`
182
+ );
183
+ }
184
+ return language;
185
+ }
186
+
187
+ // An existing config is authoritative: the choice is never re-prompted or
188
+ // rewritten, even by --force. A missing config prompts once, with zh-CN as the
189
+ // default for both non-interactive and dry-run runs.
190
+ async function resolveLanguage(options, prompt) {
191
+ const requested = options.language
192
+ ? normalizeLanguageOrUsage(options.language)
193
+ : null;
194
+ const existing = await readProjectLanguage(options.target);
195
+ if (existing.present) {
196
+ return {
197
+ existing: true,
198
+ configured: existing.configured,
199
+ language: existing.language,
200
+ valid: existing.valid,
201
+ raw: existing.raw,
202
+ requested,
203
+ source: 'existing'
204
+ };
205
+ }
206
+
207
+ if (requested) {
208
+ return {
209
+ existing: false,
210
+ language: requested,
211
+ requested,
212
+ source: 'flag'
213
+ };
214
+ }
215
+
216
+ if (options.dryRun || typeof prompt !== 'function') {
217
+ return {
218
+ existing: false,
219
+ language: DEFAULT_LANGUAGE,
220
+ requested,
221
+ source: 'default'
222
+ };
223
+ }
224
+
225
+ const answer = String(
226
+ await prompt(`Project language (${SUPPORTED_LANGUAGES.join('/')}) [${DEFAULT_LANGUAGE}]: `) ?? ''
227
+ ).trim();
228
+ return {
229
+ existing: false,
230
+ language: normalizeLanguageOrUsage(answer || DEFAULT_LANGUAGE),
231
+ requested,
232
+ source: answer ? 'prompt' : 'default'
233
+ };
234
+ }
235
+
236
+ function emitLanguageWarnings(io, language) {
237
+ if (!language.existing) {
238
+ return;
239
+ }
240
+ if (!language.valid) {
241
+ io.writeErr(
242
+ `Warning: unsupported language ${JSON.stringify(language.raw)} in .cowork-flow/config.yaml; using ${language.language}. Edit the file to change it.\n`
243
+ );
244
+ return;
245
+ }
246
+ if (language.requested && !language.configured) {
247
+ io.writeErr(
248
+ `Warning: .cowork-flow/config.yaml has no language setting; using ${language.language}. Add language: ${language.requested} and run cwf project sync to switch.\n`
249
+ );
250
+ return;
251
+ }
252
+ if (language.requested && language.requested !== language.language) {
253
+ io.writeErr(
254
+ `Warning: existing .cowork-flow/config.yaml language ${language.language} wins over --language ${language.requested}.\n`
255
+ );
256
+ }
257
+ }
258
+
259
+ async function buildConfigActions(target, language, existing) {
260
+ if (existing) {
261
+ return [];
262
+ }
263
+ const source = join(templateRoot, '.cowork-flow', 'config.yaml');
264
+ const content = withLanguage(await readFile(source, 'utf8'), language);
265
+ return [{
266
+ action: 'create',
267
+ source,
268
+ destination: join(target, '.cowork-flow', 'config.yaml'),
269
+ relativePath: '.cowork-flow/config.yaml',
270
+ content,
271
+ targetExists: false
272
+ }];
273
+ }
274
+
168
275
 
169
276
  async function buildDeveloperActions(target, developer) {
170
277
  const workflowDir = join(target, '.cowork-flow');
@@ -195,14 +302,22 @@ export async function runInitWithOptions(
195
302
  ) {
196
303
  const options = parseInitArgs(args);
197
304
  const platforms = await resolvePlatforms(options, selectPlatforms);
305
+ const language = await resolveLanguage(options, prompt);
306
+ emitLanguageWarnings(io, language);
198
307
  const developer = await resolveDeveloperName(options, prompt);
199
308
  const packageInfo = await readPackageInfo();
200
309
  const developerActions = developer.name
201
310
  ? await buildDeveloperActions(options.target, developer)
202
311
  : [];
312
+ const configActions = await buildConfigActions(
313
+ options.target,
314
+ language.language,
315
+ language.existing
316
+ );
203
317
  const plan = await buildInitPlan(options.target, {
204
- additionalActions: developerActions,
318
+ additionalActions: [...developerActions, ...configActions],
205
319
  force: options.force,
320
+ language: language.language,
206
321
  platforms,
207
322
  version: packageInfo.version
208
323
  });
@@ -220,6 +335,11 @@ export async function runInitWithOptions(
220
335
  } else {
221
336
  io.writeOut('dry-run would-skip=.cowork-flow/.developer (no developer identity; pass --developer)\n');
222
337
  }
338
+ if (language.existing) {
339
+ io.writeOut(`dry-run preserve-existing=.cowork-flow/config.yaml for language ${language.language}\n`);
340
+ } else {
341
+ io.writeOut(`dry-run would-create=.cowork-flow/config.yaml for language ${language.language}\n`);
342
+ }
223
343
  io.writeOut('Next: update AGENTS.md and .cowork-flow/config.yaml for this project.\n');
224
344
  return 0;
225
345
  }
@@ -230,6 +350,13 @@ export async function runInitWithOptions(
230
350
  const source = developer.source === 'git' ? ' (from git user.name)' : '';
231
351
  io.writeOut(`Developer initialized: ${developer.name}${source}\n`);
232
352
  }
353
+ if (language.existing) {
354
+ const missing = language.configured ? '' : ' (default; no language key)';
355
+ io.writeOut(`Language already initialized: ${language.language}${missing}\n`);
356
+ } else {
357
+ const source = language.source === 'default' ? ' (default)' : '';
358
+ io.writeOut(`Language initialized: ${language.language}${source}\n`);
359
+ }
233
360
  io.writeOut('Next: update AGENTS.md and .cowork-flow/config.yaml for this project.\n');
234
361
  return 0;
235
362
  }
@@ -51,18 +51,20 @@ export const COMMANDS = [
51
51
  {
52
52
  path: ['project', 'init'],
53
53
  summary: 'Copy the cowork-flow template into a project',
54
- usage: `project init [target] --host <${HOST_TOKENS}> [--developer <name>] [--dry-run] [--force]`,
54
+ usage: `project init [target] --host <${HOST_TOKENS}> [--developer <name>] [--language <zh-CN|en-US>] [--dry-run] [--force]`,
55
55
  flags: [
56
56
  { name: '--host', kind: 'value', valueName: 'host', repeatable: true, description: 'Host to set the project up for; repeatable and comma-separated, auto-detected when omitted' },
57
57
  { name: '--platform', kind: 'value', valueName: 'host', repeatable: true, description: 'Alias of --host' },
58
58
  { name: '--platforms', kind: 'value', valueName: 'host', repeatable: true, description: 'Alias of --host' },
59
59
  { name: '--developer', kind: 'value', valueName: 'name', description: 'Developer name recorded in .cowork-flow/.developer; falls back to git user.name' },
60
+ { name: '--language', kind: 'value', valueName: 'language', description: 'Output language for task and plan artifacts; accepts zh-CN or en-US (default: zh-CN)' },
60
61
  flag('--dry-run'),
61
62
  flag('--force')
62
63
  ],
63
64
  positional: { min: 0, max: 1, name: '[target]' },
64
65
  examples: [
65
66
  'cwf project init --host codex --developer alice',
67
+ 'cwf project init --host codex --developer alice --language en-US',
66
68
  'cwf project init ./my-project --host all --dry-run'
67
69
  ],
68
70
  run: (args, context) => runInit(args, context)
@@ -9,6 +9,7 @@ import {
9
9
  formatReadinessReport,
10
10
  summarizePlan
11
11
  } from '../lib/copy-template.js';
12
+ import { readProjectLanguageStrict } from '../lib/language.js';
12
13
 
13
14
  import { parseFlags } from '../lib/cli-flags.js';
14
15
 
@@ -25,7 +26,10 @@ function parseSourceRefreshArgs(args) {
25
26
 
26
27
  export async function runSourceRefresh(args, { io, fileSystem } = {}) {
27
28
  const options = parseSourceRefreshArgs(args);
28
- const plan = await buildSourceCheckoutRefreshPlan(options.target);
29
+ const language = await readProjectLanguageStrict(options.target);
30
+ const plan = await buildSourceCheckoutRefreshPlan(options.target, {
31
+ language: language.language
32
+ });
29
33
 
30
34
  await applyPlan(plan, { dryRun: options.dryRun, fileSystem });
31
35
  io.writeOut(summarizePlan(plan, options.dryRun));
@@ -12,6 +12,7 @@ import {
12
12
  } from '../lib/copy-template.js';
13
13
  import { readPackageInfo } from '../lib/package-info.js';
14
14
  import { formatPlatformList } from '../lib/platforms.js';
15
+ import { readProjectLanguageStrict } from '../lib/language.js';
15
16
  import { parseFlags } from '../lib/cli-flags.js';
16
17
 
17
18
  function parseSyncArgs(args) {
@@ -29,9 +30,11 @@ function parseSyncArgs(args) {
29
30
  export async function runSync(args, { io, fileSystem }) {
30
31
  const options = parseSyncArgs(args);
31
32
  const packageInfo = await readPackageInfo();
33
+ const language = await readProjectLanguageStrict(options.target);
32
34
  const platforms = await detectInstalledPlatforms(options.target);
33
35
  const plan = await buildSyncPlan(options.target, {
34
36
  force: options.force,
37
+ language: language.language,
35
38
  platforms,
36
39
  version: packageInfo.version
37
40
  });
@@ -153,13 +153,17 @@ async function appendSkillReadRootActions(actions, {
153
153
  }
154
154
  }
155
155
 
156
+ // The source checkout's `.cowork-flow/spec` tree is a mirror of the template,
157
+ // not a downstream project's spec tree: project-owned directories are framework
158
+ // content here too, so dev refresh rewrites every spec directory and keeps only
159
+ // task/plan state and the project config protected.
156
160
  function isSourceCheckoutProtected(relativePath) {
157
161
  const normalized = toTemplatePath(relativePath);
158
162
  if (hostRegistry.syncPolicy.protectedFiles.includes(normalized)) {
159
163
  return true;
160
164
  }
161
165
  return hostRegistry.syncPolicy.protectedPrefixes
162
- .filter((prefix) => prefix !== '.cowork-flow/spec/')
166
+ .filter((prefix) => !prefix.startsWith('.cowork-flow/spec/'))
163
167
  .some((prefix) => normalized.startsWith(prefix))
164
168
  || normalized.startsWith('.cowork-flow/.runtime/');
165
169
  }
@@ -196,12 +200,16 @@ export async function buildInitPlan(targetDir, options = {}) {
196
200
  continue;
197
201
  }
198
202
 
199
- const source = join(templateRoot, file);
203
+ const source = join(
204
+ templateRoot,
205
+ ...hostRegistry.localizedSource(templatePath, options.language).split('/')
206
+ );
200
207
  const destination = join(targetDir, file);
201
208
  seen.add(destination);
202
209
  const exists = await pathExists(destination);
210
+ const forceProtected = hostRegistry.isForceProtectedSyncFile(templatePath);
203
211
  const action = exists
204
- ? (options.force && templatePath !== '.cowork-flow/.developer' ? 'update' : 'skip')
212
+ ? (forceProtected ? 'protected' : (options.force && templatePath !== '.cowork-flow/.developer' ? 'update' : 'skip'))
205
213
  : 'create';
206
214
  actions.push({ action, source, destination, relativePath: file });
207
215
  }
@@ -448,7 +456,10 @@ export async function buildSourceCheckoutRefreshPlan(targetDir, options = {}) {
448
456
  if (!shouldIncludeSourceCheckoutRuntimeFile(templatePath)) {
449
457
  continue;
450
458
  }
451
- const source = join(templateRoot, file);
459
+ const source = join(
460
+ templateRoot,
461
+ ...hostRegistry.localizedSource(templatePath, options.language).split('/')
462
+ );
452
463
  const destination = join(targetDir, file);
453
464
  seen.add(destination);
454
465
  const exists = await pathExists(destination);
@@ -508,12 +519,19 @@ async function planSyncFile(file, { targetDir, options, platforms, seen }) {
508
519
  return null;
509
520
  }
510
521
 
511
- const source = join(templateRoot, file);
522
+ const source = join(
523
+ templateRoot,
524
+ ...hostRegistry.localizedSource(file, options.language).split('/')
525
+ );
512
526
  const exists = await pathExists(destination);
513
527
  if (hostRegistry.isManagedBlockFile(file)) {
514
528
  return await buildManagedBlockSyncAction({ source, destination, exists, options, relativePath: file });
515
529
  }
516
530
 
531
+ if (exists && hostRegistry.isForceProtectedSyncFile(file)) {
532
+ return { action: 'protected', source, destination, relativePath: file };
533
+ }
534
+
517
535
  const protectedFile = hostRegistry.isProtectedSyncFile(file) && !options.force && exists;
518
536
  const safeFile = hostRegistry.isSafeSyncFile(file);
519
537
 
@@ -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
  };
@@ -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}`