938 lines
35 KiB
TypeScript
938 lines
35 KiB
TypeScript
export type CourseStageId = string;
|
||
export type CourseDeliverableType = 'markdown' | 'html' | 'code' | 'report';
|
||
|
||
export const PROJECT_TEMPLATE_SCHEMA_VERSION = 1;
|
||
export const projectTemplateIds = ['standard-dev', 'youth-ai-course', 'web-mini-game-course'] as const;
|
||
|
||
export type ProjectTemplateId = (typeof projectTemplateIds)[number];
|
||
export type ProjectTemplateCapabilityKey = 'chat' | 'course' | 'publish' | 'gameDevelopment';
|
||
export type ProjectTemplateValidationResult =
|
||
| { ok: true; snapshot: ProjectTemplateSnapshot }
|
||
| { ok: false; error: string };
|
||
|
||
export const gameDevelopmentRoleIds = ['gameplay', 'game-dev', 'game-release'] as const;
|
||
export type GameDevelopmentRoleId = (typeof gameDevelopmentRoleIds)[number];
|
||
|
||
export type ProjectTemplateSnapshot = {
|
||
schemaVersion: 1;
|
||
template: {
|
||
id: ProjectTemplateId;
|
||
version: number;
|
||
title: string;
|
||
description: string;
|
||
createdAt: string;
|
||
};
|
||
capabilities: {
|
||
chat: ProjectTemplateChatCapability;
|
||
course: ProjectTemplateCourseCapability;
|
||
publish: ProjectTemplatePublishCapability;
|
||
scaffold?: ProjectTemplateScaffoldCapability;
|
||
gameDevelopment?: ProjectTemplateGameDevelopmentCapability;
|
||
};
|
||
};
|
||
|
||
export type ProjectTemplateChatCapability = {
|
||
enabled: boolean;
|
||
defaultRoute: '/opencode-chat' | '/subagents';
|
||
};
|
||
|
||
export type ProjectTemplateCourseCapability =
|
||
| { enabled: false }
|
||
| {
|
||
enabled: true;
|
||
title: string;
|
||
slots: ProjectTemplateCourseSlot[];
|
||
stages: ProjectTemplateCourseStage[];
|
||
};
|
||
|
||
export type ProjectTemplatePublishCapability =
|
||
| { enabled: false }
|
||
| {
|
||
enabled: true;
|
||
provider: 'works-square';
|
||
};
|
||
|
||
export type ProjectTemplateScaffoldCapability =
|
||
| { enabled: false }
|
||
| {
|
||
enabled: true;
|
||
kind: 'phaser-2d-casual';
|
||
entryFile: 'src/main.ts';
|
||
startCommand: 'pnpm dev';
|
||
packageManager: 'pnpm';
|
||
};
|
||
|
||
export type ProjectTemplateGameDevelopmentCapability =
|
||
| { enabled: false }
|
||
| {
|
||
enabled: true;
|
||
engine: 'phaser3';
|
||
requiredSkillIds: string[];
|
||
requiredRoleIds: GameDevelopmentRoleId[];
|
||
designDocument: 'GDD.md';
|
||
taskDocument: 'TASKS.md';
|
||
collaborationMode: 'pair';
|
||
};
|
||
|
||
export type ProjectTemplateCourseSlot = {
|
||
id: CourseStageId;
|
||
title: string;
|
||
agentName: string;
|
||
agentRole: string;
|
||
goal: string;
|
||
recommendedAgentProfile: ProjectTemplateRecommendedAgentProfile;
|
||
};
|
||
|
||
export type ProjectTemplateCourseStage = {
|
||
id: CourseStageId;
|
||
order: number;
|
||
title: string;
|
||
agentName: string;
|
||
agentRole: string;
|
||
goal: string;
|
||
task: string;
|
||
deliverableTitle: string;
|
||
deliverableType: CourseDeliverableType;
|
||
deliverableSummary: string;
|
||
acceptanceCriteria: string[];
|
||
handoffTarget: CourseStageId | null;
|
||
};
|
||
|
||
export type ProjectTemplateRecommendedAgentProfile = {
|
||
roleId: CourseStageId;
|
||
displayName: string;
|
||
notes: string[];
|
||
};
|
||
|
||
const youthCourseStages: ProjectTemplateCourseStage[] = [
|
||
{
|
||
id: 'pm',
|
||
order: 1,
|
||
title: '想法与计划',
|
||
agentName: '项目经理',
|
||
agentRole: '思路整理',
|
||
goal: '把一个模糊想法收敛成可以执行的小项目。',
|
||
task: '讨论项目目标、核心用户、第一版范围、任务清单和验收标准。',
|
||
deliverableTitle: '项目计划.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: '项目目标、核心用户、第一版范围、任务清单、验收标准。',
|
||
acceptanceCriteria: [
|
||
'有明确项目目标',
|
||
'有核心用户和使用场景',
|
||
'有第一版范围',
|
||
'有任务清单',
|
||
'有验收标准',
|
||
],
|
||
handoffTarget: 'product',
|
||
},
|
||
{
|
||
id: 'product',
|
||
order: 2,
|
||
title: '原型 Demo',
|
||
agentName: '产品经理',
|
||
agentRole: '原型设计',
|
||
goal: '把项目计划变成可以演示的产品流程。',
|
||
task: '基于项目计划定义页面、按钮、交互、边界状态和 Demo 交付说明,不依赖最终视觉规范。',
|
||
deliverableTitle: '原型Demo.html',
|
||
deliverableType: 'html',
|
||
deliverableSummary: '页面流程、关键交互、状态说明、基础可用性说明、可演示 Demo。',
|
||
acceptanceCriteria: [
|
||
'有完整用户流程',
|
||
'有页面清单',
|
||
'有关键交互',
|
||
'有边界状态',
|
||
'有基础可用性说明',
|
||
'能在 1-3 分钟内演示',
|
||
],
|
||
handoffTarget: 'designer',
|
||
},
|
||
{
|
||
id: 'designer',
|
||
order: 3,
|
||
title: '视觉设计',
|
||
agentName: '美术设计师',
|
||
agentRole: '视觉设计',
|
||
goal: '基于项目计划和原型 Demo,形成清楚、可实现的 UI/UX 视觉规范。',
|
||
task: '基于项目计划和原型 Demo 明确风格、色彩、排版、布局、组件规则、响应式和可访问性要求。',
|
||
deliverableTitle: '设计规范.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: '基于原型的设计方向、色彩规则、排版层级、页面布局、组件状态、UI/UX 质量检查。',
|
||
acceptanceCriteria: [
|
||
'有设计方向',
|
||
'有色彩和排版规则',
|
||
'有布局规则',
|
||
'有组件状态',
|
||
'有响应式和可访问性要求',
|
||
'有 UI/UX 质量检查',
|
||
],
|
||
handoffTarget: 'dev',
|
||
},
|
||
{
|
||
id: 'dev',
|
||
order: 4,
|
||
title: '代码开发',
|
||
agentName: '开发工程师',
|
||
agentRole: '代码开发',
|
||
goal: '把原型实现成可以运行和检查的软件作品。',
|
||
task: '完成数据/接口设计、代码实现、测试验证和剩余风险说明。',
|
||
deliverableTitle: '代码实现与测试报告.md',
|
||
deliverableType: 'code',
|
||
deliverableSummary: '实现文件、数据接口、测试命令、UI/UX 检查结果、风险说明。',
|
||
acceptanceCriteria: [
|
||
'有代码实现说明',
|
||
'有数据或接口说明',
|
||
'有测试或检查结果',
|
||
'有 UI/UX 检查结果',
|
||
'有剩余风险',
|
||
'有给宣发和部署的事实说明',
|
||
],
|
||
handoffTarget: 'marketing',
|
||
},
|
||
{
|
||
id: 'marketing',
|
||
order: 5,
|
||
title: '宣传展示',
|
||
agentName: '市场运营',
|
||
agentRole: '宣传展示',
|
||
goal: '把作品讲清楚,让别人理解它解决了什么问题。',
|
||
task: '准备定位、一句话介绍、核心卖点、展示文案和讲解提纲。',
|
||
deliverableTitle: '宣发材料.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: '宣传定位、目标受众、展示文案、宣讲提纲、证据清单。',
|
||
acceptanceCriteria: [
|
||
'有目标受众',
|
||
'有一句话介绍',
|
||
'有核心卖点',
|
||
'文案不夸大',
|
||
'有展示文案或讲解提纲',
|
||
'有证据清单或需要补充的证据',
|
||
],
|
||
handoffTarget: 'deploy',
|
||
},
|
||
{
|
||
id: 'deploy',
|
||
order: 6,
|
||
title: '发布部署',
|
||
agentName: '部署工程师',
|
||
agentRole: '发布部署',
|
||
goal: '确认作品可以通过 Docker 构建、启动、打包并通过作品广场上传接口提交。',
|
||
task: '创建或维护 Dockerfile 和 docker-compose.yml,检查 Docker 构建状态,把当前项目打成 zip 包,通过作品广场上传接口提交,并记录发布目标、发布步骤、回滚说明和作品广场提交清单。',
|
||
deliverableTitle: '部署报告.md',
|
||
deliverableType: 'report',
|
||
deliverableSummary: 'Dockerfile、docker-compose.yml、发布目标、准备情况、Docker 执行命令、zip 包路径、作品广场上传接口结果、发布步骤、回滚步骤。',
|
||
acceptanceCriteria: [
|
||
'有明确发布目标',
|
||
'有 Dockerfile',
|
||
'有 docker-compose.yml',
|
||
'有 Docker 构建或检查结果',
|
||
'有 zip 包路径',
|
||
'有作品广场上传接口结果',
|
||
'有发布步骤',
|
||
'有回滚步骤',
|
||
'没有隐私或密钥风险',
|
||
'没有失败/阻塞或未验证的必需发布证据',
|
||
],
|
||
handoffTarget: null,
|
||
},
|
||
];
|
||
|
||
const youthCourseSlots: ProjectTemplateCourseSlot[] = youthCourseStages.map((stage) => ({
|
||
id: stage.id,
|
||
title: stage.title,
|
||
agentName: stage.agentName,
|
||
agentRole: stage.agentRole,
|
||
goal: stage.goal,
|
||
recommendedAgentProfile: {
|
||
roleId: stage.id,
|
||
displayName: stage.agentName,
|
||
notes: [],
|
||
},
|
||
}));
|
||
|
||
const webMiniGameCourseStages: ProjectTemplateCourseStage[] = [
|
||
{
|
||
id: 'gameplay',
|
||
order: 1,
|
||
title: '玩法规划',
|
||
agentName: '玩法策划',
|
||
agentRole: '玩法策划',
|
||
goal: '把用户的游戏想法收敛成可验证、能持续维护的玩法设计。',
|
||
task: '定义玩家、核心循环、胜负条件、关卡节奏和第一版范围,并写清当前可玩目标。',
|
||
deliverableTitle: 'GDD.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: 'GDD.md 中的游戏目标、玩法循环、系统和第一版边界,以及 TASKS.md 中的当前可玩目标。',
|
||
acceptanceCriteria: [
|
||
'明确游戏目标',
|
||
'说明目标玩家',
|
||
'解释核心玩法循环',
|
||
'列出胜利和失败条件',
|
||
'描述关卡节奏',
|
||
'收敛第一版范围',
|
||
'把当前可玩目标和可观察验收写入 TASKS.md',
|
||
],
|
||
handoffTarget: 'assets',
|
||
},
|
||
{
|
||
id: 'assets',
|
||
order: 2,
|
||
title: '素材搜索',
|
||
agentName: '素材规划师',
|
||
agentRole: '素材规划师',
|
||
goal: '搜索可复用的游戏素材,并记录仍然缺少的内容。',
|
||
task: '列出视觉、音频、UI 和动画素材需求,记录来源、授权、尺寸和备用方案。',
|
||
deliverableTitle: 'ASSET_PLAN.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: '素材清单、来源、授权、技术限制和备用方案。',
|
||
acceptanceCriteria: [
|
||
'列出必需素材',
|
||
'记录用户明确确认选定或明确授权代选的原话',
|
||
'当前目标必需素材全部为 selected 或 approved-placeholder',
|
||
'确认清单没有 unresolved、candidate、missing 或 TODO 项',
|
||
'记录来源或获取路径',
|
||
'说明授权或使用限制',
|
||
'包含技术要求',
|
||
'覆盖视觉一致性',
|
||
'包含备用方案',
|
||
],
|
||
handoffTarget: 'game-dev',
|
||
},
|
||
{
|
||
id: 'game-dev',
|
||
order: 3,
|
||
title: '游戏开发',
|
||
agentName: '游戏开发师',
|
||
agentRole: '游戏开发师',
|
||
goal: '根据玩法设计和素材计划做出可在浏览器试玩的小游戏。',
|
||
task: '一次推进一个玩家可感知的完整增量,实现核心玩法循环,接入计划素材,并在真实浏览器中试玩。',
|
||
deliverableTitle: 'PLAYABLE_GAME.md',
|
||
deliverableType: 'code',
|
||
deliverableSummary: '可试玩实现、玩法说明、已接入素材和试玩结果。',
|
||
acceptanceCriteria: [
|
||
'做出可试玩的 2D 小游戏',
|
||
'实现核心玩法循环',
|
||
'使用计划素材或占位素材',
|
||
'可以在浏览器运行',
|
||
'包含来自真实浏览器的试玩证据',
|
||
'说明剩余问题或下一步',
|
||
],
|
||
handoffTarget: 'game-release',
|
||
},
|
||
{
|
||
id: 'game-release',
|
||
order: 4,
|
||
title: '发布打包',
|
||
agentName: '游戏发布官',
|
||
agentRole: '游戏发布官',
|
||
goal: '准备构建结果、试玩记录和发布检查清单。',
|
||
task: '运行生产构建和预览,在真实浏览器中完成核心循环与重开检查;记录 Compose 配置、构建、启动、动态端口、HTTP 冒烟和清理结果,并说明发布与回滚。',
|
||
deliverableTitle: 'RELEASE_CHECKLIST.md',
|
||
deliverableType: 'report',
|
||
deliverableSummary: '构建结果、试玩检查、运行说明、发布检查项和已知风险;包括 ZIP 根目录、大小、文件数、路径安全、重复项和符号链接、works-publish.json 以及发布与回滚。',
|
||
acceptanceCriteria: [
|
||
'包含 pnpm build 命令和成功结果',
|
||
'包含真实浏览器试玩和重开记录',
|
||
'包含运行说明',
|
||
'包含发布检查项',
|
||
'包含已知风险或问题',
|
||
'没有失败/阻塞或未验证的必需发布证据',
|
||
],
|
||
handoffTarget: 'game-showcase',
|
||
},
|
||
{
|
||
id: 'game-showcase',
|
||
order: 5,
|
||
title: '宣传展示',
|
||
agentName: '游戏宣传官',
|
||
agentRole: '宣传展示',
|
||
goal: '把完成的小游戏整理成可以给别人理解和试玩的展示材料。',
|
||
task: '准备一句话介绍、核心亮点、试玩入口说明、截图/录屏建议、展示文案和推广发布建议。',
|
||
deliverableTitle: 'SHOWCASE_PACKAGE.md',
|
||
deliverableType: 'markdown',
|
||
deliverableSummary: '小游戏定位、一句话介绍、核心亮点、试玩说明、展示文案、截图/录屏清单和推广建议。',
|
||
acceptanceCriteria: [
|
||
'包含目标受众',
|
||
'包含一句话介绍',
|
||
'列出核心亮点',
|
||
'说明如何试玩',
|
||
'包含截图或录屏建议',
|
||
'包含可直接使用的展示文案',
|
||
],
|
||
handoffTarget: null,
|
||
},
|
||
];
|
||
|
||
const webMiniGameCourseSlots: ProjectTemplateCourseSlot[] = webMiniGameCourseStages.map((stage) => ({
|
||
id: stage.id,
|
||
title: stage.title,
|
||
agentName: stage.agentName,
|
||
agentRole: stage.agentRole,
|
||
goal: stage.goal,
|
||
recommendedAgentProfile: {
|
||
roleId: stage.id,
|
||
displayName: stage.agentName,
|
||
notes: [],
|
||
},
|
||
}));
|
||
|
||
export const builtInProjectTemplates: ProjectTemplateSnapshot[] = [
|
||
{
|
||
schemaVersion: 1,
|
||
template: {
|
||
id: 'standard-dev',
|
||
version: 1,
|
||
title: '普通开发项目',
|
||
description: '用于日常代码开发和普通会话,不显示课程角色栏。',
|
||
createdAt: '1970-01-01T00:00:00.000Z',
|
||
},
|
||
capabilities: {
|
||
chat: { enabled: true, defaultRoute: '/opencode-chat' },
|
||
course: { enabled: false },
|
||
publish: { enabled: false },
|
||
scaffold: { enabled: false },
|
||
},
|
||
},
|
||
{
|
||
schemaVersion: 1,
|
||
template: {
|
||
id: 'youth-ai-course',
|
||
version: 1,
|
||
title: 'AI 伙伴课程项目',
|
||
description: '提供课程角色、阶段导航和作品发布信息,所有阶段均可随时进入。',
|
||
createdAt: '1970-01-01T00:00:00.000Z',
|
||
},
|
||
capabilities: {
|
||
chat: { enabled: true, defaultRoute: '/opencode-chat' },
|
||
course: {
|
||
enabled: true,
|
||
title: 'AI 伙伴课程路径',
|
||
slots: youthCourseSlots,
|
||
stages: youthCourseStages,
|
||
},
|
||
publish: { enabled: true, provider: 'works-square' },
|
||
scaffold: { enabled: false },
|
||
},
|
||
},
|
||
{
|
||
schemaVersion: 1,
|
||
template: {
|
||
id: 'web-mini-game-course',
|
||
version: 3,
|
||
title: 'Web 小游戏课程项目',
|
||
description: '启用结对式 Web 小游戏课程、游戏设计任务文档和 Phaser 3 脚手架支持。',
|
||
createdAt: '1970-01-01T00:00:00.000Z',
|
||
},
|
||
capabilities: {
|
||
chat: { enabled: true, defaultRoute: '/opencode-chat' },
|
||
course: {
|
||
enabled: true,
|
||
title: 'Web 小游戏制作路径',
|
||
slots: webMiniGameCourseSlots,
|
||
stages: webMiniGameCourseStages,
|
||
},
|
||
publish: { enabled: true, provider: 'works-square' },
|
||
scaffold: {
|
||
enabled: true,
|
||
kind: 'phaser-2d-casual',
|
||
entryFile: 'src/main.ts',
|
||
startCommand: 'pnpm dev',
|
||
packageManager: 'pnpm',
|
||
},
|
||
gameDevelopment: {
|
||
enabled: true,
|
||
engine: 'phaser3',
|
||
requiredSkillIds: ['nianxxgame-skill'],
|
||
requiredRoleIds: ['gameplay', 'game-dev', 'game-release'],
|
||
designDocument: 'GDD.md',
|
||
taskDocument: 'TASKS.md',
|
||
collaborationMode: 'pair',
|
||
},
|
||
},
|
||
},
|
||
];
|
||
|
||
export function isProjectTemplateId(value: unknown): value is ProjectTemplateId {
|
||
return typeof value === 'string' && projectTemplateIds.includes(value as ProjectTemplateId);
|
||
}
|
||
|
||
export function isProjectTemplateCourseStageId(value: unknown): value is CourseStageId {
|
||
return typeof value === 'string' && value.trim().length > 0;
|
||
}
|
||
|
||
export function createProjectTemplateSnapshot(
|
||
templateId: ProjectTemplateId,
|
||
now = new Date().toISOString(),
|
||
): ProjectTemplateSnapshot {
|
||
const source = builtInProjectTemplates.find((template) => template.template.id === templateId);
|
||
if (!source) {
|
||
throw new Error(`Unknown project template: ${templateId}`);
|
||
}
|
||
|
||
return {
|
||
...structuredClone(source),
|
||
template: {
|
||
...source.template,
|
||
createdAt: now,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function hasProjectCapability(
|
||
snapshot: ProjectTemplateSnapshot | null | undefined,
|
||
key: ProjectTemplateCapabilityKey,
|
||
): boolean {
|
||
return Boolean(snapshot?.capabilities[key]?.enabled);
|
||
}
|
||
|
||
export function getProjectTemplateRequiredSkillIds(
|
||
snapshot: ProjectTemplateSnapshot | null | undefined,
|
||
roleId: string | null | undefined,
|
||
): string[] {
|
||
const gameDevelopment = snapshot?.capabilities.gameDevelopment;
|
||
if (!gameDevelopment?.enabled || !roleId || !gameDevelopment.requiredRoleIds.includes(roleId as GameDevelopmentRoleId)) {
|
||
return [];
|
||
}
|
||
return [...gameDevelopment.requiredSkillIds];
|
||
}
|
||
|
||
export function getProjectTemplateDisplayLabel(snapshot: ProjectTemplateSnapshot | null | undefined): string {
|
||
return snapshot?.template.title ?? '未选择模板';
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||
}
|
||
|
||
function pushMissing(errors: string[], condition: boolean, field: string) {
|
||
if (!condition) {
|
||
errors.push(field);
|
||
}
|
||
}
|
||
|
||
function isCourseDeliverableType(value: unknown): value is CourseDeliverableType {
|
||
return value === 'markdown' || value === 'html' || value === 'code' || value === 'report';
|
||
}
|
||
|
||
function isRecommendedAgentProfile(value: unknown): value is ProjectTemplateRecommendedAgentProfile {
|
||
return (
|
||
isRecord(value)
|
||
&& isProjectTemplateCourseStageId(value.roleId)
|
||
&& typeof value.displayName === 'string'
|
||
&& value.displayName.trim().length > 0
|
||
&& Array.isArray(value.notes)
|
||
&& value.notes.every((note) => typeof note === 'string')
|
||
);
|
||
}
|
||
|
||
function isProjectTemplateScaffoldCapability(value: unknown): value is ProjectTemplateScaffoldCapability {
|
||
if (!isRecord(value) || typeof value.enabled !== 'boolean') {
|
||
return false;
|
||
}
|
||
if (value.enabled === false) {
|
||
return true;
|
||
}
|
||
return value.kind === 'phaser-2d-casual'
|
||
&& value.entryFile === 'src/main.ts'
|
||
&& value.startCommand === 'pnpm dev'
|
||
&& value.packageManager === 'pnpm';
|
||
}
|
||
|
||
function isProjectTemplateGameDevelopmentCapability(
|
||
value: unknown,
|
||
): value is ProjectTemplateGameDevelopmentCapability {
|
||
if (!isRecord(value) || typeof value.enabled !== 'boolean') {
|
||
return false;
|
||
}
|
||
if (value.enabled === false) {
|
||
return true;
|
||
}
|
||
|
||
const requiredSkillIds = Array.isArray(value.requiredSkillIds) ? value.requiredSkillIds : [];
|
||
const requiredRoleIds = Array.isArray(value.requiredRoleIds) ? value.requiredRoleIds : [];
|
||
return value.engine === 'phaser3'
|
||
&& requiredSkillIds.length > 0
|
||
&& requiredSkillIds.every((skillId) => (
|
||
typeof skillId === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(skillId)
|
||
))
|
||
&& new Set(requiredSkillIds).size === requiredSkillIds.length
|
||
&& requiredRoleIds.length > 0
|
||
&& requiredRoleIds.every((roleId) => (
|
||
typeof roleId === 'string' && gameDevelopmentRoleIds.includes(roleId as GameDevelopmentRoleId)
|
||
))
|
||
&& new Set(requiredRoleIds).size === requiredRoleIds.length
|
||
&& value.designDocument === 'GDD.md'
|
||
&& value.taskDocument === 'TASKS.md'
|
||
&& value.collaborationMode === 'pair';
|
||
}
|
||
|
||
function collectBuiltInContractMismatches(
|
||
actual: unknown,
|
||
expected: unknown,
|
||
path: string,
|
||
mismatches: Set<string>,
|
||
): void {
|
||
if (Array.isArray(expected)) {
|
||
if (!Array.isArray(actual) || actual.length !== expected.length) {
|
||
mismatches.add(path);
|
||
return;
|
||
}
|
||
for (let index = 0; index < expected.length; index += 1) {
|
||
collectBuiltInContractMismatches(actual[index], expected[index], `${path}[${index}]`, mismatches);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (isRecord(expected)) {
|
||
if (!isRecord(actual)) {
|
||
mismatches.add(path);
|
||
return;
|
||
}
|
||
|
||
const actualKeys = Object.keys(actual).sort();
|
||
const expectedKeys = Object.keys(expected).sort();
|
||
if (actualKeys.join('\0') !== expectedKeys.join('\0')) {
|
||
mismatches.add(path);
|
||
}
|
||
|
||
for (const key of expectedKeys) {
|
||
const nextPath = path ? `${path}.${key}` : key;
|
||
if (nextPath === 'template.createdAt') {
|
||
continue;
|
||
}
|
||
collectBuiltInContractMismatches(actual[key], expected[key], nextPath, mismatches);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (actual !== expected) {
|
||
mismatches.add(path);
|
||
}
|
||
}
|
||
|
||
const LEGACY_YOUTH_DEPLOY_STAGE: ProjectTemplateCourseStage = {
|
||
id: 'deploy',
|
||
order: 6,
|
||
title: '发布部署',
|
||
agentName: '部署工程师',
|
||
agentRole: '发布部署',
|
||
goal: '确认作品可以通过 Docker 构建、启动、打包并通过作品广场上传接口提交。',
|
||
task: '创建或维护 Dockerfile 和 docker-compose.yml,检查 Docker 构建状态,把当前项目打成 zip 包,通过作品广场上传接口提交,并记录发布目标、发布步骤、回滚说明和作品广场提交清单。',
|
||
deliverableTitle: '部署报告.md',
|
||
deliverableType: 'report',
|
||
deliverableSummary: 'Dockerfile、docker-compose.yml、发布目标、准备情况、Docker 执行命令、zip 包路径、作品广场上传接口结果、发布步骤、回滚步骤。',
|
||
acceptanceCriteria: [
|
||
'有明确发布目标',
|
||
'有 Dockerfile',
|
||
'有 docker-compose.yml',
|
||
'有 Docker 构建或检查结果',
|
||
'有 zip 包路径',
|
||
'有作品广场上传接口结果',
|
||
'有发布步骤',
|
||
'有回滚步骤',
|
||
'没有隐私或密钥风险',
|
||
],
|
||
handoffTarget: null,
|
||
};
|
||
|
||
const LEGACY_GAME_RELEASE_STAGE: ProjectTemplateCourseStage = {
|
||
id: 'game-release',
|
||
order: 4,
|
||
title: '发布打包',
|
||
agentName: '游戏发布官',
|
||
agentRole: '游戏发布官',
|
||
goal: '准备构建结果、试玩记录和发布检查清单。',
|
||
task: '记录如何运行游戏、试玩了什么,以及发布前后需要检查哪些项目。',
|
||
deliverableTitle: 'RELEASE_CHECKLIST.md',
|
||
deliverableType: 'report',
|
||
deliverableSummary: '构建结果、试玩检查、运行说明、发布检查项和已知风险。',
|
||
acceptanceCriteria: [
|
||
'包含构建命令和结果',
|
||
'包含试玩记录',
|
||
'包含运行说明',
|
||
'包含发布检查项',
|
||
'包含已知风险或问题',
|
||
],
|
||
handoffTarget: 'game-showcase',
|
||
};
|
||
|
||
type LegacyPublishTemplateId = 'youth-ai-course' | 'web-mini-game-course';
|
||
|
||
function createExpectedLegacyPublishContractSnapshot(
|
||
templateId: LegacyPublishTemplateId,
|
||
createdAt: string,
|
||
version: number,
|
||
): ProjectTemplateSnapshot {
|
||
const expected = createProjectTemplateSnapshot(templateId, createdAt);
|
||
expected.template.version = version;
|
||
if (!expected.capabilities.course.enabled) return expected;
|
||
const legacyStage = templateId === 'youth-ai-course' ? LEGACY_YOUTH_DEPLOY_STAGE : LEGACY_GAME_RELEASE_STAGE;
|
||
expected.capabilities.course.stages = expected.capabilities.course.stages.map((stage) => (
|
||
stage.id === legacyStage.id ? structuredClone(legacyStage) : stage
|
||
));
|
||
expected.capabilities.course.slots = expected.capabilities.course.slots.map((slot) => (
|
||
slot.id === legacyStage.id ? { ...slot, goal: legacyStage.goal } : slot
|
||
));
|
||
return expected;
|
||
}
|
||
|
||
function isExactSnapshotMatch(candidate: Record<string, unknown>, expected: ProjectTemplateSnapshot): boolean {
|
||
const mismatches = new Set<string>();
|
||
collectBuiltInContractMismatches(candidate, expected, '', mismatches);
|
||
return mismatches.size === 0;
|
||
}
|
||
|
||
function getLegacySnapshotMeta(
|
||
value: Record<string, unknown>,
|
||
templateId: LegacyPublishTemplateId,
|
||
): { createdAt: string; version: number } | null {
|
||
const template = isRecord(value.template) ? value.template : {};
|
||
const allowedVersions = templateId === 'web-mini-game-course' ? [1, 3] : [1];
|
||
if (template.id !== templateId || !allowedVersions.includes(Number(template.version)) || typeof template.createdAt !== 'string') return null;
|
||
return { createdAt: template.createdAt, version: Number(template.version) };
|
||
}
|
||
|
||
function isLegacyPublishContractSnapshot(value: Record<string, unknown>, templateId: LegacyPublishTemplateId): boolean {
|
||
const meta = getLegacySnapshotMeta(value, templateId);
|
||
return meta !== null && isExactSnapshotMatch(value, createExpectedLegacyPublishContractSnapshot(templateId, meta.createdAt, meta.version));
|
||
}
|
||
|
||
function createExpectedWebMiniGameSnapshotBeforeShowcase(
|
||
createdAt: string,
|
||
releaseContract: 'current' | 'legacy',
|
||
version: number,
|
||
): ProjectTemplateSnapshot {
|
||
const expected = releaseContract === 'legacy'
|
||
? createExpectedLegacyPublishContractSnapshot('web-mini-game-course', createdAt, version)
|
||
: createProjectTemplateSnapshot('web-mini-game-course', createdAt);
|
||
expected.template.version = version;
|
||
if (expected.capabilities.course.enabled) {
|
||
expected.capabilities.course.stages = expected.capabilities.course.stages.filter((stage) => stage.id !== 'game-showcase');
|
||
expected.capabilities.course.slots = expected.capabilities.course.slots.filter((slot) => slot.id !== 'game-showcase');
|
||
const releaseStage = expected.capabilities.course.stages.find((stage) => stage.id === 'game-release');
|
||
if (releaseStage) releaseStage.handoffTarget = null;
|
||
}
|
||
return expected;
|
||
}
|
||
|
||
function isLegacyWebMiniGameSnapshotBeforeShowcase(value: Record<string, unknown>): boolean {
|
||
const meta = getLegacySnapshotMeta(value, 'web-mini-game-course');
|
||
return meta !== null && isExactSnapshotMatch(value, createExpectedWebMiniGameSnapshotBeforeShowcase(meta.createdAt, 'current', meta.version));
|
||
}
|
||
|
||
function isLegacyWebMiniGameSnapshotBeforeShowcaseWithLegacyRelease(value: Record<string, unknown>): boolean {
|
||
const meta = getLegacySnapshotMeta(value, 'web-mini-game-course');
|
||
return meta !== null && isExactSnapshotMatch(value, createExpectedWebMiniGameSnapshotBeforeShowcase(meta.createdAt, 'legacy', meta.version));
|
||
}
|
||
|
||
function normalizeProjectTemplateSnapshotForCompatibility(value: Record<string, unknown>): Record<string, unknown> {
|
||
const template = isRecord(value.template) ? value.template : {};
|
||
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
||
const chat = isRecord(capabilities.chat) ? capabilities.chat : {};
|
||
const builtInTemplate = builtInProjectTemplates.find((candidate) => (
|
||
candidate.template.id === template.id && candidate.template.version === template.version
|
||
));
|
||
|
||
let normalized: Record<string, unknown> | null = null;
|
||
|
||
if (template.id === 'youth-ai-course' && template.version === 1 && chat.defaultRoute === '/subagents') {
|
||
normalized = structuredClone(value) as Record<string, unknown>;
|
||
const normalizedCapabilities = isRecord(normalized.capabilities) ? normalized.capabilities : {};
|
||
const normalizedChat = isRecord(normalizedCapabilities.chat) ? normalizedCapabilities.chat : {};
|
||
normalizedChat.defaultRoute = '/opencode-chat';
|
||
}
|
||
|
||
const expectedScaffold = builtInTemplate?.capabilities.scaffold;
|
||
if (expectedScaffold?.enabled === false && capabilities.scaffold === undefined) {
|
||
normalized ??= structuredClone(value) as Record<string, unknown>;
|
||
const normalizedCapabilities = isRecord(normalized.capabilities) ? normalized.capabilities : {};
|
||
normalizedCapabilities.scaffold = { enabled: false };
|
||
}
|
||
|
||
const candidate = normalized ?? value;
|
||
const candidateTemplate = isRecord(candidate.template) ? candidate.template : {};
|
||
const createdAt = typeof candidateTemplate.createdAt === 'string' ? candidateTemplate.createdAt : null;
|
||
if (createdAt !== null) {
|
||
if (isLegacyPublishContractSnapshot(candidate, 'youth-ai-course')) {
|
||
return createProjectTemplateSnapshot('youth-ai-course', createdAt);
|
||
}
|
||
if (
|
||
isLegacyPublishContractSnapshot(candidate, 'web-mini-game-course')
|
||
|| isLegacyWebMiniGameSnapshotBeforeShowcase(candidate)
|
||
|| isLegacyWebMiniGameSnapshotBeforeShowcaseWithLegacyRelease(candidate)
|
||
) {
|
||
return createProjectTemplateSnapshot('web-mini-game-course', createdAt);
|
||
}
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
export function validateProjectTemplateSnapshot(value: unknown): ProjectTemplateValidationResult {
|
||
const errors: string[] = [];
|
||
|
||
if (!isRecord(value)) {
|
||
return { ok: false, error: 'snapshot must be an object' };
|
||
}
|
||
|
||
const normalizedValue = normalizeProjectTemplateSnapshotForCompatibility(value);
|
||
|
||
pushMissing(errors, normalizedValue.schemaVersion === PROJECT_TEMPLATE_SCHEMA_VERSION, 'schemaVersion');
|
||
|
||
const template = isRecord(normalizedValue.template) ? normalizedValue.template : {};
|
||
pushMissing(errors, isProjectTemplateId(template.id), 'template.id');
|
||
pushMissing(errors, Number.isInteger(template.version) && Number(template.version) > 0, 'template.version');
|
||
pushMissing(errors, typeof template.title === 'string' && template.title.trim().length > 0, 'template.title');
|
||
pushMissing(
|
||
errors,
|
||
typeof template.description === 'string' && template.description.trim().length > 0,
|
||
'template.description',
|
||
);
|
||
pushMissing(
|
||
errors,
|
||
typeof template.createdAt === 'string' && template.createdAt.trim().length > 0,
|
||
'template.createdAt',
|
||
);
|
||
|
||
const capabilities = isRecord(normalizedValue.capabilities) ? normalizedValue.capabilities : {};
|
||
const chat = isRecord(capabilities.chat) ? capabilities.chat : {};
|
||
const course = isRecord(capabilities.course) ? capabilities.course : {};
|
||
const publish = isRecord(capabilities.publish) ? capabilities.publish : {};
|
||
const scaffold = capabilities.scaffold;
|
||
const gameDevelopment = capabilities.gameDevelopment;
|
||
|
||
pushMissing(errors, typeof chat.enabled === 'boolean', 'capabilities.chat.enabled');
|
||
pushMissing(errors, typeof course.enabled === 'boolean', 'capabilities.course.enabled');
|
||
pushMissing(errors, typeof publish.enabled === 'boolean', 'capabilities.publish.enabled');
|
||
if (scaffold !== undefined) {
|
||
pushMissing(errors, isProjectTemplateScaffoldCapability(scaffold), 'capabilities.scaffold');
|
||
}
|
||
if (gameDevelopment !== undefined) {
|
||
pushMissing(
|
||
errors,
|
||
isProjectTemplateGameDevelopmentCapability(gameDevelopment),
|
||
'capabilities.gameDevelopment',
|
||
);
|
||
}
|
||
|
||
if (chat.enabled === true) {
|
||
pushMissing(
|
||
errors,
|
||
chat.defaultRoute === '/opencode-chat' || chat.defaultRoute === '/subagents',
|
||
'capabilities.chat.defaultRoute',
|
||
);
|
||
}
|
||
|
||
if (course.enabled === true) {
|
||
pushMissing(
|
||
errors,
|
||
typeof course.title === 'string' && course.title.trim().length > 0,
|
||
'capabilities.course.title',
|
||
);
|
||
|
||
const stages = Array.isArray(course.stages) ? course.stages : [];
|
||
const slots = Array.isArray(course.slots) ? course.slots : [];
|
||
|
||
pushMissing(errors, stages.length > 0, 'capabilities.course.stages');
|
||
pushMissing(errors, slots.length > 0, 'capabilities.course.slots');
|
||
|
||
const stageIds = new Set<string>();
|
||
for (const stage of stages) {
|
||
if (!isRecord(stage)) {
|
||
errors.push('capabilities.course.stages.item');
|
||
continue;
|
||
}
|
||
|
||
const stageId = typeof stage.id === 'string' ? stage.id.trim() : '';
|
||
pushMissing(errors, isProjectTemplateCourseStageId(stage.id) && !stageIds.has(stageId), 'capabilities.course.stages.id');
|
||
pushMissing(errors, Number.isInteger(stage.order) && Number(stage.order) > 0, 'capabilities.course.stages.order');
|
||
pushMissing(errors, typeof stage.title === 'string' && stage.title.trim().length > 0, 'capabilities.course.stages.title');
|
||
pushMissing(errors, typeof stage.agentName === 'string' && stage.agentName.trim().length > 0, 'capabilities.course.stages.agentName');
|
||
pushMissing(errors, typeof stage.agentRole === 'string' && stage.agentRole.trim().length > 0, 'capabilities.course.stages.agentRole');
|
||
pushMissing(errors, typeof stage.goal === 'string' && stage.goal.trim().length > 0, 'capabilities.course.stages.goal');
|
||
pushMissing(errors, typeof stage.task === 'string' && stage.task.trim().length > 0, 'capabilities.course.stages.task');
|
||
pushMissing(errors, typeof stage.deliverableTitle === 'string' && stage.deliverableTitle.trim().length > 0, 'capabilities.course.stages.deliverableTitle');
|
||
pushMissing(errors, isCourseDeliverableType(stage.deliverableType), 'capabilities.course.stages.deliverableType');
|
||
pushMissing(errors, typeof stage.deliverableSummary === 'string' && stage.deliverableSummary.trim().length > 0, 'capabilities.course.stages.deliverableSummary');
|
||
pushMissing(
|
||
errors,
|
||
Array.isArray(stage.acceptanceCriteria)
|
||
&& stage.acceptanceCriteria.length > 0
|
||
&& stage.acceptanceCriteria.every(
|
||
(criterion) => typeof criterion === 'string' && criterion.trim().length > 0,
|
||
),
|
||
'capabilities.course.stages.acceptanceCriteria',
|
||
);
|
||
pushMissing(
|
||
errors,
|
||
stage.handoffTarget === null || isProjectTemplateCourseStageId(stage.handoffTarget),
|
||
'capabilities.course.stages.handoffTarget',
|
||
);
|
||
|
||
if (stageId) {
|
||
stageIds.add(stageId);
|
||
}
|
||
}
|
||
|
||
const slotIds = new Set<string>();
|
||
for (const slot of slots) {
|
||
if (!isRecord(slot)) {
|
||
errors.push('capabilities.course.slots.item');
|
||
continue;
|
||
}
|
||
|
||
const slotId = typeof slot.id === 'string' ? slot.id.trim() : '';
|
||
pushMissing(errors, isProjectTemplateCourseStageId(slot.id) && !slotIds.has(slotId), 'capabilities.course.slots.id');
|
||
pushMissing(errors, typeof slot.title === 'string' && slot.title.trim().length > 0, 'capabilities.course.slots.title');
|
||
pushMissing(errors, typeof slot.agentName === 'string' && slot.agentName.trim().length > 0, 'capabilities.course.slots.agentName');
|
||
pushMissing(errors, typeof slot.agentRole === 'string' && slot.agentRole.trim().length > 0, 'capabilities.course.slots.agentRole');
|
||
pushMissing(errors, typeof slot.goal === 'string' && slot.goal.trim().length > 0, 'capabilities.course.slots.goal');
|
||
pushMissing(errors, isRecommendedAgentProfile(slot.recommendedAgentProfile), 'capabilities.course.slots.recommendedAgentProfile');
|
||
|
||
if (slotId) {
|
||
slotIds.add(slotId);
|
||
}
|
||
}
|
||
|
||
pushMissing(
|
||
errors,
|
||
slotIds.size === stageIds.size && [...stageIds].every((stageId) => slotIds.has(stageId)),
|
||
'capabilities.course.slotStageAlignment',
|
||
);
|
||
|
||
}
|
||
|
||
if (publish.enabled === true) {
|
||
pushMissing(errors, publish.provider === 'works-square', 'capabilities.publish.provider');
|
||
}
|
||
|
||
const snapshotId = isProjectTemplateId(template.id) ? template.id : null;
|
||
const snapshotVersion = Number.isInteger(template.version) ? Number(template.version) : null;
|
||
if (snapshotId && snapshotVersion !== null) {
|
||
const builtInTemplate = builtInProjectTemplates.find((candidate) => (
|
||
candidate.template.id === snapshotId && candidate.template.version === snapshotVersion
|
||
));
|
||
if (!builtInTemplate) {
|
||
errors.push(`built-in contract ${snapshotId}@${snapshotVersion}`);
|
||
} else {
|
||
const expectedSnapshot = createProjectTemplateSnapshot(snapshotId, String(template.createdAt ?? builtInTemplate.template.createdAt));
|
||
const mismatches = new Set<string>();
|
||
collectBuiltInContractMismatches(normalizedValue, expectedSnapshot, '', mismatches);
|
||
if (mismatches.size > 0) {
|
||
errors.push(
|
||
`built-in contract ${snapshotId}@${snapshotVersion}: ${Array.from(mismatches)
|
||
.filter(Boolean)
|
||
.join(', ')}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (errors.length > 0) {
|
||
return {
|
||
ok: false,
|
||
error: `Invalid project template snapshot: ${Array.from(new Set(errors)).join(', ')}`,
|
||
};
|
||
}
|
||
|
||
return { ok: true, snapshot: normalizedValue as ProjectTemplateSnapshot };
|
||
}
|