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 { 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, ): 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, expected: ProjectTemplateSnapshot): boolean { const mismatches = new Set(); collectBuiltInContractMismatches(candidate, expected, '', mismatches); return mismatches.size === 0; } function getLegacySnapshotMeta( value: Record, 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, 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): boolean { const meta = getLegacySnapshotMeta(value, 'web-mini-game-course'); return meta !== null && isExactSnapshotMatch(value, createExpectedWebMiniGameSnapshotBeforeShowcase(meta.createdAt, 'current', meta.version)); } function isLegacyWebMiniGameSnapshotBeforeShowcaseWithLegacyRelease(value: Record): boolean { const meta = getLegacySnapshotMeta(value, 'web-mini-game-course'); return meta !== null && isExactSnapshotMatch(value, createExpectedWebMiniGameSnapshotBeforeShowcase(meta.createdAt, 'legacy', meta.version)); } function normalizeProjectTemplateSnapshotForCompatibility(value: Record): Record { 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 | null = null; if (template.id === 'youth-ai-course' && template.version === 1 && chat.defaultRoute === '/subagents') { normalized = structuredClone(value) as Record; 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; 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(); 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(); 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(); 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 }; }