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.
- package/CHANGELOG.md +33 -1
- package/README.en.md +46 -22
- package/README.md +10 -7
- package/docs/architecture.en.md +95 -0
- package/docs/architecture.md +7 -3
- package/docs/direction.en.md +71 -0
- package/docs/direction.md +6 -1
- package/docs/hosts.en.md +299 -0
- package/docs/hosts.md +5 -1
- package/docs/index.en.md +30 -0
- package/docs/index.md +2 -0
- package/docs/release.en.md +108 -0
- package/docs/release.md +2 -0
- package/package.json +2 -2
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/claude-code/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/codex/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/kimi-code/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/presets/opencode/cowork-flow/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/presets/zcode/skills/cowork-flow-bootstrap/SKILL.md +8 -2
- package/src/cli.js +1 -1
- package/src/commands/init.js +200 -16
- package/src/commands/registry.js +10 -7
- package/src/commands/source-refresh.js +5 -1
- package/src/commands/sync.js +4 -1
- package/src/lib/copy-template.js +23 -5
- package/src/lib/host-assets.js +119 -4
- package/src/lib/language.js +162 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +8 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_create_command.py +5 -3
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +71 -2
- package/template/.cowork-flow/scripts/infra/config.py +85 -0
- package/template/.cowork-flow/scripts/services/decision_anchor_schema.py +104 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +21 -15
- package/template/.cowork-flow/scripts/services/task_context.py +13 -7
- package/template/.cowork-flow/scripts/services/task_creation.py +72 -30
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +7 -24
- package/template/.cowork-flow/spec/contracts/context-injection.md +5 -4
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +21 -14
- package/template/.cowork-flow/spec/contracts/plan-binding.md +2 -1
- package/template/.cowork-flow/spec/index.md +26 -26
- package/template/.cowork-flow/spec/references/definition-of-done.md +26 -26
- package/template/.cowork-flow/spec/references/index.md +16 -16
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +71 -71
- package/template/.cowork-flow/spec/references/security-checklist.md +37 -37
- package/template/.cowork-flow/spec/references/testing-checklist.md +32 -32
- package/template/.cowork-flow/spec/runtime/host-assets.json +23 -4
- package/template/.cowork-flow/spec/runtime/index.md +10 -12
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +31 -0
- package/template/.cowork-flow/spec/schemas/index.md +5 -5
- package/template/.cowork-flow/templates/plan-template.md +91 -0
- package/template/locales/en-US/.cowork-flow/spec/contracts/decision-anchor.md +160 -0
- package/template/locales/en-US/.cowork-flow/templates/plan-template.md +91 -0
- package/template/skills/runtime-health/scripts/doctor.py +110 -0
- package/template/skills/task-planning/SKILL.md +19 -92
package/src/lib/host-assets.js
CHANGED
|
@@ -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
|
|
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
|
-
`
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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."""
|