cowork-flow 0.0.37 → 0.0.39

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 (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. package/template/skills/using-cowork-flow/SKILL.md +0 -80
@@ -228,13 +228,104 @@ def worker_command_block_message(
228
228
  return f"Blocked: worker mode cannot run `{command}` for assignment {assignment}. {reason}"
229
229
 
230
230
 
231
- def build_worker_resume_text(
232
- context: ExecutionContext,
233
- repo_root: Path | None = None,
234
- ) -> str:
235
- if repo_root is None:
236
- repo_root = get_repo_root()
237
-
231
+ def _append_allowed_context(
232
+ lines: list[str],
233
+ allowed_context: object,
234
+ *,
235
+ allow_string_items: bool,
236
+ ) -> None:
237
+ if not isinstance(allowed_context, list) or not allowed_context:
238
+ return
239
+ lines.extend(["", "## Allowed context"])
240
+ for item in allowed_context:
241
+ if allow_string_items and isinstance(item, str) and item.strip():
242
+ lines.append(f"- {item.strip()}")
243
+ elif isinstance(item, dict):
244
+ _append_file_reason_item(lines, item)
245
+
246
+
247
+ def _append_file_reason_item(lines: list[str], item: dict) -> None:
248
+ file_value = item.get("file")
249
+ reason = item.get("reason")
250
+ if isinstance(file_value, str) and file_value.strip():
251
+ suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
252
+ lines.append(f"- {file_value}{suffix}")
253
+
254
+
255
+ def _append_string_list_section(
256
+ lines: list[str],
257
+ title: str,
258
+ values: object,
259
+ ) -> None:
260
+ if not isinstance(values, list) or not values:
261
+ return
262
+ items = [value.strip() for value in values if isinstance(value, str) and value.strip()]
263
+ if not items:
264
+ return
265
+ lines.extend(["", title])
266
+ lines.extend(f"- {item}" for item in items)
267
+
268
+
269
+ def _append_context_metadata(
270
+ lines: list[str],
271
+ context_data: dict[str, object],
272
+ ) -> None:
273
+ metadata_fields = [
274
+ ("runtimeContextId", "Runtime context"),
275
+ ("runtimeContextStatus", "Runtime context status"),
276
+ ("agentType", "Agent type"),
277
+ ("dispatchReliability", "Dispatch reliability"),
278
+ ]
279
+ runtime_context_id = (
280
+ context_data.get("runtimeContextId")
281
+ or context_data.get("runtime_context_id")
282
+ )
283
+ if isinstance(runtime_context_id, str) and runtime_context_id.strip():
284
+ lines.append(f"Runtime context: {runtime_context_id}")
285
+ for key, label in metadata_fields[1:]:
286
+ value = context_data.get(key)
287
+ if isinstance(value, str) and value.strip():
288
+ lines.append(f"{label}: {value}")
289
+
290
+
291
+ def _append_status_file(
292
+ lines: list[str],
293
+ repo_root: Path,
294
+ status_file: object,
295
+ ) -> None:
296
+ if not isinstance(status_file, str) or not status_file.strip():
297
+ return
298
+ status_path = repo_root / status_file
299
+ if not status_path.is_file():
300
+ return
301
+ try:
302
+ status = json.loads(status_path.read_text(encoding="utf-8"))
303
+ except (json.JSONDecodeError, OSError):
304
+ lines.extend(["", "## Current status", f"Status file unreadable: {status_file}"])
305
+ return
306
+ lines.extend(["", "## Current status", f"Status: {status.get('status', 'unknown')}"])
307
+ note = status.get("note")
308
+ if isinstance(note, str) and note.strip():
309
+ lines.append(f"Note: {note}")
310
+
311
+
312
+ def _append_recent_events(
313
+ lines: list[str],
314
+ repo_root: Path,
315
+ events_file: object,
316
+ ) -> None:
317
+ if not isinstance(events_file, str) or not events_file.strip():
318
+ return
319
+ events_path = repo_root / events_file
320
+ if not events_path.is_file():
321
+ return
322
+ events = events_path.read_text(encoding="utf-8").splitlines()[-5:]
323
+ if events:
324
+ lines.extend(["", "## Recent events"])
325
+ lines.extend(f"- {event}" for event in events)
326
+
327
+
328
+ def _worker_resume_header(context: ExecutionContext) -> list[str]:
238
329
  lines = [
239
330
  "========================================",
240
331
  "WORKER CONTEXT",
@@ -249,36 +340,28 @@ def build_worker_resume_text(
249
340
  lines.append(f"Prompt file: {context.prompt_file}")
250
341
  if context.context_file:
251
342
  lines.append(f"Context file: {context.context_file}")
252
- lines.extend(
253
- [
254
- "",
255
- "## READ FIRST",
256
- f"- Read worker brief: {context.prompt_file or '(missing prompt file)'}",
257
- ]
343
+ return lines
344
+
345
+
346
+ def _append_worker_read_first(
347
+ lines: list[str],
348
+ context: ExecutionContext,
349
+ repo_root: Path,
350
+ context_data: dict[str, object],
351
+ ) -> None:
352
+ lines.extend(["", "## READ FIRST", f"- Read worker brief: {context.prompt_file or '(missing prompt file)'}"])
353
+ _append_allowed_context(
354
+ lines,
355
+ context_data.get("allowedContext"),
356
+ allow_string_items=False,
258
357
  )
259
- context_data = _load_context_file(context.context_file) if context.context_file else {}
260
- allowed_context = context_data.get("allowedContext")
261
- if isinstance(allowed_context, list) and allowed_context:
262
- lines.extend(["", "## Allowed context"])
263
- for item in allowed_context:
264
- if not isinstance(item, dict):
265
- continue
266
- file_value = item.get("file")
267
- reason = item.get("reason")
268
- if isinstance(file_value, str) and file_value.strip():
269
- suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
270
- lines.append(f"- {file_value}{suffix}")
271
- if context.task_dir and (repo_root / context.task_dir / "decision-anchor.md").is_file():
272
- lines.append(f"- Read task PRD: {context.task_dir}/decision-anchor.md")
358
+ decision_anchor = repo_root / (context.task_dir or "") / "decision-anchor.md"
359
+ if context.task_dir and decision_anchor.is_file():
360
+ lines.append(f"- Read task decision-anchor: {context.task_dir}/decision-anchor.md")
273
361
  lines.append("- Follow only the files, steps, and commands named in the worker brief.")
274
362
 
275
- forbidden_actions = context_data.get("forbiddenActions")
276
- if isinstance(forbidden_actions, list) and forbidden_actions:
277
- lines.extend(["", "## FORBIDDEN ACTIONS"])
278
- for action in forbidden_actions:
279
- if isinstance(action, str) and action.strip():
280
- lines.append(f"- {action.strip()}")
281
363
 
364
+ def _append_worker_rules(lines: list[str], context: ExecutionContext) -> None:
282
365
  lines.extend(
283
366
  [
284
367
  "",
@@ -295,24 +378,31 @@ def build_worker_resume_text(
295
378
  "========================================",
296
379
  ]
297
380
  )
298
- return "\n".join(lines)
299
381
 
300
382
 
301
- def build_subagent_resume_text(
383
+ def build_worker_resume_text(
302
384
  context: ExecutionContext,
303
385
  repo_root: Path | None = None,
304
386
  ) -> str:
305
387
  if repo_root is None:
306
388
  repo_root = get_repo_root()
389
+
390
+ lines = _worker_resume_header(context)
307
391
  context_data = _load_context_file(context.context_file) if context.context_file else {}
308
- allowed_context = context_data.get("allowedContext")
309
- forbidden_actions = context_data.get("forbiddenActions")
310
- status_file = context_data.get("statusFile")
311
- events_file = context_data.get("eventsFile")
312
- runtime_context_id = context_data.get("runtimeContextId") or context_data.get("runtime_context_id")
313
- runtime_context_status = context_data.get("runtimeContextStatus")
314
- agent_type = context_data.get("agentType")
315
- dispatch_reliability = context_data.get("dispatchReliability")
392
+ _append_worker_read_first(lines, context, repo_root, context_data)
393
+ _append_string_list_section(
394
+ lines,
395
+ "## FORBIDDEN ACTIONS",
396
+ context_data.get("forbiddenActions"),
397
+ )
398
+ _append_worker_rules(lines, context)
399
+ return "\n".join(lines)
400
+
401
+
402
+ def _subagent_resume_header(
403
+ context: ExecutionContext,
404
+ context_data: dict[str, object],
405
+ ) -> list[str]:
316
406
  lines = [
317
407
  "========================================",
318
408
  "COWORK-FLOW SUBAGENT RESUME",
@@ -326,50 +416,13 @@ def build_subagent_resume_text(
326
416
  f"Role: {context.role or 'unknown'}",
327
417
  f"Goal: {context.goal or 'unknown'}",
328
418
  ]
329
- if isinstance(runtime_context_id, str) and runtime_context_id.strip():
330
- lines.append(f"Runtime context: {runtime_context_id}")
331
- if isinstance(runtime_context_status, str) and runtime_context_status.strip():
332
- lines.append(f"Runtime context status: {runtime_context_status}")
333
- if isinstance(agent_type, str) and agent_type.strip():
334
- lines.append(f"Agent type: {agent_type}")
335
- if isinstance(dispatch_reliability, str) and dispatch_reliability.strip():
336
- lines.append(f"Dispatch reliability: {dispatch_reliability}")
419
+ _append_context_metadata(lines, context_data)
337
420
  if context.context_file:
338
421
  lines.append(f"Context file: {context.context_file}")
339
- if isinstance(allowed_context, list) and allowed_context:
340
- lines.extend(["", "## Allowed context"])
341
- for item in allowed_context:
342
- if isinstance(item, str) and item.strip():
343
- lines.append(f"- {item.strip()}")
344
- elif isinstance(item, dict):
345
- file_value = item.get("file")
346
- reason = item.get("reason")
347
- if isinstance(file_value, str) and file_value.strip():
348
- suffix = f" - {reason}" if isinstance(reason, str) and reason.strip() else ""
349
- lines.append(f"- {file_value}{suffix}")
350
- if isinstance(forbidden_actions, list) and forbidden_actions:
351
- lines.extend(["", "## Forbidden actions"])
352
- for action in forbidden_actions:
353
- if isinstance(action, str) and action.strip():
354
- lines.append(f"- {action.strip()}")
355
- if isinstance(status_file, str) and status_file.strip():
356
- status_path = repo_root / status_file
357
- if status_path.is_file():
358
- try:
359
- status = json.loads(status_path.read_text(encoding="utf-8"))
360
- lines.extend(["", "## Current status", f"Status: {status.get('status', 'unknown')}"])
361
- note = status.get("note")
362
- if isinstance(note, str) and note.strip():
363
- lines.append(f"Note: {note}")
364
- except (json.JSONDecodeError, OSError):
365
- lines.extend(["", "## Current status", f"Status file unreadable: {status_file}"])
366
- if isinstance(events_file, str) and events_file.strip():
367
- events_path = repo_root / events_file
368
- if events_path.is_file():
369
- events = events_path.read_text(encoding="utf-8").splitlines()[-5:]
370
- if events:
371
- lines.extend(["", "## Recent events"])
372
- lines.extend(f"- {event}" for event in events)
422
+ return lines
423
+
424
+
425
+ def _append_subagent_rules(lines: list[str]) -> None:
373
426
  lines.extend([
374
427
  "",
375
428
  "## RULES",
@@ -382,4 +435,27 @@ def build_subagent_resume_text(
382
435
  "",
383
436
  "========================================",
384
437
  ])
438
+
439
+
440
+ def build_subagent_resume_text(
441
+ context: ExecutionContext,
442
+ repo_root: Path | None = None,
443
+ ) -> str:
444
+ if repo_root is None:
445
+ repo_root = get_repo_root()
446
+ context_data = _load_context_file(context.context_file) if context.context_file else {}
447
+ lines = _subagent_resume_header(context, context_data)
448
+ _append_allowed_context(
449
+ lines,
450
+ context_data.get("allowedContext"),
451
+ allow_string_items=True,
452
+ )
453
+ _append_string_list_section(
454
+ lines,
455
+ "## Forbidden actions",
456
+ context_data.get("forbiddenActions"),
457
+ )
458
+ _append_status_file(lines, repo_root, context_data.get("statusFile"))
459
+ _append_recent_events(lines, repo_root, context_data.get("eventsFile"))
460
+ _append_subagent_rules(lines)
385
461
  return "\n".join(lines)
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """Host Asset Manifest loading and semantic validation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ try:
12
+ import tomllib
13
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
14
+ tomllib = None # type: ignore[assignment]
15
+
16
+
17
+ MANIFEST_PATH = Path(".cowork-flow/spec/runtime/host-assets.json")
18
+ SCHEMA_PATH = Path(".cowork-flow/spec/schemas/host-assets.schema.json")
19
+
20
+
21
+ class HostManifestError(RuntimeError):
22
+ """Raised when the Host Asset Manifest is unavailable or invalid."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class CommandTarget:
27
+ config: str
28
+ format: str
29
+ target: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class HostPlatform:
34
+ id: str
35
+ display_name: str
36
+ aliases: tuple[str, ...]
37
+ detect_any: tuple[str, ...]
38
+ asset_prefixes: tuple[str, ...]
39
+ asset_files: tuple[str, ...]
40
+ skill_target: str | None
41
+ adapter_path: str
42
+ capabilities: dict[str, str]
43
+ command_targets: tuple[CommandTarget, ...]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class SyncPolicy:
48
+ protected_files: tuple[str, ...]
49
+ protected_prefixes: tuple[str, ...]
50
+ safe_files: tuple[str, ...]
51
+ safe_prefixes: tuple[str, ...]
52
+ managed_block_files: tuple[str, ...]
53
+ obsolete_files: tuple[str, ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class HostManifest:
58
+ schema_version: int
59
+ capability_values: tuple[str, ...]
60
+ platforms: tuple[HostPlatform, ...]
61
+ excluded_prefixes: tuple[str, ...]
62
+ sync_policy: SyncPolicy
63
+
64
+ @property
65
+ def platform_ids(self) -> tuple[str, ...]:
66
+ return tuple(platform.id for platform in self.platforms)
67
+
68
+ def platform(self, platform_id: str) -> HostPlatform:
69
+ for platform in self.platforms:
70
+ if platform.id == platform_id:
71
+ return platform
72
+ raise HostManifestError(f"unknown host platform: {platform_id}")
73
+
74
+ def resolve_alias(self, alias: str) -> str:
75
+ normalized = alias.strip().lower()
76
+ if normalized == "all":
77
+ raise HostManifestError("all resolves to multiple host platforms")
78
+ for platform in self.platforms:
79
+ if normalized in platform.aliases:
80
+ return platform.id
81
+ raise HostManifestError(f"unknown host platform alias: {alias}")
82
+
83
+
84
+ def load_host_manifest(template_root: Path) -> HostManifest:
85
+ path = Path(template_root) / MANIFEST_PATH
86
+ try:
87
+ raw = json.loads(path.read_text(encoding="utf-8"))
88
+ except FileNotFoundError as error:
89
+ raise HostManifestError(f"missing host asset manifest: {path}") from error
90
+ except json.JSONDecodeError as error:
91
+ raise HostManifestError(f"invalid host asset manifest JSON: {path}") from error
92
+ if not isinstance(raw, dict):
93
+ raise HostManifestError("host asset manifest must be a JSON object")
94
+ return _build_manifest(raw)
95
+
96
+
97
+ def validate_host_assets(template_root: Path) -> list[str]:
98
+ root = Path(template_root)
99
+ errors: list[str] = []
100
+ try:
101
+ manifest = load_host_manifest(root)
102
+ except HostManifestError as error:
103
+ return [str(error)]
104
+
105
+ if not (root / SCHEMA_PATH).is_file():
106
+ errors.append(f"missing host asset schema: {root / SCHEMA_PATH}")
107
+
108
+ allowed = set(manifest.capability_values)
109
+ aliases: dict[str, str] = {}
110
+ for platform in manifest.platforms:
111
+ for alias in platform.aliases:
112
+ owner = aliases.get(alias)
113
+ if owner is not None and owner != platform.id:
114
+ errors.append(
115
+ f"duplicate platform alias {alias}: {owner}, {platform.id}"
116
+ )
117
+ aliases[alias] = platform.id
118
+ _validate_platform(root, platform, allowed, errors)
119
+ return errors
120
+
121
+
122
+ def _validate_platform(
123
+ root: Path,
124
+ platform: HostPlatform,
125
+ allowed: set[str],
126
+ errors: list[str],
127
+ ) -> None:
128
+ adapter_path = root / platform.adapter_path
129
+ if not adapter_path.is_file():
130
+ errors.append(f"missing adapter: {platform.adapter_path}")
131
+ return
132
+ try:
133
+ adapter = _parse_simple_yaml(adapter_path)
134
+ except (OSError, ValueError) as error:
135
+ errors.append(f"invalid adapter YAML {platform.adapter_path}: {error}")
136
+ return
137
+
138
+ if adapter.get("host") != platform.id:
139
+ errors.append(
140
+ f"adapter host mismatch {platform.adapter_path}: "
141
+ f"{adapter.get('host')} != {platform.id}"
142
+ )
143
+ capabilities = adapter.get("capabilities")
144
+ if not isinstance(capabilities, dict):
145
+ errors.append(f"adapter missing capabilities: {platform.adapter_path}")
146
+ else:
147
+ for name, value in capabilities.items():
148
+ if value not in allowed:
149
+ errors.append(
150
+ f"illegal capability {platform.id}:{name}={value}"
151
+ )
152
+ if capabilities != platform.capabilities:
153
+ errors.append(
154
+ f"capability mismatch between manifest and adapter: {platform.id}"
155
+ )
156
+
157
+ for target in platform.command_targets:
158
+ _validate_command_target(root, platform.id, target, errors)
159
+
160
+
161
+ def _validate_command_target(
162
+ root: Path,
163
+ platform_id: str,
164
+ command_target: CommandTarget,
165
+ errors: list[str],
166
+ ) -> None:
167
+ config_path = root / command_target.config
168
+ target_path = root / command_target.target
169
+ if not config_path.is_file():
170
+ errors.append(
171
+ f"missing command config {platform_id}: {command_target.config}"
172
+ )
173
+ return
174
+ if not target_path.is_file():
175
+ errors.append(
176
+ f"missing command target {platform_id}: {command_target.target}"
177
+ )
178
+ return
179
+ try:
180
+ config = _load_structured(config_path, command_target.format)
181
+ except (OSError, ValueError, json.JSONDecodeError) as error:
182
+ errors.append(
183
+ f"invalid command config {platform_id}: "
184
+ f"{command_target.config}: {error}"
185
+ )
186
+ return
187
+ strings = tuple(_walk_strings(config))
188
+ if not any(command_target.target in value for value in strings):
189
+ errors.append(
190
+ f"command config {command_target.config} does not reference "
191
+ f"{command_target.target}"
192
+ )
193
+
194
+
195
+ def _load_structured(path: Path, format_name: str) -> object:
196
+ text = path.read_text(encoding="utf-8")
197
+ if format_name == "json":
198
+ return json.loads(text)
199
+ if format_name == "toml":
200
+ if tomllib is None:
201
+ raise ValueError("TOML parser unavailable")
202
+ return tomllib.loads(text)
203
+ if format_name == "yaml":
204
+ return _parse_simple_yaml(path)
205
+ raise ValueError(f"unsupported config format: {format_name}")
206
+
207
+
208
+ def _walk_strings(value: object):
209
+ if isinstance(value, str):
210
+ yield value
211
+ elif isinstance(value, dict):
212
+ for item in value.values():
213
+ yield from _walk_strings(item)
214
+ elif isinstance(value, list):
215
+ for item in value:
216
+ yield from _walk_strings(item)
217
+
218
+
219
+ def _parse_simple_yaml(path: Path) -> dict[str, object]:
220
+ root: dict[str, object] = {}
221
+ stack: list[tuple[int, dict[str, object]]] = [(-1, root)]
222
+ for line_number, raw_line in enumerate(
223
+ path.read_text(encoding="utf-8").splitlines(),
224
+ start=1,
225
+ ):
226
+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
227
+ continue
228
+ indent = len(raw_line) - len(raw_line.lstrip())
229
+ stripped = raw_line.strip()
230
+ key, separator, raw_value = stripped.partition(":")
231
+ if not separator or not key.strip():
232
+ raise ValueError(f"invalid mapping at line {line_number}")
233
+ while stack[-1][0] >= indent:
234
+ stack.pop()
235
+ parent = stack[-1][1]
236
+ value = raw_value.strip()
237
+ if value:
238
+ parent[key.strip()] = _parse_scalar(value)
239
+ else:
240
+ child: dict[str, object] = {}
241
+ parent[key.strip()] = child
242
+ stack.append((indent, child))
243
+ return root
244
+
245
+
246
+ def _parse_scalar(value: str) -> object:
247
+ if value == "true":
248
+ return True
249
+ if value == "false":
250
+ return False
251
+ if value.isdecimal():
252
+ return int(value)
253
+ return value
254
+
255
+
256
+ def _build_manifest(raw: dict[str, Any]) -> HostManifest:
257
+ schema_version = raw.get("schemaVersion")
258
+ if schema_version != 1:
259
+ raise HostManifestError(
260
+ f"unsupported host asset schemaVersion: {schema_version}"
261
+ )
262
+ capability_values = _string_tuple(
263
+ raw.get("capabilityValues"),
264
+ "capabilityValues",
265
+ )
266
+ platforms_raw = raw.get("platforms")
267
+ if not isinstance(platforms_raw, list) or not platforms_raw:
268
+ raise HostManifestError("platforms must be a non-empty array")
269
+ platforms = tuple(
270
+ _build_platform(item)
271
+ for item in platforms_raw
272
+ if isinstance(item, dict)
273
+ )
274
+ if len(platforms) != len(platforms_raw):
275
+ raise HostManifestError("every platform must be an object")
276
+ sync_raw = raw.get("syncPolicy")
277
+ if not isinstance(sync_raw, dict):
278
+ raise HostManifestError("syncPolicy must be an object")
279
+ return HostManifest(
280
+ schema_version=schema_version,
281
+ capability_values=capability_values,
282
+ platforms=platforms,
283
+ excluded_prefixes=_string_tuple(
284
+ raw.get("excludedPrefixes"),
285
+ "excludedPrefixes",
286
+ ),
287
+ sync_policy=SyncPolicy(
288
+ protected_files=_string_tuple(
289
+ sync_raw.get("protectedFiles"),
290
+ "syncPolicy.protectedFiles",
291
+ ),
292
+ protected_prefixes=_string_tuple(
293
+ sync_raw.get("protectedPrefixes"),
294
+ "syncPolicy.protectedPrefixes",
295
+ ),
296
+ safe_files=_string_tuple(
297
+ sync_raw.get("safeFiles"),
298
+ "syncPolicy.safeFiles",
299
+ ),
300
+ safe_prefixes=_string_tuple(
301
+ sync_raw.get("safePrefixes"),
302
+ "syncPolicy.safePrefixes",
303
+ ),
304
+ managed_block_files=_string_tuple(
305
+ sync_raw.get("managedBlockFiles"),
306
+ "syncPolicy.managedBlockFiles",
307
+ ),
308
+ obsolete_files=_string_tuple(
309
+ sync_raw.get("obsoleteFiles"),
310
+ "syncPolicy.obsoleteFiles",
311
+ ),
312
+ ),
313
+ )
314
+
315
+
316
+ def _build_platform(raw: dict[str, Any]) -> HostPlatform:
317
+ platform_id = _required_string(raw, "id")
318
+ capabilities = raw.get("capabilities")
319
+ if not isinstance(capabilities, dict) or not capabilities:
320
+ raise HostManifestError(
321
+ f"platform {platform_id} capabilities must be an object"
322
+ )
323
+ normalized_capabilities: dict[str, str] = {}
324
+ for name, value in capabilities.items():
325
+ if not isinstance(name, str) or not isinstance(value, str):
326
+ raise HostManifestError(
327
+ f"platform {platform_id} capabilities must be strings"
328
+ )
329
+ normalized_capabilities[name] = value
330
+ command_targets_raw = raw.get("commandTargets")
331
+ if not isinstance(command_targets_raw, list):
332
+ raise HostManifestError(
333
+ f"platform {platform_id} commandTargets must be an array"
334
+ )
335
+ command_targets = tuple(
336
+ CommandTarget(
337
+ config=_required_string(item, "config"),
338
+ format=_required_string(item, "format"),
339
+ target=_required_string(item, "target"),
340
+ )
341
+ for item in command_targets_raw
342
+ if isinstance(item, dict)
343
+ )
344
+ if len(command_targets) != len(command_targets_raw):
345
+ raise HostManifestError(
346
+ f"platform {platform_id} commandTargets must be objects"
347
+ )
348
+ skill_target = raw.get("skillTarget")
349
+ if skill_target is not None and not isinstance(skill_target, str):
350
+ raise HostManifestError(
351
+ f"platform {platform_id} skillTarget must be a string or null"
352
+ )
353
+ return HostPlatform(
354
+ id=platform_id,
355
+ display_name=_required_string(raw, "displayName"),
356
+ aliases=_string_tuple(raw.get("aliases"), f"{platform_id}.aliases"),
357
+ detect_any=_string_tuple(
358
+ raw.get("detectAny"),
359
+ f"{platform_id}.detectAny",
360
+ ),
361
+ asset_prefixes=_string_tuple(
362
+ raw.get("assetPrefixes"),
363
+ f"{platform_id}.assetPrefixes",
364
+ ),
365
+ asset_files=_string_tuple(
366
+ raw.get("assetFiles"),
367
+ f"{platform_id}.assetFiles",
368
+ ),
369
+ skill_target=skill_target,
370
+ adapter_path=_required_string(raw, "adapterPath"),
371
+ capabilities=normalized_capabilities,
372
+ command_targets=command_targets,
373
+ )
374
+
375
+
376
+ def _required_string(raw: dict[str, Any], key: str) -> str:
377
+ value = raw.get(key)
378
+ if not isinstance(value, str) or not value.strip():
379
+ raise HostManifestError(f"{key} must be a non-empty string")
380
+ return value.strip()
381
+
382
+
383
+ def _string_tuple(value: object, label: str) -> tuple[str, ...]:
384
+ if not isinstance(value, list):
385
+ raise HostManifestError(f"{label} must be an array")
386
+ result: list[str] = []
387
+ for item in value:
388
+ if not isinstance(item, str) or not item.strip():
389
+ raise HostManifestError(
390
+ f"{label} entries must be non-empty strings"
391
+ )
392
+ result.append(item.strip())
393
+ return tuple(result)