cowork-flow 0.0.48 → 0.0.50

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 (44) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/SKILL.md +4 -3
  42. package/template/skills/task-review/scripts/review_check.py +135 -2
  43. package/template/skills/test-first/SKILL.md +18 -3
  44. package/template/skills/test-first/manifest.json +8 -4
package/README.md CHANGED
@@ -46,6 +46,12 @@ cowork-flow sync ./my-project --dry-run
46
46
  # 同步已初始化项目
47
47
  cowork-flow sync ./my-project
48
48
 
49
+ # 维护者:预览本仓库 source checkout live runtime / Skill replica 刷新
50
+ npm run source:refresh:dry-run
51
+
52
+ # 维护者:刷新 ignored root .cowork-flow、.agents/skills、.claude/skills
53
+ npm run source:refresh
54
+
49
55
  # 预览 CLI 更新(不安装)
50
56
  cowork-flow update --dry-run
51
57
 
@@ -104,6 +110,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
104
110
  |---|---|
105
111
  | `init <path>` | 初始化项目模板 |
106
112
  | `sync <path> [--dry-run]` | 同步已初始化项目的模板和技能 |
113
+ | `source-refresh [path] [--dry-run]` | 维护者刷新 source checkout 的 ignored live runtime 与 Host Skill replica |
107
114
  | `install-zcode-plugin` | 安装 ZCode 插件到全局缓存 |
108
115
  | `update [--dry-run]` | 升级 CLI 本身 |
109
116
 
@@ -126,6 +133,13 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
126
133
  - **Dry-run readiness**:`sync --dry-run` 只构建计划并输出 `readiness=<json>`,不写文件或事务状态;字段包含 `wouldCopy`、`wouldSkipProtected`、`wouldRemoveObsolete`、`hostAssetRefresh`、`pendingRecovery`、`warnings`
127
134
  - `--force` 整文件覆盖保护文件
128
135
 
136
+ ### source-refresh 行为
137
+
138
+ - **用途**:仅面向 cowork-flow 源码 checkout 维护者;以 `template/.cowork-flow/` 和 `template/skills/` 为唯一 tracked 分发源,刷新 ignored 的根 `.cowork-flow/`、`.agents/skills/`、`.claude/skills/` 受管副本
139
+ - **保护边界**:不覆盖 `.cowork-flow/tasks/`、`.cowork-flow/plans/`、`.cowork-flow/.runtime/`、`.cowork-flow/.developer`、`.cowork-flow/config.yaml` 和自定义 Skill
140
+ - **事务语义**:复用 Asset Plan / plan applier,失败时回滚;`.cowork-flow/.version` 保持 version-last,并复制 template 版本文件的原始内容
141
+ - **常用命令**:`npm run source:refresh:dry-run` 只预览;`npm run source:refresh` 应用后再运行 `./.cowork-flow/run doctor --all --json`
142
+
129
143
  ### update 行为
130
144
 
131
145
  - 默认查询 npm latest,发现新版本时执行 `npm install -g cowork-flow@latest`
@@ -251,6 +265,7 @@ npm run test:fast # 快速 Node 测试,等价于 npm test
251
265
  npm run test:integration # init/sync 关键集成路径
252
266
  npm run test:node:full # 完整 Node 测试
253
267
  npm run test:template # 核心模板集成测试
268
+ npm run test:windows:core # Windows core 发布信心门禁(Node/Python/init/sync/pack/模板)
254
269
  npm run test:template:full # 完整模板 Python discovery
255
270
  npm run test:all # 发布前全量测试与打包检查
256
271
  npm run release:check # 发布信心门禁;当前等价于 test:all
@@ -271,7 +286,7 @@ npm run release -- minor # minor
271
286
 
272
287
  - 发布说明维护在 `CHANGELOG.md`;发布前更新当前版本段落,并保留 `release:check` 和 `git diff --check` 证据。
273
288
 
274
- CI 需要 `NPM_TOKEN` secret。
289
+ CI 的 PR 同时运行 Ubuntu core 与 Windows core;发布工作流要求 Ubuntu 与 Windows full verification 均成功后才执行 publish。测试 job 不接触 `NPM_TOKEN`,仅 publish job 使用该 secret。
275
290
 
276
291
  Windows 上发布前使用 `run.cmd` 入口验证;POSIX shell 专属 release 用例在没有 shell 的 Windows 环境会明确跳过,不得记录为通过。`release:check` 会保留这些 skip 报告,不把 skip 伪装成 pass。
277
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,15 +18,18 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "test": "npm run test:fast",
21
- "test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/opencode-plugin.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
21
+ "test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/opencode-plugin.test.js test/package.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
22
22
  "test:integration": "node --test --test-name-pattern=\"^(init copies the template into a new target directory|sync updates safe template files and preserves protected files)$\" test/init.test.js test/sync.test.js",
23
23
  "test:node:full": "node --test",
24
24
  "test:template": "node scripts/run-template-tests.js --suite core",
25
25
  "test:template:full": "node scripts/run-template-tests.js --suite full",
26
26
  "pack:check": "node scripts/pack-check.js",
27
+ "source:refresh:dry-run": "node bin/cowork-flow.js source-refresh --dry-run",
28
+ "source:refresh": "node bin/cowork-flow.js source-refresh",
27
29
  "test:all": "npm run test:node:full && npm run test:template:full && npm run pack:check",
28
30
  "release:check": "npm run test:all",
29
- "release": "sh scripts/release.sh"
31
+ "release": "sh scripts/release.sh",
32
+ "test:windows:core": "npm run test:fast && npm run test:integration && npm run pack:check && npm run test:template"
30
33
  },
31
34
  "engines": {
32
35
  "node": ">=20"
package/src/cli.js CHANGED
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline/promises';
4
4
 
5
5
  import { runInit } from './commands/init.js';
6
6
  import { runInstallZCodePlugin } from './commands/install-zcode-plugin.js';
7
+ import { runSourceRefresh } from './commands/source-refresh.js';
7
8
  import { runSync } from './commands/sync.js';
8
9
  import { runUpdate } from './commands/update.js';
9
10
  import { readPackageInfo } from './lib/package-info.js';
@@ -15,6 +16,7 @@ Usage:
15
16
  cowork-flow install-zcode-plugin [--dry-run] [--force] [--prune-old]
16
17
  cowork-flow update
17
18
  cowork-flow sync [target] [--dry-run] [--force]
19
+ cowork-flow source-refresh [target] [--dry-run]
18
20
  cowork-flow --version
19
21
  cowork-flow --help
20
22
  `;
@@ -173,6 +175,10 @@ export async function main(argv = process.argv.slice(2), options = {}) {
173
175
  return await runSync(args, { io });
174
176
  }
175
177
 
178
+ if (command === 'source-refresh') {
179
+ return await runSourceRefresh(args, { io });
180
+ }
181
+
176
182
  if (command === 'update') {
177
183
  return await runUpdate(args, { io });
178
184
  }
@@ -0,0 +1,40 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ import {
4
+ applyPlan,
5
+ buildReadinessReport,
6
+ buildSourceCheckoutRefreshPlan,
7
+ formatReadinessReport,
8
+ summarizePlan
9
+ } from '../lib/copy-template.js';
10
+
11
+ function parseSourceRefreshArgs(args) {
12
+ const options = { dryRun: false, target: process.cwd() };
13
+ for (const arg of args) {
14
+ if (arg === '--dry-run') {
15
+ options.dryRun = true;
16
+ } else if (arg.startsWith('--')) {
17
+ throw new Error(`Unknown source-refresh option: ${arg}`);
18
+ } else {
19
+ options.target = resolve(arg);
20
+ }
21
+ }
22
+ return options;
23
+ }
24
+
25
+ export async function runSourceRefresh(args, { io, fileSystem } = {}) {
26
+ const options = parseSourceRefreshArgs(args);
27
+ const plan = await buildSourceCheckoutRefreshPlan(options.target);
28
+
29
+ await applyPlan(plan, { dryRun: options.dryRun, fileSystem });
30
+ io.writeOut(summarizePlan(plan, options.dryRun));
31
+ if (options.dryRun) {
32
+ const readiness = await buildReadinessReport(plan, { fileSystem });
33
+ io.writeOut(formatReadinessReport(readiness));
34
+ }
35
+ io.writeOut('Source checkout refresh: template/.cowork-flow -> .cowork-flow; template/skills -> host Skill replicas\n');
36
+ if (!options.dryRun) {
37
+ io.writeOut('Next check: ./.cowork-flow/run doctor --all --json\n');
38
+ }
39
+ return 0;
40
+ }
@@ -24,10 +24,13 @@ async function listFiles(root, current = root) {
24
24
  const files = [];
25
25
 
26
26
  for (const entry of entries) {
27
+ if (entry.name === '__pycache__') {
28
+ continue;
29
+ }
27
30
  const absolute = join(current, entry.name);
28
31
  if (entry.isDirectory()) {
29
32
  files.push(...await listFiles(root, absolute));
30
- } else if (entry.isFile()) {
33
+ } else if (entry.isFile() && !entry.name.endsWith('.pyc')) {
31
34
  files.push(relative(root, absolute));
32
35
  }
33
36
  }
@@ -116,6 +119,67 @@ async function appendSkillFileActions(actions, {
116
119
  }
117
120
  }
118
121
 
122
+ async function appendSkillTargetActions(actions, {
123
+ targetDir,
124
+ skillTargets,
125
+ seen,
126
+ sync = false
127
+ }) {
128
+ const normalizedTargets = [...new Set(skillTargets.map(toTemplatePath))].sort();
129
+ for (const skill of await listSkillDirs()) {
130
+ const skillFiles = await listSkillFiles(skill);
131
+ for (const destBase of normalizedTargets) {
132
+ for (const skillFile of skillFiles) {
133
+ const destination = join(
134
+ targetDir,
135
+ destBase,
136
+ skill.id,
137
+ skillFile.skillRelativePath
138
+ );
139
+ if (seen.has(destination)) continue;
140
+ seen.add(destination);
141
+ const exists = await pathExists(destination);
142
+ const unchanged = sync && exists
143
+ ? await sourceMatchesDestination(skillFile.source, destination)
144
+ : false;
145
+ actions.push({
146
+ action: exists ? (sync ? (unchanged ? 'skip' : 'update') : 'skip') : 'create',
147
+ source: skillFile.source,
148
+ destination,
149
+ relativePath: join(destBase, skill.id, skillFile.skillRelativePath)
150
+ });
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ function isSourceCheckoutProtected(relativePath) {
157
+ const normalized = toTemplatePath(relativePath);
158
+ if (hostRegistry.syncPolicy.protectedFiles.includes(normalized)) {
159
+ return true;
160
+ }
161
+ return hostRegistry.syncPolicy.protectedPrefixes
162
+ .filter((prefix) => prefix !== '.cowork-flow/spec/')
163
+ .some((prefix) => normalized.startsWith(prefix))
164
+ || normalized.startsWith('.cowork-flow/.runtime/');
165
+ }
166
+
167
+ function shouldIncludeSourceCheckoutRuntimeFile(relativePath) {
168
+ const normalized = toTemplatePath(relativePath);
169
+ return normalized.startsWith('.cowork-flow/')
170
+ && normalized !== '.cowork-flow/.version'
171
+ && !isSourceCheckoutProtected(normalized);
172
+ }
173
+
174
+ function isSourceRefreshObsoleteFile(relativePath) {
175
+ const normalized = toTemplatePath(relativePath);
176
+ if (isSourceCheckoutProtected(normalized)) {
177
+ return false;
178
+ }
179
+ return normalized.startsWith('.cowork-flow/')
180
+ || hostRegistry.skillTargets.some((target) => normalized.startsWith(`${target}/`));
181
+ }
182
+
119
183
  export async function buildInitPlan(targetDir, options = {}) {
120
184
  const files = await listFiles(templateRoot);
121
185
  const actions = [];
@@ -230,6 +294,71 @@ async function buildFileSyncAction({ source, destination, relativePath }) {
230
294
  };
231
295
  }
232
296
 
297
+ export async function buildSourceCheckoutRefreshPlan(targetDir, options = {}) {
298
+ const files = await listFiles(templateRoot);
299
+ const actions = [];
300
+ const seen = new Set();
301
+
302
+ for (const file of files) {
303
+ const templatePath = toTemplatePath(file);
304
+ if (!shouldIncludeSourceCheckoutRuntimeFile(templatePath)) {
305
+ continue;
306
+ }
307
+ const source = join(templateRoot, file);
308
+ const destination = join(targetDir, file);
309
+ seen.add(destination);
310
+ const exists = await pathExists(destination);
311
+ if (!exists) {
312
+ actions.push({ action: 'create', source, destination, relativePath: file });
313
+ continue;
314
+ }
315
+ actions.push(await buildFileSyncAction({
316
+ source,
317
+ destination,
318
+ relativePath: file
319
+ }));
320
+ }
321
+
322
+ await appendSkillTargetActions(actions, {
323
+ targetDir,
324
+ skillTargets: options.skillTargets ?? hostRegistry.skillTargets,
325
+ seen,
326
+ sync: true
327
+ });
328
+
329
+ const deletedObsoleteParents = [];
330
+ for (const file of hostRegistry.obsoleteSyncFiles()) {
331
+ if (!isSourceRefreshObsoleteFile(file) || isCoveredByDeletedParent(file, deletedObsoleteParents)) {
332
+ continue;
333
+ }
334
+ const destination = join(targetDir, file);
335
+ if (await pathExists(destination)) {
336
+ deletedObsoleteParents.push(file);
337
+ actions.push({ action: 'delete', source: null, destination, relativePath: file });
338
+ }
339
+ }
340
+
341
+ const versionDestination = join(targetDir, '.cowork-flow', '.version');
342
+ const versionSource = join(templateRoot, '.cowork-flow', '.version');
343
+ const versionContent = await readFile(versionSource, 'utf8');
344
+ const versionExists = await pathExists(versionDestination);
345
+ const versionUnchanged = versionExists
346
+ && (await readFile(versionDestination, 'utf8')) === versionContent;
347
+ actions.push({
348
+ action: versionExists ? (versionUnchanged ? 'skip' : 'update') : 'create',
349
+ source: null,
350
+ destination: versionDestination,
351
+ relativePath: '.cowork-flow/.version',
352
+ content: versionContent
353
+ });
354
+
355
+ return createAssetPlan({
356
+ kind: 'source-refresh',
357
+ targetDir,
358
+ actions
359
+ });
360
+ }
361
+
233
362
  export async function buildSyncPlan(targetDir, options = {}) {
234
363
  if (!await pathExists(join(targetDir, '.cowork-flow'))) {
235
364
  throw new Error(`Target is not initialized: ${targetDir}`);
@@ -4,13 +4,14 @@ import { join } from 'node:path';
4
4
  import { templateRoot } from './paths.js';
5
5
 
6
6
 
7
- export const hostAssetManifestPath = join(
8
- templateRoot,
9
- '.cowork-flow',
10
- 'spec',
11
- 'runtime',
12
- 'host-assets.json'
13
- );
7
+ export const hostAssetManifestPath = process.env.COWORK_FLOW_HOST_ASSET_MANIFEST
8
+ || join(
9
+ templateRoot,
10
+ '.cowork-flow',
11
+ 'spec',
12
+ 'runtime',
13
+ 'host-assets.json'
14
+ );
14
15
 
15
16
  const REQUIRED_HOST_NEUTRAL_CAPABILITIES = [
16
17
  'task_action',
@@ -24,6 +25,45 @@ const REQUIRED_CAPABILITY_MATRIX_HOSTS = [
24
25
  'opencode',
25
26
  'zcode'
26
27
  ];
28
+ const CAPABILITY_STATUS_VALUES = [
29
+ 'native',
30
+ 'shim',
31
+ 'plugin',
32
+ 'external',
33
+ 'experimental',
34
+ 'unsupported'
35
+ ];
36
+ const CAPABILITY_DECLARATION_KEYS = ['status', 'fallback'];
37
+ const COMMAND_TARGET_KEYS = ['config', 'format', 'target'];
38
+ const COMMAND_TARGET_FORMATS = ['json', 'toml', 'yaml'];
39
+ const MANIFEST_KEYS = [
40
+ 'schemaVersion',
41
+ 'capabilityValues',
42
+ 'capabilityMatrix',
43
+ 'platforms',
44
+ 'excludedPrefixes',
45
+ 'syncPolicy'
46
+ ];
47
+ const PLATFORM_KEYS = [
48
+ 'id',
49
+ 'displayName',
50
+ 'aliases',
51
+ 'detectAny',
52
+ 'assetPrefixes',
53
+ 'assetFiles',
54
+ 'skillTarget',
55
+ 'adapterPath',
56
+ 'capabilities',
57
+ 'commandTargets'
58
+ ];
59
+ const SYNC_POLICY_KEYS = [
60
+ 'protectedFiles',
61
+ 'protectedPrefixes',
62
+ 'safeFiles',
63
+ 'safePrefixes',
64
+ 'managedBlockFiles',
65
+ 'obsoleteFiles'
66
+ ];
27
67
 
28
68
 
29
69
  export function loadHostAssetManifest(path = hostAssetManifestPath) {
@@ -229,74 +269,135 @@ function validateManifest(manifest) {
229
269
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
230
270
  throw new Error('Host Asset Manifest must be an object');
231
271
  }
272
+ assertKnownKeys(manifest, MANIFEST_KEYS, 'Host Asset Manifest');
232
273
  if (manifest.schemaVersion !== 1) {
233
274
  throw new Error(
234
275
  `Unsupported Host Asset Manifest schemaVersion: ${manifest.schemaVersion}`
235
276
  );
236
277
  }
278
+ const allowed = validateCapabilityValues(manifest.capabilityValues);
237
279
  if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0) {
238
280
  throw new Error(
239
281
  'Host Asset Manifest platforms must be a non-empty array'
240
282
  );
241
283
  }
242
- if (!Array.isArray(manifest.excludedPrefixes)) {
284
+ const platformIds = [];
285
+ const seenPlatformIds = new Set();
286
+ const aliases = new Map();
287
+ for (const platform of manifest.platforms) {
288
+ validatePlatform(platform, allowed, seenPlatformIds, aliases);
289
+ platformIds.push(platform.id);
290
+ }
291
+ validateStringArray(
292
+ manifest.excludedPrefixes,
293
+ 'Host Asset Manifest excludedPrefixes'
294
+ );
295
+ validateCapabilityMatrix(manifest.capabilityMatrix, allowed, platformIds);
296
+ validateSyncPolicy(manifest.syncPolicy);
297
+ }
298
+
299
+ function validateCapabilityValues(values) {
300
+ validateStringArray(
301
+ values,
302
+ 'Host Asset Manifest capabilityValues',
303
+ { unique: true }
304
+ );
305
+ if (!arraysEqual(values, CAPABILITY_STATUS_VALUES)) {
243
306
  throw new Error(
244
- 'Host Asset Manifest excludedPrefixes must be an array'
307
+ `Host Asset Manifest capabilityValues must be ${CAPABILITY_STATUS_VALUES.join(', ')}`
245
308
  );
246
309
  }
247
- validateCapabilityMatrix(manifest);
248
- if (!manifest.syncPolicy || typeof manifest.syncPolicy !== 'object') {
249
- throw new Error('Host Asset Manifest syncPolicy must be an object');
310
+ return new Set(CAPABILITY_STATUS_VALUES);
311
+ }
312
+
313
+ function validatePlatform(platform, allowed, seenPlatformIds, aliases) {
314
+ if (!platform || typeof platform !== 'object' || Array.isArray(platform)) {
315
+ throw new Error('Every host platform must be an object');
250
316
  }
251
- for (const platform of manifest.platforms) {
252
- if (!platform || typeof platform !== 'object' || !platform.id) {
253
- throw new Error('Every host platform must define an id');
254
- }
255
- for (const key of [
256
- 'aliases',
257
- 'detectAny',
258
- 'assetPrefixes',
259
- 'assetFiles',
260
- 'commandTargets'
261
- ]) {
262
- if (!Array.isArray(platform[key])) {
263
- throw new Error(
264
- `Host platform ${platform.id} ${key} must be an array`
265
- );
266
- }
317
+ assertKnownKeys(platform, PLATFORM_KEYS, `Host platform ${platform.id ?? '<unknown>'}`);
318
+ validateRequiredString(platform.id, 'Host platform id');
319
+ if (seenPlatformIds.has(platform.id)) {
320
+ throw new Error(`Duplicate host platform id: ${platform.id}`);
321
+ }
322
+ seenPlatformIds.add(platform.id);
323
+ validateRequiredString(platform.displayName, `Host platform ${platform.id} displayName`);
324
+ validateStringArray(platform.aliases, `Host platform ${platform.id} aliases`);
325
+ for (const alias of platform.aliases) {
326
+ const normalized = alias.toLowerCase();
327
+ const owner = aliases.get(normalized);
328
+ if (owner !== undefined) {
329
+ throw new Error(`Duplicate platform alias: ${alias}`);
267
330
  }
331
+ aliases.set(normalized, platform.id);
268
332
  }
269
- for (const key of [
270
- 'protectedFiles',
271
- 'protectedPrefixes',
272
- 'safeFiles',
273
- 'safePrefixes',
274
- 'managedBlockFiles',
275
- 'obsoleteFiles'
276
- ]) {
277
- if (!Array.isArray(manifest.syncPolicy[key])) {
278
- throw new Error(
279
- `Host Asset Manifest syncPolicy.${key} must be an array`
280
- );
333
+ validateStringArray(platform.detectAny, `Host platform ${platform.id} detectAny`);
334
+ validateStringArray(platform.assetPrefixes, `Host platform ${platform.id} assetPrefixes`);
335
+ validateStringArray(platform.assetFiles, `Host platform ${platform.id} assetFiles`);
336
+ if (platform.skillTarget !== null && typeof platform.skillTarget !== 'string') {
337
+ throw new Error(`Host platform ${platform.id} skillTarget must be a string or null`);
338
+ }
339
+ if (typeof platform.skillTarget === 'string' && platform.skillTarget.length === 0) {
340
+ throw new Error(`Host platform ${platform.id} skillTarget must be a non-empty string or null`);
341
+ }
342
+ validateRequiredString(platform.adapterPath, `Host platform ${platform.id} adapterPath`);
343
+ validatePlatformCapabilities(platform, allowed);
344
+ if (!Array.isArray(platform.commandTargets)) {
345
+ throw new Error(`Host platform ${platform.id} commandTargets must be an array`);
346
+ }
347
+ for (const target of platform.commandTargets) {
348
+ validateCommandTarget(target, platform.id);
349
+ }
350
+ }
351
+
352
+ function validatePlatformCapabilities(platform, allowed) {
353
+ if (!platform.capabilities || typeof platform.capabilities !== 'object' || Array.isArray(platform.capabilities)) {
354
+ throw new Error(`Host platform ${platform.id} capabilities must be an object`);
355
+ }
356
+ if (Object.keys(platform.capabilities).length === 0) {
357
+ throw new Error(`Host platform ${platform.id} capabilities must be a non-empty object`);
358
+ }
359
+ for (const [name, value] of Object.entries(platform.capabilities)) {
360
+ validateRequiredString(name, `Host platform ${platform.id} capability name`);
361
+ if (typeof value !== 'string' || !allowed.has(value)) {
362
+ throw new Error(`Host platform ${platform.id} illegal capability status ${name}=${value}`);
281
363
  }
282
364
  }
283
365
  }
284
366
 
367
+ function validateCommandTarget(target, platformId) {
368
+ if (!target || typeof target !== 'object' || Array.isArray(target)) {
369
+ throw new Error(`Host platform ${platformId} commandTargets must be objects`);
370
+ }
371
+ assertKnownKeys(target, COMMAND_TARGET_KEYS, `Host platform ${platformId} commandTarget`);
372
+ validateRequiredString(target.config, `Host platform ${platformId} commandTarget.config`);
373
+ validateRequiredString(target.target, `Host platform ${platformId} commandTarget.target`);
374
+ if (!COMMAND_TARGET_FORMATS.includes(target.format)) {
375
+ throw new Error(`Host platform ${platformId} commandTarget.format must be json, toml, or yaml`);
376
+ }
377
+ }
285
378
 
286
- function validateCapabilityMatrix(manifest) {
287
- if (!Array.isArray(manifest.capabilityValues) || manifest.capabilityValues.length === 0) {
288
- throw new Error('Host Asset Manifest capabilityValues must be a non-empty array');
379
+ function validateSyncPolicy(syncPolicy) {
380
+ if (!syncPolicy || typeof syncPolicy !== 'object' || Array.isArray(syncPolicy)) {
381
+ throw new Error('Host Asset Manifest syncPolicy must be an object');
289
382
  }
290
- const allowed = new Set(manifest.capabilityValues);
291
- for (const value of allowed) {
292
- if (typeof value !== 'string' || value.length === 0) {
293
- throw new Error('Host Asset Manifest capabilityValues entries must be strings');
294
- }
383
+ assertKnownKeys(syncPolicy, SYNC_POLICY_KEYS, 'Host Asset Manifest syncPolicy');
384
+ for (const key of SYNC_POLICY_KEYS) {
385
+ validateStringArray(
386
+ syncPolicy[key],
387
+ `Host Asset Manifest syncPolicy.${key}`
388
+ );
295
389
  }
296
- const matrix = manifest.capabilityMatrix;
390
+ }
391
+
392
+ function validateCapabilityMatrix(matrix, allowed, platformIds) {
297
393
  if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix)) {
298
394
  throw new Error('Host Asset Manifest capabilityMatrix must be an object');
299
395
  }
396
+ assertKnownKeys(matrix, ['required', 'hosts'], 'Host Asset Manifest capabilityMatrix');
397
+ validateStringArray(
398
+ matrix.required,
399
+ 'Host Asset Manifest capabilityMatrix.required'
400
+ );
300
401
  if (!arraysEqual(matrix.required, REQUIRED_HOST_NEUTRAL_CAPABILITIES)) {
301
402
  throw new Error(
302
403
  `Host Asset Manifest capabilityMatrix.required must be ${REQUIRED_HOST_NEUTRAL_CAPABILITIES.join(', ')}`
@@ -307,7 +408,7 @@ function validateCapabilityMatrix(manifest) {
307
408
  }
308
409
  const requiredHosts = new Set([
309
410
  ...REQUIRED_CAPABILITY_MATRIX_HOSTS,
310
- ...manifest.platforms.map((platform) => platform.id)
411
+ ...platformIds
311
412
  ]);
312
413
  for (const hostId of [...requiredHosts].sort()) {
313
414
  if (!matrix.hosts[hostId]) {
@@ -315,6 +416,7 @@ function validateCapabilityMatrix(manifest) {
315
416
  }
316
417
  }
317
418
  for (const [hostId, capabilities] of Object.entries(matrix.hosts)) {
419
+ validateRequiredString(hostId, 'Host Asset Manifest capabilityMatrix host id');
318
420
  if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {
319
421
  throw new Error(`Host Asset Manifest capabilityMatrix host ${hostId} must be an object`);
320
422
  }
@@ -324,31 +426,74 @@ function validateCapabilityMatrix(manifest) {
324
426
  }
325
427
  }
326
428
  for (const capability of REQUIRED_HOST_NEUTRAL_CAPABILITIES) {
327
- const declaration = capabilities[capability];
328
- if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
329
- throw new Error(`Host Asset Manifest missing host-neutral capability ${hostId}:${capability}`);
330
- }
331
- if (!allowed.has(declaration.status)) {
332
- throw new Error(
333
- `Host Asset Manifest illegal host-neutral capability ${hostId}:${capability}=${declaration.status}`
334
- );
335
- }
336
- if (declaration.fallback !== undefined && (
337
- typeof declaration.fallback !== 'string' || declaration.fallback.length === 0
338
- )) {
339
- throw new Error(
340
- `Host Asset Manifest capability fallback must be a non-empty string: ${hostId}:${capability}`
341
- );
342
- }
343
- if (declaration.status === 'unsupported' && !declaration.fallback) {
344
- throw new Error(
345
- `Host Asset Manifest unsupported capability requires fallback: ${hostId}:${capability}`
346
- );
347
- }
429
+ validateCapabilityDeclaration(
430
+ capabilities[capability],
431
+ allowed,
432
+ hostId,
433
+ capability
434
+ );
435
+ }
436
+ }
437
+ }
438
+
439
+ function validateCapabilityDeclaration(declaration, allowed, hostId, capability) {
440
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
441
+ throw new Error(`Host Asset Manifest missing host-neutral capability ${hostId}:${capability}`);
442
+ }
443
+ assertKnownKeys(
444
+ declaration,
445
+ CAPABILITY_DECLARATION_KEYS,
446
+ `Host Asset Manifest capabilityMatrix.hosts.${hostId}.${capability}`
447
+ );
448
+ if (!allowed.has(declaration.status)) {
449
+ throw new Error(
450
+ `Host Asset Manifest illegal host-neutral capability ${hostId}:${capability}=${declaration.status}`
451
+ );
452
+ }
453
+ if (declaration.fallback !== undefined && (
454
+ typeof declaration.fallback !== 'string' || declaration.fallback.length === 0
455
+ )) {
456
+ throw new Error(
457
+ `Host Asset Manifest capability fallback must be a non-empty string: ${hostId}:${capability}`
458
+ );
459
+ }
460
+ if (declaration.status === 'unsupported' && !declaration.fallback) {
461
+ throw new Error(
462
+ `Host Asset Manifest unsupported capability requires fallback: ${hostId}:${capability}`
463
+ );
464
+ }
465
+ }
466
+
467
+ function validateRequiredString(value, label) {
468
+ if (typeof value !== 'string' || value.length === 0) {
469
+ throw new Error(`${label} must be a non-empty string`);
470
+ }
471
+ }
472
+
473
+ function validateStringArray(value, label, { unique = false } = {}) {
474
+ if (!Array.isArray(value)) {
475
+ throw new Error(`${label} must be an array`);
476
+ }
477
+ const seen = new Set();
478
+ for (const item of value) {
479
+ if (typeof item !== 'string' || item.length === 0) {
480
+ throw new Error(`${label} entries must be non-empty strings`);
348
481
  }
482
+ if (unique && seen.has(item)) {
483
+ throw new Error(`${label} entries must be unique: ${item}`);
484
+ }
485
+ seen.add(item);
349
486
  }
350
487
  }
351
488
 
489
+ function assertKnownKeys(value, allowedKeys, label) {
490
+ const allowed = new Set(allowedKeys);
491
+ for (const key of Object.keys(value)) {
492
+ if (!allowed.has(key)) {
493
+ throw new Error(`${label} unknown field: ${key}`);
494
+ }
495
+ }
496
+ }
352
497
 
353
498
  function normalizeCapabilityMatrix(matrix) {
354
499
  return {
@@ -5,7 +5,7 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": ".codex/hooks/inject-workflow-state.py",
8
+ "command": ".cowork-flow/run python .codex/hooks/inject-workflow-state.py",
9
9
  "timeout": 5
10
10
  }
11
11
  ]
@@ -1 +1 @@
1
- 0.0.48
1
+ 0.0.50